Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a309b283b | |||
| be71ac406b | |||
| bb6bd8efb9 | |||
| 29f74dd3cc | |||
| bdced83b46 | |||
| fa98e4722e | |||
| de1cb40456 | |||
| dc73fc4e31 | |||
| cf2af6ce1e | |||
| 5397f09785 | |||
| 7d31de5a6b | |||
| 20215650f2 | |||
| f2b65cd5d2 | |||
| 9a25945567 | |||
| e80d45f4b3 | |||
| 4ed0238a25 | |||
| 774c0660b0 | |||
| cbac8b9c4f | |||
| ad3f2d6e99 | |||
| 87411e5fb1 | |||
| 4bb7555459 | |||
| 9df223907b | |||
| f8ec77de8a | |||
| 6397ea1cc9 | |||
| 07bfcea8cc | |||
| 59a0a08d32 | |||
| 1b8ae3c3e5 | |||
| 56051e58c0 | |||
| d63619bd0e | |||
| fb9b719f0b | |||
| 2b38390c87 | |||
| 0ff4562e60 | |||
| bb7c460d12 | |||
| dd98da5c42 | |||
| 9e2abea16b | |||
| b481792c02 | |||
| 5865699a5a | |||
| b6a21c17eb | |||
| 494c719a5d | |||
| dad3cd0738 | |||
| a1e4743492 | |||
| 2ef470c2ba | |||
| 80f807d2ad | |||
| e9049ad27e | |||
| 431e052e6f | |||
| 0a2e194e76 | |||
| 18900dd613 | |||
| 2d42e98871 | |||
| 4a17821b06 | |||
| 4b24fbad98 | |||
| 8d83c66d11 | |||
| a6a62a2d56 |
File diff suppressed because it is too large
Load Diff
@@ -45,5 +45,5 @@ func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text st
|
||||
// The sentinel cannot be renamed into an enabled function, and the original
|
||||
// utterance remains the authority even when a model rewrites Slots.Text.
|
||||
func refusesCommand(dec router.Decision) bool {
|
||||
return dec.Slots.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
return dec.CapabilitySelection.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestRefusesCommandUsesCapabilitySelection proves that refusesCommand reads
|
||||
// CapabilitySelection.Fn rather than the compatibility Slots.Fn. When
|
||||
// CapabilitySelection is populated with the prohibited sentinel but Slots.Fn
|
||||
// is blank, the refusal must still fire.
|
||||
func TestRefusesCommandUsesCapabilitySelection(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: router.ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
// Slots compatibility fields deliberately blank.
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true when CapabilitySelection.Fn == ProhibitedActFn")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandUtteranceFallback proves that the utterance-based
|
||||
// prohibition check still works as a defense-in-depth belt when
|
||||
// CapabilitySelection does not carry the sentinel.
|
||||
func TestRefusesCommandUtteranceFallback(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Utterance: "don't restart nginx",
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true for a prohibited utterance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandNonProhibitedCapability proves that an act with a
|
||||
// non-prohibited capability is NOT refused even when Slots.Fn happens to
|
||||
// carry the prohibited sentinel (cross-contamination).
|
||||
func TestRefusesCommandNonProhibitedCapability(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
},
|
||||
}
|
||||
if refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return false for a non-prohibited capability")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandProhibitedSentinelPreservedThroughPipeline proves that the
|
||||
// command-prohibition grammar sentinel survives through SelectCapability into
|
||||
// CapabilitySelection byte-for-byte.
|
||||
func TestRefusesCommandProhibitedSentinelPreservedThroughPipeline(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{
|
||||
Fn: router.ProhibitedActFn,
|
||||
HasFn: true,
|
||||
},
|
||||
}
|
||||
sel := router.SelectCapability(dec, nil)
|
||||
dec.CapabilitySelection = sel
|
||||
|
||||
if dec.CapabilitySelection.Fn != router.ProhibitedActFn {
|
||||
t.Errorf("CapabilitySelection.Fn = %q, want %q", dec.CapabilitySelection.Fn, router.ProhibitedActFn)
|
||||
}
|
||||
if !dec.CapabilitySelection.Resolved {
|
||||
t.Error("CapabilitySelection.Resolved should be true")
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true after pipeline preserves the sentinel")
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialog
|
||||
r.err = router.ErrNoIntents
|
||||
return
|
||||
}
|
||||
r.dec, r.err = r.h.router.Route(ctx, r.input.Text, r.now)
|
||||
r.dec, r.err = r.h.router.Route(ctx, r.input, r.now)
|
||||
})
|
||||
return r.dec, r.cont, r.prev, r.err
|
||||
}
|
||||
|
||||
+2
-2
@@ -211,7 +211,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
|
||||
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
|
||||
// action → replier), identical to the text path.
|
||||
replyText := h.runTurn(ctx, router.NormalizedInput{Text: text, Source: sourceVoice})
|
||||
replyText := h.runTurn(ctx, router.NormalizedInput{Text: text, MatchText: router.NormalizeMatchText(text), Source: sourceVoice})
|
||||
|
||||
// 6. tts — synthesise the reply text; return to the voice server which
|
||||
// ships it back on the conn.
|
||||
@@ -244,7 +244,7 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
||||
// HandlePushToTalk so text channels share the same routing logic.
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||||
log.Printf("voice: handleText: %q", text)
|
||||
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), router.NormalizedInput{Text: text, Source: sourceText})
|
||||
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), router.NormalizedInput{Text: text, MatchText: router.NormalizeMatchText(text), Source: sourceText})
|
||||
}
|
||||
|
||||
// turnSource is a local alias for router.InputSource, kept so the daemon code
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// merge-corpus merges the frozen holdout from corpus_v1.json with the
|
||||
// expanded v2 development pool, writing the result back to corpus_v1.json.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/merge-corpus/ -v1 internal/router/semantic/corpus_v1.json \
|
||||
// -v2 /tmp/corpus_v2.json -out internal/router/semantic/corpus_v1.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router/semantic"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v1Path := flag.String("v1", "internal/router/semantic/corpus_v1.json", "v1 corpus path")
|
||||
v2Path := flag.String("v2", "/tmp/corpus_v2.json", "v2 factory output path")
|
||||
outPath := flag.String("out", "internal/router/semantic/corpus_v1.json", "output path")
|
||||
flag.Parse()
|
||||
|
||||
// 1. Load v1
|
||||
v1Data, err := os.ReadFile(*v1Path)
|
||||
if err != nil {
|
||||
log.Fatalf("read v1: %v", err)
|
||||
}
|
||||
var v1Env semantic.CorpusEnvelope
|
||||
if err := json.Unmarshal(v1Data, &v1Env); err != nil {
|
||||
log.Fatalf("parse v1: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "v1: %d examples\n", len(v1Env.Examples))
|
||||
|
||||
// 2. Identify frozen holdout from v1
|
||||
frozen, _, _ := semantic.FrozenHoldoutSplit(v1Env.Examples)
|
||||
frozenTexts := make(map[string]bool)
|
||||
for _, e := range frozen {
|
||||
frozenTexts[strings.TrimSpace(e.Text)] = true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "v1 frozen holdout: %d examples\n", len(frozen))
|
||||
|
||||
// 3. Load v2
|
||||
v2Data, err := os.ReadFile(*v2Path)
|
||||
if err != nil {
|
||||
log.Fatalf("read v2: %v", err)
|
||||
}
|
||||
var v2Env semantic.CorpusEnvelope
|
||||
if err := json.Unmarshal(v2Data, &v2Env); err != nil {
|
||||
log.Fatalf("parse v2: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "v2: %d examples\n", len(v2Env.Examples))
|
||||
|
||||
// 4. Merge: frozen from v1 + all from v2
|
||||
// Dedup by normalized text
|
||||
seen := make(map[string]bool)
|
||||
var merged []semantic.RouteExample
|
||||
|
||||
// Frozen holdout first
|
||||
for _, e := range frozen {
|
||||
norm := strings.TrimSpace(e.Text)
|
||||
if seen[norm] {
|
||||
fmt.Fprintf(os.Stderr, "SKIP v1 frozen dup: %q\n", norm)
|
||||
continue
|
||||
}
|
||||
seen[norm] = true
|
||||
merged = append(merged, e)
|
||||
}
|
||||
|
||||
// V2 examples
|
||||
skipped := 0
|
||||
for _, e := range v2Env.Examples {
|
||||
norm := strings.TrimSpace(e.Text)
|
||||
if seen[norm] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// Check if this text conflicts with a frozen holdout entry
|
||||
if frozenTexts[norm] {
|
||||
// Text exists in frozen — skip v2 version to preserve frozen
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen[norm] = true
|
||||
merged = append(merged, e)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "merged: %d examples (skipped %d duplicates)\n", len(merged), skipped)
|
||||
|
||||
// 5. Fast-path metadata must match the real router. V2 rows are derived
|
||||
// by interactively-labeled construction; a stale manually-supplied value
|
||||
// is a build error, never silently rewritten. Frozen holdout rows are
|
||||
// preserved verbatim — report drift, do not touch them.
|
||||
v2Checked, frozenChecked, frozenDrift := 0, 0, 0
|
||||
for _, e := range merged {
|
||||
derived := semantic.DeriveFastPath(e.Text).Matched
|
||||
if frozenTexts[strings.TrimSpace(e.Text)] {
|
||||
frozenChecked++
|
||||
if derived != e.FastPathResolved {
|
||||
frozenDrift++
|
||||
fmt.Fprintf(os.Stderr, "frozen drift: %q stored=%v derived=%v\n", e.Text, e.FastPathResolved, derived)
|
||||
}
|
||||
continue
|
||||
}
|
||||
v2Checked++
|
||||
if derived != e.FastPathResolved {
|
||||
log.Fatalf("v2 row (source=%s source_id=%s) fast_path_resolved=%v but router derives %v: %q",
|
||||
e.Source, e.SourceID, e.FastPathResolved, derived, e.Text)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fast-path check: v2 rows %d OK, frozen rows %d (drift %d, reported only)\n",
|
||||
v2Checked, frozenChecked, frozenDrift)
|
||||
|
||||
// 6. Validate
|
||||
if err := semantic.ValidateCorpus(merged); err != nil {
|
||||
log.Fatalf("validation failed: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "validation: OK\n")
|
||||
|
||||
// 7. Compute dataset hash
|
||||
texts := make([]string, len(merged))
|
||||
for i, e := range merged {
|
||||
texts[i] = e.Text
|
||||
}
|
||||
sort.Strings(texts)
|
||||
h := sha256.Sum256([]byte(strings.Join(texts, "\n")))
|
||||
datasetHash := hex.EncodeToString(h[:16])
|
||||
|
||||
// 8. Stats
|
||||
routeCounts := make(map[semantic.SemanticRoute]int)
|
||||
fpCount, resCount := 0, 0
|
||||
for _, e := range merged {
|
||||
routeCounts[e.Route]++
|
||||
if e.FastPathResolved {
|
||||
fpCount++
|
||||
} else {
|
||||
resCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nRoute distribution:\n")
|
||||
for _, r := range semantic.AllRoutes {
|
||||
fmt.Fprintf(os.Stderr, " %-15s %d\n", r, routeCounts[r])
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fast-path: %d residual: %d\n", fpCount, resCount)
|
||||
|
||||
// 9. Write merged corpus
|
||||
outEnv := semantic.CorpusEnvelope{
|
||||
SchemaVersion: 1,
|
||||
Name: "semantic_coarse_route_v1",
|
||||
Notes: []string{
|
||||
"Coarse semantic route corpus for the learned-router experiment.",
|
||||
"Frozen holdout preserved from v1. Development pool expanded by corpus-factory v2.",
|
||||
"Every row carries provenance (source + source_id) and a split_group.",
|
||||
"Labels come from explicit mapping rules, not model output.",
|
||||
},
|
||||
Reproducibility: &semantic.ReproducibilityMeta{
|
||||
SourceFixtureHash: "v1-frozen + corpus-factory-v2",
|
||||
ContrastGeneratorVersion: "v2-direct-generation",
|
||||
SplitAlgorithm: "grouped-cv-v1",
|
||||
DatasetHash: datasetHash,
|
||||
},
|
||||
Examples: merged,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(outEnv, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, data, 0644); err != nil {
|
||||
log.Fatalf("write %s: %v", *outPath, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nOutput: %s (%d bytes)\n", *outPath, len(data))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Semantic Router Linear Head Experiment
|
||||
======================================
|
||||
|
||||
Evaluates whether the six-way residual routing boundary is linearly learnable
|
||||
from Maven's existing e5-small representation.
|
||||
|
||||
Architecture under test:
|
||||
NormalizedInput.MatchText
|
||||
→ existing multilingual-e5-small embedder
|
||||
→ tiny 6-class linear head (softmax logistic regression)
|
||||
→ SemanticRouteDecision
|
||||
|
||||
No new encoder. No fine-tuning. Linear separability floor only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from sklearn.exceptions import ConvergenceWarning
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.metrics import (
|
||||
accuracy_score,
|
||||
f1_score,
|
||||
precision_recall_fscore_support,
|
||||
confusion_matrix,
|
||||
brier_score_loss,
|
||||
log_loss,
|
||||
)
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
|
||||
warnings.filterwarnings("ignore", category=ConvergenceWarning)
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
ROUTES = ["action", "conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
ROUTE_IDX = {r: i for i, r in enumerate(ROUTES)}
|
||||
|
||||
# Regularization grid
|
||||
C_VALUES = [0.01, 0.1, 1.0, 10.0, 100.0]
|
||||
|
||||
# Abstention thresholds
|
||||
THRESHOLDS = [0.40, 0.50, 0.60, 0.70, 0.80, 0.90]
|
||||
|
||||
# Contrast families
|
||||
CONTRAST_FAMILIES = [
|
||||
"negation", "question", "reported_speech", "quotation",
|
||||
"hypothetical", "capability_question",
|
||||
]
|
||||
|
||||
# ─── Data Loading ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_embeddings(path):
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
meta = data["meta"]
|
||||
examples = data["examples"]
|
||||
return meta, examples
|
||||
|
||||
|
||||
def filter_dev_pool(examples):
|
||||
"""Return only development pool examples (not frozen holdout)."""
|
||||
return [e for e in examples if e["dev_pool"]]
|
||||
|
||||
|
||||
def filter_residual(examples):
|
||||
"""Return only router-residual examples (fast_path_resolved == false)."""
|
||||
return [e for e in examples if not e["fast_path_resolved"]]
|
||||
|
||||
|
||||
def extract_Xy(examples):
|
||||
"""Extract feature matrix and label vector from cached examples."""
|
||||
X = np.array([e["embedding"] for e in examples])
|
||||
y = np.array([e["route"] for e in examples])
|
||||
return X, y
|
||||
|
||||
|
||||
def get_fold_groups(examples):
|
||||
"""Return fold assignment array matching the Go-generated CV folds."""
|
||||
return np.array([e["cv_fold"] for e in examples])
|
||||
|
||||
|
||||
# ─── Grouped CV ─────────────────────────────────────────────────────────────
|
||||
|
||||
def grouped_cv_experiment(X, y, fold_ids, C_values, examples_meta):
|
||||
"""
|
||||
Run grouped cross-validation with logistic regression.
|
||||
Returns best C, per-fold results, and out-of-fold predictions.
|
||||
"""
|
||||
unique_folds = sorted(set(fold_ids))
|
||||
n_classes = len(ROUTES)
|
||||
|
||||
results_by_C = {}
|
||||
for C in C_values:
|
||||
fold_metrics = []
|
||||
oof_rows = []
|
||||
|
||||
for test_fold in unique_folds:
|
||||
train_mask = fold_ids != test_fold
|
||||
test_mask = fold_ids == test_fold
|
||||
|
||||
X_train, y_train = X[train_mask], y[train_mask]
|
||||
X_test, y_test = X[test_mask], y[test_mask]
|
||||
|
||||
model = LogisticRegression(
|
||||
C=C, max_iter=2000, solver="lbfgs",
|
||||
random_state=42,
|
||||
)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
y_pred = model.predict(X_test)
|
||||
y_proba = model.predict_proba(X_test)
|
||||
classes = model.classes_
|
||||
|
||||
acc = accuracy_score(y_test, y_pred)
|
||||
macro_f1 = f1_score(y_test, y_pred, average="macro", zero_division=0)
|
||||
|
||||
prec, rec, f1, sup = precision_recall_fscore_support(
|
||||
y_test, y_pred, labels=ROUTES, zero_division=0
|
||||
)
|
||||
|
||||
false_action = 0
|
||||
for true, pred in zip(y_test, y_pred):
|
||||
if true != "action" and pred == "action":
|
||||
false_action += 1
|
||||
|
||||
fold_metrics.append({
|
||||
"fold": test_fold,
|
||||
"train_size": len(X_train),
|
||||
"test_size": len(X_test),
|
||||
"accuracy": acc,
|
||||
"macro_f1": macro_f1,
|
||||
"per_class": {
|
||||
route: {"precision": prec[i], "recall": rec[i], "f1": f1[i]}
|
||||
for i, route in enumerate(ROUTES)
|
||||
},
|
||||
"false_action": false_action,
|
||||
})
|
||||
|
||||
for i, (true, pred) in enumerate(zip(y_test, y_pred)):
|
||||
test_indices = np.where(test_mask)[0]
|
||||
meta = examples_meta[test_indices[i]]
|
||||
proba_dict = {cls: float(y_proba[i][j]) for j, cls in enumerate(classes)}
|
||||
max_proba = max(proba_dict.values()) if proba_dict else 0.0
|
||||
oof_rows.append({
|
||||
"source_id": meta["source_id"],
|
||||
"fold": test_fold,
|
||||
"true": true,
|
||||
"predicted": pred,
|
||||
"correct": true == pred,
|
||||
"max_proba": max_proba,
|
||||
"proba": proba_dict,
|
||||
"fast_path_resolved": meta.get("fast_path_resolved", False),
|
||||
"tags": meta.get("tags", []),
|
||||
"text": meta["text"],
|
||||
})
|
||||
|
||||
# Aggregate across folds
|
||||
mean_acc = np.mean([m["accuracy"] for m in fold_metrics])
|
||||
mean_f1 = np.mean([m["macro_f1"] for m in fold_metrics])
|
||||
std_acc = np.std([m["accuracy"] for m in fold_metrics])
|
||||
std_f1 = np.std([m["macro_f1"] for m in fold_metrics])
|
||||
total_fa = sum(m["false_action"] for m in fold_metrics)
|
||||
|
||||
results_by_C[C] = {
|
||||
"mean_accuracy": mean_acc,
|
||||
"std_accuracy": std_acc,
|
||||
"mean_macro_f1": mean_f1,
|
||||
"std_macro_f1": std_f1,
|
||||
"total_false_action": total_fa,
|
||||
"fold_metrics": fold_metrics,
|
||||
"oof_predictions": oof_rows,
|
||||
}
|
||||
|
||||
# Select best C by mean macro F1
|
||||
best_C = max(results_by_C, key=lambda c: results_by_C[c]["mean_macro_f1"])
|
||||
return best_C, results_by_C
|
||||
|
||||
|
||||
# ─── Metrics Computation ────────────────────────────────────────────────────
|
||||
|
||||
def compute_full_metrics(y_true, y_pred, y_proba=None):
|
||||
"""Compute all required metrics from out-of-fold predictions."""
|
||||
acc = accuracy_score(y_true, y_pred)
|
||||
macro_f1 = f1_score(y_true, y_pred, average="macro", zero_division=0)
|
||||
|
||||
# Per-class P/R/F1
|
||||
prec, rec, f1, sup = precision_recall_fscore_support(
|
||||
y_true, y_pred, labels=ROUTES, zero_division=0
|
||||
)
|
||||
|
||||
# Confusion matrix
|
||||
cm = confusion_matrix(y_true, y_pred, labels=ROUTES)
|
||||
|
||||
# False action
|
||||
false_action = 0
|
||||
false_action_predicted = []
|
||||
for true, pred in zip(y_true, y_pred):
|
||||
if true != "action" and pred == "action":
|
||||
false_action += 1
|
||||
|
||||
# Action precision/recall
|
||||
action_tp = sum(1 for t, p in zip(y_true, y_pred) if t == "action" and p == "action")
|
||||
action_fp = sum(1 for t, p in zip(y_true, y_pred) if t != "action" and p == "action")
|
||||
action_fn = sum(1 for t, p in zip(y_true, y_pred) if t == "action" and p != "action")
|
||||
action_precision = action_tp / max(action_tp + action_fp, 1)
|
||||
action_recall = action_tp / max(action_tp + action_fn, 1)
|
||||
|
||||
# Uncertain precision/recall
|
||||
unc_tp = sum(1 for t, p in zip(y_true, y_pred) if t == "uncertain" and p == "uncertain")
|
||||
unc_fp = sum(1 for t, p in zip(y_true, y_pred) if t != "uncertain" and p == "uncertain")
|
||||
unc_fn = sum(1 for t, p in zip(y_true, y_pred) if t == "uncertain" and p != "uncertain")
|
||||
unc_precision = unc_tp / max(unc_tp + unc_fp, 1)
|
||||
unc_recall = unc_tp / max(unc_tp + unc_fn, 1)
|
||||
|
||||
metrics = {
|
||||
"accuracy": acc,
|
||||
"macro_f1": macro_f1,
|
||||
"false_action_count": false_action,
|
||||
"false_action_rate": false_action / max(len(y_true), 1),
|
||||
"action_precision": action_precision,
|
||||
"action_recall": action_recall,
|
||||
"uncertain_precision": unc_precision,
|
||||
"uncertain_recall": unc_recall,
|
||||
"per_class": {},
|
||||
"confusion_matrix": cm.tolist(),
|
||||
}
|
||||
|
||||
for i, route in enumerate(ROUTES):
|
||||
metrics["per_class"][route] = {
|
||||
"precision": float(prec[i]),
|
||||
"recall": float(rec[i]),
|
||||
"f1": float(f1[i]),
|
||||
"support": int(sup[i]),
|
||||
}
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def compute_calibration(y_true, y_pred, oof_rows):
|
||||
"""Compute ECE, Brier score, and per-threshold abstention curves."""
|
||||
# ECE (Expected Calibration Error) with 10 bins
|
||||
n_bins = 10
|
||||
bin_boundaries = np.linspace(0, 1, n_bins + 1)
|
||||
ece = 0.0
|
||||
total = len(y_true)
|
||||
|
||||
confidences = np.array([r["max_proba"] for r in oof_rows])
|
||||
correct = np.array([r["correct"] for r in oof_rows])
|
||||
|
||||
for i in range(n_bins):
|
||||
lo, hi = bin_boundaries[i], bin_boundaries[i + 1]
|
||||
mask = (confidences > lo) & (confidences <= hi)
|
||||
if mask.sum() == 0:
|
||||
continue
|
||||
bin_acc = correct[mask].mean()
|
||||
bin_conf = confidences[mask].mean()
|
||||
ece += mask.sum() / total * abs(bin_acc - bin_conf)
|
||||
|
||||
# Brier score (multiclass one-hot encoding)
|
||||
y_true_idx = np.array([ROUTES.index(t) for t in y_true])
|
||||
n_samples = len(y_true)
|
||||
n_classes = len(ROUTES)
|
||||
y_true_oh = np.zeros((n_samples, n_classes))
|
||||
y_true_oh[np.arange(n_samples), y_true_idx] = 1.0
|
||||
|
||||
# Build probability matrix
|
||||
y_proba_matrix = np.zeros((n_samples, n_classes))
|
||||
for i, r in enumerate(oof_rows):
|
||||
for j, route in enumerate(ROUTES):
|
||||
y_proba_matrix[i, j] = r["proba"].get(route, 0.0)
|
||||
|
||||
brier = np.mean(np.sum((y_proba_matrix - y_true_oh) ** 2, axis=1))
|
||||
ll = log_loss(y_true, y_proba_matrix, labels=ROUTES)
|
||||
|
||||
# Abstention curves
|
||||
abstention_curves = []
|
||||
for thr in THRESHOLDS:
|
||||
accepted_mask = confidences >= thr
|
||||
n_accepted = accepted_mask.sum()
|
||||
coverage = n_accepted / max(total, 1)
|
||||
|
||||
if n_accepted > 0:
|
||||
acc_accepted = accuracy_score(y_true[accepted_mask], y_pred[accepted_mask])
|
||||
f1_accepted = f1_score(
|
||||
y_true[accepted_mask], y_pred[accepted_mask],
|
||||
average="macro", zero_division=0,
|
||||
)
|
||||
fa_count = sum(
|
||||
1 for t, p in zip(y_true[accepted_mask], y_pred[accepted_mask])
|
||||
if t != "action" and p == "action"
|
||||
)
|
||||
else:
|
||||
acc_accepted = 0.0
|
||||
f1_accepted = 0.0
|
||||
fa_count = 0
|
||||
|
||||
abstention_curves.append({
|
||||
"threshold": thr,
|
||||
"n_accepted": int(n_accepted),
|
||||
"coverage": coverage,
|
||||
"accuracy": acc_accepted,
|
||||
"macro_f1": f1_accepted,
|
||||
"false_action_count": fa_count,
|
||||
})
|
||||
|
||||
return {
|
||||
"ece": float(ece),
|
||||
"brier": float(brier),
|
||||
"log_loss": float(ll),
|
||||
"abstention_curves": abstention_curves,
|
||||
}
|
||||
|
||||
|
||||
def compute_action_threshold(oof_rows):
|
||||
"""Evaluate action-specific safety gate thresholds."""
|
||||
action_curves = []
|
||||
for thr in THRESHOLDS:
|
||||
action_pred = []
|
||||
for r in oof_rows:
|
||||
p = r["predicted"]
|
||||
proba = r["proba"].get("action", 0.0)
|
||||
if p == "action" and proba < thr:
|
||||
# Demote action prediction
|
||||
# Find next best route that isn't action
|
||||
sorted_routes = sorted(r["proba"].items(), key=lambda x: -x[1])
|
||||
for route, _ in sorted_routes:
|
||||
if route != "action":
|
||||
p = route
|
||||
break
|
||||
action_pred.append(p)
|
||||
|
||||
y_true = np.array([r["true"] for r in oof_rows])
|
||||
y_pred = np.array(action_pred)
|
||||
|
||||
action_tp = sum(1 for t, p in zip(y_true, y_pred) if t == "action" and p == "action")
|
||||
action_fp = sum(1 for t, p in zip(y_true, y_pred) if t != "action" and p == "action")
|
||||
action_fn = sum(1 for t, p in zip(y_true, y_pred) if t == "action" and p != "action")
|
||||
false_action = sum(1 for t, p in zip(y_true, y_pred) if t != "action" and p == "action")
|
||||
|
||||
action_curves.append({
|
||||
"threshold": thr,
|
||||
"action_precision": action_tp / max(action_tp + action_fp, 1),
|
||||
"action_recall": action_tp / max(action_tp + action_fn, 1),
|
||||
"false_action_count": false_action,
|
||||
})
|
||||
|
||||
return action_curves
|
||||
|
||||
|
||||
def compute_disagreement(y_true, y_pred, legacy_pred, oof_rows):
|
||||
"""Analyze disagreements between legacy and learned router."""
|
||||
results = {
|
||||
"legacy_wrong_learned_right": [],
|
||||
"legacy_right_learned_wrong": [],
|
||||
"both_wrong_differently": [],
|
||||
}
|
||||
|
||||
legacy_false_actions = []
|
||||
learned_false_actions = []
|
||||
shared_false_actions = []
|
||||
|
||||
for i, r in enumerate(oof_rows):
|
||||
sid = r["source_id"]
|
||||
text = r["text"]
|
||||
true = y_true[i]
|
||||
learned = y_pred[i]
|
||||
legacy = legacy_pred[i]
|
||||
|
||||
if legacy != true and learned == true:
|
||||
results["legacy_wrong_learned_right"].append({
|
||||
"source_id": sid, "text": text,
|
||||
"true": true, "legacy": legacy, "learned": learned,
|
||||
})
|
||||
elif legacy == true and learned != true:
|
||||
results["legacy_right_learned_wrong"].append({
|
||||
"source_id": sid, "text": text,
|
||||
"true": true, "legacy": legacy, "learned": learned,
|
||||
})
|
||||
elif legacy != true and learned != true and legacy != learned:
|
||||
results["both_wrong_differently"].append({
|
||||
"source_id": sid, "text": text,
|
||||
"true": true, "legacy": legacy, "learned": learned,
|
||||
})
|
||||
|
||||
# False action tracking
|
||||
if true != "action" and legacy == "action":
|
||||
legacy_false_actions.append(sid)
|
||||
if true != "action" and learned == "action":
|
||||
learned_false_actions.append(sid)
|
||||
if true != "action" and legacy == "action" and learned == "action":
|
||||
shared_false_actions.append(sid)
|
||||
|
||||
# Repaired false actions
|
||||
repaired = [sid for sid in legacy_false_actions if sid not in learned_false_actions]
|
||||
new_errors = [sid for sid in learned_false_actions if sid not in legacy_false_actions]
|
||||
shared = shared_false_actions
|
||||
|
||||
return {
|
||||
"details": results,
|
||||
"legacy_false_actions": legacy_false_actions,
|
||||
"learned_false_actions": learned_false_actions,
|
||||
"repaired": repaired,
|
||||
"new_errors": new_errors,
|
||||
"shared": shared,
|
||||
}
|
||||
|
||||
|
||||
def compute_contrast_family(oof_rows):
|
||||
"""Analyze performance per contrast family."""
|
||||
family_results = {}
|
||||
for family in CONTRAST_FAMILIES:
|
||||
members = [r for r in oof_rows if family in r.get("tags", [])]
|
||||
if not members:
|
||||
continue
|
||||
y_true = [r["true"] for r in members]
|
||||
y_pred = [r["predicted"] for r in members]
|
||||
correct = sum(1 for t, p in zip(y_true, y_pred) if t == p)
|
||||
false_act = sum(1 for t, p in zip(y_true, y_pred) if t != "action" and p == "action")
|
||||
family_results[family] = {
|
||||
"count": len(members),
|
||||
"correct": correct,
|
||||
"accuracy": correct / len(members),
|
||||
"false_action": false_act,
|
||||
}
|
||||
return family_results
|
||||
|
||||
|
||||
def compute_legacy_baseline(examples):
|
||||
"""
|
||||
Compute legacy baseline by mapping each example through the known
|
||||
fast-path and classifier behavior. Since we don't have the actual
|
||||
router running, we use the corpus metadata:
|
||||
- fast_path_resolved examples are correct (grammar handles them)
|
||||
- We simulate the legacy baseline from the test output numbers
|
||||
"""
|
||||
# The actual legacy baseline was measured in TestLegacyBaseline:
|
||||
# overall accuracy: 52.2%, residual: 40.8%, false-action rate: 19.9%
|
||||
# We need per-example predictions. We'll approximate from the corpus structure.
|
||||
#
|
||||
# For fast_path_resolved examples, the legacy router is correct (stage-0 grammar).
|
||||
# For residual examples, we need to simulate the hash-embedder classifier.
|
||||
# Since we don't have the hash embedder running, we use the known aggregate.
|
||||
#
|
||||
# This is a limitation: the legacy baseline numbers come from the Go test,
|
||||
# and we only have the aggregate. We'll use the aggregate for comparison.
|
||||
pass
|
||||
|
||||
|
||||
# ─── Report Generation ──────────────────────────────────────────────────────
|
||||
|
||||
def fmt_pct(v, decimals=1):
|
||||
return f"{100 * v:.{decimals}f}%"
|
||||
|
||||
|
||||
def fmt_float(v, decimals=3):
|
||||
return f"{v:.{decimals}f}"
|
||||
|
||||
|
||||
def generate_report(meta, all_results, residual_results, oof_all, oof_residual):
|
||||
"""Generate the full experiment report."""
|
||||
lines = []
|
||||
lines.append("# Semantic Router Linear Head Experiment — Report")
|
||||
lines.append("")
|
||||
lines.append("## 1. Exact e5 representation used")
|
||||
lines.append("")
|
||||
lines.append(f"- **Model**: {meta['embedder_id']}")
|
||||
lines.append(f"- **Checkpoint**: {meta['model_path']}")
|
||||
lines.append(f"- **Tokenizer**: {meta['tokenizer_path']}")
|
||||
lines.append(f"- **Dimension**: {meta['dimension']}")
|
||||
lines.append(f"- **Pooling**: {meta['pooling']}")
|
||||
lines.append(f"- **Normalization**: {meta['normalization']}")
|
||||
lines.append(f"- **Input template**: {meta['input_template']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 2. Development/residual row counts")
|
||||
lines.append("")
|
||||
lines.append(f"- Total corpus: {meta['total_examples']}")
|
||||
lines.append(f"- Frozen holdout: {meta['frozen_count']}")
|
||||
lines.append(f"- Development pool: {meta['dev_count']}")
|
||||
lines.append(f"- Fast-path resolved: {meta['fast_path_count']}")
|
||||
lines.append(f"- Router-residual: {meta['residual_count']}")
|
||||
lines.append("")
|
||||
lines.append("Route distribution (full corpus):")
|
||||
for route, count in sorted(meta["route_counts"].items()):
|
||||
lines.append(f" - {route}: {count}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 3. Grouped fold composition")
|
||||
lines.append("")
|
||||
lines.append(f"Folds: {meta['cv_folds']}")
|
||||
for fold_id, stats in sorted(meta["fold_composition"].items()):
|
||||
lines.append(f" - Fold {fold_id}: eval={stats['eval_count']} train={stats['train_count']} routes={stats['eval_routes']}")
|
||||
lines.append("")
|
||||
|
||||
# Regularization selection
|
||||
lines.append("## 4. Selected regularization")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Experiment A: All development examples")
|
||||
best_C_all = all_results["best_C"]
|
||||
lines.append(f"- Best C: {best_C_all}")
|
||||
lines.append(f"- Mean accuracy: {fmt_pct(all_results['results_by_C'][best_C_all]['mean_accuracy'])} ± {fmt_pct(all_results['results_by_C'][best_C_all]['std_accuracy'])}")
|
||||
lines.append(f"- Mean macro F1: {fmt_float(all_results['results_by_C'][best_C_all]['mean_macro_f1'])} ± {fmt_float(all_results['results_by_C'][best_C_all]['std_macro_f1'])}")
|
||||
lines.append(f"- Total false actions (CV): {all_results['results_by_C'][best_C_all]['total_false_action']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Experiment B: Router-residual only")
|
||||
best_C_res = residual_results["best_C"]
|
||||
lines.append(f"- Best C: {best_C_res}")
|
||||
lines.append(f"- Mean accuracy: {fmt_pct(residual_results['results_by_C'][best_C_res]['mean_accuracy'])} ± {fmt_pct(residual_results['results_by_C'][best_C_res]['std_accuracy'])}")
|
||||
lines.append(f"- Mean macro F1: {fmt_float(residual_results['results_by_C'][best_C_res]['mean_macro_f1'])} ± {fmt_float(residual_results['results_by_C'][best_C_res]['std_macro_f1'])}")
|
||||
lines.append(f"- Total false actions (CV): {residual_results['results_by_C'][best_C_res]['total_false_action']}")
|
||||
lines.append("")
|
||||
|
||||
# Stability across folds
|
||||
lines.append("### Stability across folds")
|
||||
lines.append("")
|
||||
for C_val in C_VALUES:
|
||||
r = all_results["results_by_C"][C_val]
|
||||
fold_accs = [m["accuracy"] for m in r["fold_metrics"]]
|
||||
fold_f1s = [m["macro_f1"] for m in r["fold_metrics"]]
|
||||
lines.append(f" C={C_val:<6} acc={fmt_pct(r['mean_accuracy'])}±{fmt_pct(r['std_accuracy'])} f1={fmt_float(r['mean_macro_f1'])}±{fmt_float(r['std_macro_f1'])} folds_acc={[fmt_pct(a) for a in fold_accs]}")
|
||||
lines.append("")
|
||||
|
||||
# Experiment A metrics
|
||||
lines.append("## 5. All-example CV metrics")
|
||||
lines.append("")
|
||||
metrics_all = all_results["full_metrics"]
|
||||
lines.append(f"- Accuracy: {fmt_pct(metrics_all['accuracy'])}")
|
||||
lines.append(f"- Macro F1: {fmt_float(metrics_all['macro_f1'])}")
|
||||
lines.append(f"- False-action count: {metrics_all['false_action_count']}")
|
||||
lines.append(f"- False-action rate: {fmt_pct(metrics_all['false_action_rate'])}")
|
||||
lines.append(f"- Action precision: {fmt_float(metrics_all['action_precision'])}")
|
||||
lines.append(f"- Action recall: {fmt_float(metrics_all['action_recall'])}")
|
||||
lines.append(f"- Uncertain precision: {fmt_float(metrics_all['uncertain_precision'])}")
|
||||
lines.append(f"- Uncertain recall: {fmt_float(metrics_all['uncertain_recall'])}")
|
||||
lines.append("")
|
||||
lines.append("Per-class metrics:")
|
||||
for route in ROUTES:
|
||||
pc = metrics_all["per_class"][route]
|
||||
lines.append(f" {route:<15} P={fmt_float(pc['precision'])} R={fmt_float(pc['recall'])} F1={fmt_float(pc['f1'])} (n={pc['support']})")
|
||||
lines.append("")
|
||||
lines.append("Confusion matrix (rows=expected, cols=predicted):")
|
||||
header = f"{'':>15}" + "".join(f"{r:>15}" for r in ROUTES)
|
||||
lines.append(header)
|
||||
for i, route in enumerate(ROUTES):
|
||||
row = f"{route:>15}" + "".join(f"{metrics_all['confusion_matrix'][i][j]:>15}" for j in range(len(ROUTES)))
|
||||
lines.append(row)
|
||||
lines.append("")
|
||||
|
||||
# Experiment B metrics
|
||||
lines.append("## 6. Residual-only CV metrics")
|
||||
lines.append("")
|
||||
metrics_res = residual_results["full_metrics"]
|
||||
lines.append(f"- Accuracy: {fmt_pct(metrics_res['accuracy'])}")
|
||||
lines.append(f"- Macro F1: {fmt_float(metrics_res['macro_f1'])}")
|
||||
lines.append(f"- False-action count: {metrics_res['false_action_count']}")
|
||||
lines.append(f"- False-action rate: {fmt_pct(metrics_res['false_action_rate'])}")
|
||||
lines.append(f"- Action precision: {fmt_float(metrics_res['action_precision'])}")
|
||||
lines.append(f"- Action recall: {fmt_float(metrics_res['action_recall'])}")
|
||||
lines.append(f"- Uncertain precision: {fmt_float(metrics_res['uncertain_precision'])}")
|
||||
lines.append(f"- Uncertain recall: {fmt_float(metrics_res['uncertain_recall'])}")
|
||||
lines.append("")
|
||||
lines.append("Per-class metrics:")
|
||||
for route in ROUTES:
|
||||
pc = metrics_res["per_class"][route]
|
||||
lines.append(f" {route:<15} P={fmt_float(pc['precision'])} R={fmt_float(pc['recall'])} F1={fmt_float(pc['f1'])} (n={pc['support']})")
|
||||
lines.append("")
|
||||
lines.append("Confusion matrix (rows=expected, cols=predicted):")
|
||||
header = f"{'':>15}" + "".join(f"{r:>15}" for r in ROUTES)
|
||||
lines.append(header)
|
||||
for i, route in enumerate(ROUTES):
|
||||
row = f"{route:>15}" + "".join(f"{metrics_res['confusion_matrix'][i][j]:>15}" for j in range(len(ROUTES)))
|
||||
lines.append(row)
|
||||
lines.append("")
|
||||
|
||||
# Legacy comparison
|
||||
lines.append("## 7. Legacy-vs-linear comparison")
|
||||
lines.append("")
|
||||
lines.append("### All examples")
|
||||
lines.append(f"{'metric':<25} {'legacy':>10} {'linear e5':>10} {'delta':>10}")
|
||||
lines.append("-" * 55)
|
||||
# Legacy baseline from test: 52.2% overall, 40.8% residual, 19.9% false-action
|
||||
# These are approximate since we don't have per-example legacy predictions
|
||||
legacy_acc = 0.522
|
||||
legacy_fa_rate = 0.199
|
||||
legacy_macro_f1 = 0.0 # unknown precisely
|
||||
lines.append(f"{'accuracy':<25} {fmt_pct(legacy_acc):>10} {fmt_pct(metrics_all['accuracy']):>10} {fmt_pct(metrics_all['accuracy'] - legacy_acc):>10}")
|
||||
lines.append(f"{'macro F1':<25} {'—':>10} {fmt_float(metrics_all['macro_f1']):>10} {'—':>10}")
|
||||
lines.append(f"{'action precision':<25} {'—':>10} {fmt_float(metrics_all['action_precision']):>10} {'—':>10}")
|
||||
lines.append(f"{'false-action rate':<25} {fmt_pct(legacy_fa_rate):>10} {fmt_pct(metrics_all['false_action_rate']):>10} {fmt_pct(metrics_all['false_action_rate'] - legacy_fa_rate):>10}")
|
||||
lines.append(f"{'uncertain F1':<25} {fmt_float(0.0):>10} {fmt_float(metrics_all['per_class']['uncertain']['f1']):>10} {fmt_float(metrics_all['per_class']['uncertain']['f1']):>10}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Router-residual only")
|
||||
lines.append(f"{'metric':<25} {'legacy':>10} {'linear e5':>10} {'delta':>10}")
|
||||
lines.append("-" * 55)
|
||||
legacy_res_acc = 0.408
|
||||
lines.append(f"{'accuracy':<25} {fmt_pct(legacy_res_acc):>10} {fmt_pct(metrics_res['accuracy']):>10} {fmt_pct(metrics_res['accuracy'] - legacy_res_acc):>10}")
|
||||
lines.append(f"{'macro F1':<25} {'—':>10} {fmt_float(metrics_res['macro_f1']):>10} {'—':>10}")
|
||||
lines.append(f"{'false-action rate':<25} {'—':>10} {fmt_pct(metrics_res['false_action_rate']):>10} {'—':>10}")
|
||||
lines.append("")
|
||||
|
||||
# Fold variance
|
||||
lines.append("## 8. Fold variance")
|
||||
lines.append("")
|
||||
lines.append("All-example CV:")
|
||||
for m in all_results["results_by_C"][best_C_all]["fold_metrics"]:
|
||||
lines.append(f" Fold {m['fold']}: acc={fmt_pct(m['accuracy'])} f1={fmt_float(m['macro_f1'])} false_action={m['false_action']}")
|
||||
lines.append("")
|
||||
lines.append("Residual-only CV:")
|
||||
for m in residual_results["results_by_C"][best_C_res]["fold_metrics"]:
|
||||
lines.append(f" Fold {m['fold']}: acc={fmt_pct(m['accuracy'])} f1={fmt_float(m['macro_f1'])} false_action={m['false_action']}")
|
||||
lines.append("")
|
||||
|
||||
# Disagreement analysis
|
||||
lines.append("## 9. False-action repair/new-error analysis")
|
||||
lines.append("")
|
||||
# The disagreement analysis requires legacy per-example predictions.
|
||||
# Since we don't have those, we report what we can from the out-of-fold data.
|
||||
lines.append("Note: Legacy per-example predictions were not available for this experiment.")
|
||||
lines.append("The legacy baseline was measured in aggregate in the Go test suite.")
|
||||
lines.append("")
|
||||
lines.append("Learned router false-action cases (out-of-fold):")
|
||||
for r in oof_all:
|
||||
if r["true"] != "action" and r["predicted"] == "action":
|
||||
lines.append(f" {r['source_id']}: '{r['text']}' (true={r['true']}, proba(action)={r['proba'].get('action', 0):.3f})")
|
||||
lines.append("")
|
||||
|
||||
# Contrast family
|
||||
lines.append("## 10. Contrast-family results")
|
||||
lines.append("")
|
||||
lines.append("### Experiment A (all dev)")
|
||||
contrast_all = all_results["contrast_family"]
|
||||
lines.append(f"{'family':<25} {'count':>6} {'correct':>8} {'accuracy':>10} {'false_act':>10}")
|
||||
lines.append("-" * 60)
|
||||
for family in CONTRAST_FAMILIES:
|
||||
if family in contrast_all:
|
||||
c = contrast_all[family]
|
||||
lines.append(f"{family:<25} {c['count']:>6} {c['correct']:>8} {fmt_pct(c['accuracy']):>10} {c['false_action']:>10}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Experiment B (residual only)")
|
||||
contrast_res = residual_results["contrast_family"]
|
||||
lines.append(f"{'family':<25} {'count':>6} {'correct':>8} {'accuracy':>10} {'false_act':>10}")
|
||||
lines.append("-" * 60)
|
||||
for family in CONTRAST_FAMILIES:
|
||||
if family in contrast_res:
|
||||
c = contrast_res[family]
|
||||
lines.append(f"{family:<25} {c['count']:>6} {c['correct']:>8} {fmt_pct(c['accuracy']):>10} {c['false_action']:>10}")
|
||||
lines.append("")
|
||||
|
||||
# Calibration
|
||||
lines.append("## 11. Calibration metrics")
|
||||
lines.append("")
|
||||
lines.append("### Experiment A")
|
||||
cal_all = all_results["calibration"]
|
||||
lines.append(f"- ECE: {fmt_float(cal_all['ece'])}")
|
||||
lines.append(f"- Brier score: {fmt_float(cal_all['brier'])}")
|
||||
lines.append(f"- Log loss: {fmt_float(cal_all['log_loss'])}")
|
||||
lines.append("")
|
||||
lines.append("### Experiment B")
|
||||
cal_res = residual_results["calibration"]
|
||||
lines.append(f"- ECE: {fmt_float(cal_res['ece'])}")
|
||||
lines.append(f"- Brier score: {fmt_float(cal_res['brier'])}")
|
||||
lines.append(f"- Log loss: {fmt_float(cal_res['log_loss'])}")
|
||||
lines.append("")
|
||||
|
||||
# Abstention curves
|
||||
lines.append("## 12. Abstention curves")
|
||||
lines.append("")
|
||||
lines.append("### Experiment A (all dev)")
|
||||
lines.append(f"{'threshold':>10} {'n_accepted':>11} {'coverage':>10} {'accuracy':>10} {'macro_f1':>10} {'false_act':>10}")
|
||||
lines.append("-" * 62)
|
||||
for curve in cal_all["abstention_curves"]:
|
||||
lines.append(f"{curve['threshold']:>10.2f} {curve['n_accepted']:>11} {fmt_pct(curve['coverage']):>10} {fmt_pct(curve['accuracy']):>10} {fmt_float(curve['macro_f1']):>10} {curve['false_action_count']:>10}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Experiment B (residual only)")
|
||||
lines.append(f"{'threshold':>10} {'n_accepted':>11} {'coverage':>10} {'accuracy':>10} {'macro_f1':>10} {'false_act':>10}")
|
||||
lines.append("-" * 62)
|
||||
for curve in cal_res["abstention_curves"]:
|
||||
lines.append(f"{curve['threshold']:>10.2f} {curve['n_accepted']:>11} {fmt_pct(curve['coverage']):>10} {fmt_pct(curve['accuracy']):>10} {fmt_float(curve['macro_f1']):>10} {curve['false_action_count']:>10}")
|
||||
lines.append("")
|
||||
|
||||
# Action threshold
|
||||
lines.append("## 13. Action-threshold curve")
|
||||
lines.append("")
|
||||
lines.append("### Experiment A")
|
||||
lines.append(f"{'threshold':>10} {'action_P':>10} {'action_R':>10} {'false_act':>10}")
|
||||
lines.append("-" * 40)
|
||||
for curve in all_results["action_threshold"]:
|
||||
lines.append(f"{curve['threshold']:>10.2f} {fmt_float(curve['action_precision']):>10} {fmt_float(curve['action_recall']):>10} {curve['false_action_count']:>10}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Experiment B")
|
||||
lines.append(f"{'threshold':>10} {'action_P':>10} {'action_R':>10} {'false_act':>10}")
|
||||
lines.append("-" * 40)
|
||||
for curve in residual_results["action_threshold"]:
|
||||
lines.append(f"{curve['threshold']:>10.2f} {fmt_float(curve['action_precision']):>10} {fmt_float(curve['action_recall']):>10} {curve['false_action_count']:>10}")
|
||||
lines.append("")
|
||||
|
||||
# Model artifact size
|
||||
lines.append("## 14. Model artifact size and runtime cost")
|
||||
lines.append("")
|
||||
# Logistic regression: 6 classes × 384 features + 6 biases = 2310 parameters
|
||||
n_params = len(ROUTES) * meta["dimension"] + len(ROUTES)
|
||||
serialized_bytes = n_params * 4 # float32
|
||||
lines.append(f"- Trainable parameters: {n_params}")
|
||||
lines.append(f" - {len(ROUTES)} classes × {meta['dimension']} features = {len(ROUTES) * meta['dimension']} weights")
|
||||
lines.append(f" - {len(ROUTES)} bias terms")
|
||||
lines.append(f"- Serialized head size: {serialized_bytes} bytes ({serialized_bytes / 1024:.1f} KB)")
|
||||
lines.append(f"- Additional inference FLOPs: {len(ROUTES) * meta['dimension']} multiply-accumulates")
|
||||
lines.append(f"- Incremental cost (e5 already computed): ~{len(ROUTES) * meta['dimension']} FLOPs, <1µs")
|
||||
lines.append(f"- Cost if semantic router must trigger its own e5: full ONNX inference (~{meta['dimension']} × 128 × 12 = ~590K FLOPs)")
|
||||
lines.append("")
|
||||
|
||||
# Recommendation
|
||||
lines.append("## 16. Recommendation")
|
||||
lines.append("")
|
||||
# Decision logic
|
||||
all_f1 = metrics_all["macro_f1"]
|
||||
res_f1 = metrics_res["macro_f1"]
|
||||
res_acc = metrics_res["accuracy"]
|
||||
|
||||
if res_f1 > 0.5 and res_acc > 0.55:
|
||||
verdict = "linear head sufficient"
|
||||
detail = (f"Residual macro F1 of {fmt_float(res_f1)} and accuracy of {fmt_pct(res_acc)} "
|
||||
f"exceed the legacy baseline (40.8% residual accuracy) by a meaningful margin. "
|
||||
f"A linear head over frozen e5-small embeddings is a viable first production candidate.")
|
||||
elif all_f1 > 0.5 and res_f1 < 0.5:
|
||||
verdict = "need more data"
|
||||
detail = (f"All-example F1 ({fmt_float(all_f1)}) is acceptable but residual-only F1 "
|
||||
f"({fmt_float(res_f1)}) drops, suggesting the contrast-family examples are "
|
||||
f"hard for a linear classifier. More contrastive training data may help.")
|
||||
else:
|
||||
verdict = "representation inadequate"
|
||||
detail = (f"Neither all-example ({fmt_float(all_f1)}) nor residual-only ({fmt_float(res_f1)}) "
|
||||
f"F1 reaches the minimum viable threshold. The e5-small linear separability floor "
|
||||
f"is insufficient for this 6-way task. Consider a non-linear head or a different encoder.")
|
||||
|
||||
lines.append(f"**{verdict}**")
|
||||
lines.append("")
|
||||
lines.append(detail)
|
||||
lines.append("")
|
||||
|
||||
# Commit hash
|
||||
lines.append("## 17. Commit hash for experiment tooling")
|
||||
lines.append("")
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, cwd="/home/kami/apps/Maven"
|
||||
)
|
||||
lines.append(f"`{result.stdout.strip()}`")
|
||||
except Exception:
|
||||
lines.append("(unable to determine)")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_experiment(X, y, fold_ids, examples_meta, label):
|
||||
"""Run the full experiment pipeline for one population."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Experiment: {label}")
|
||||
print(f" Samples: {len(y)} Features: {X.shape[1]} Folds: {len(set(fold_ids))}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# 1. Grouped CV with regularization search
|
||||
best_C, results_by_C = grouped_cv_experiment(X, y, fold_ids, C_VALUES, examples_meta)
|
||||
|
||||
# 2. Use best C to get full metrics from OOF predictions
|
||||
oof_rows = results_by_C[best_C]["oof_predictions"]
|
||||
oof_true = np.array([r["true"] for r in oof_rows])
|
||||
oof_pred = np.array([r["predicted"] for r in oof_rows])
|
||||
|
||||
full_metrics = compute_full_metrics(oof_true, oof_pred)
|
||||
|
||||
# 3. Calibration
|
||||
calibration = compute_calibration(oof_true, oof_pred, oof_rows)
|
||||
|
||||
# 4. Action threshold
|
||||
action_threshold = compute_action_threshold(oof_rows)
|
||||
|
||||
# 5. Contrast family
|
||||
contrast_family = compute_contrast_family(oof_rows)
|
||||
|
||||
return {
|
||||
"best_C": best_C,
|
||||
"results_by_C": results_by_C,
|
||||
"full_metrics": full_metrics,
|
||||
"calibration": calibration,
|
||||
"action_threshold": action_threshold,
|
||||
"contrast_family": contrast_family,
|
||||
"oof_rows": oof_rows,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
EMBEDDING_PATH = "/tmp/mvn-experiment/embeddings.json"
|
||||
REPORT_PATH = "/tmp/mvn-experiment/report.md"
|
||||
|
||||
# Load data
|
||||
meta, examples = load_embeddings(EMBEDDING_PATH)
|
||||
print(f"Loaded {len(examples)} examples, embedder={meta['embedder_id']}, dim={meta['dimension']}")
|
||||
|
||||
# Development pool only
|
||||
dev_examples = filter_dev_pool(examples)
|
||||
print(f"Development pool: {len(dev_examples)} examples")
|
||||
|
||||
# Extract features
|
||||
X_all, y_all = extract_Xy(dev_examples)
|
||||
fold_ids_all = get_fold_groups(dev_examples)
|
||||
|
||||
# Experiment A: all dev examples
|
||||
all_results = run_experiment(X_all, y_all, fold_ids_all, dev_examples, "All development examples")
|
||||
|
||||
# Experiment B: residual only
|
||||
dev_residual = filter_residual(dev_examples)
|
||||
X_res, y_res = extract_Xy(dev_residual)
|
||||
fold_ids_res = get_fold_groups(dev_residual)
|
||||
residual_results = run_experiment(X_res, y_res, fold_ids_res, dev_residual, "Router-residual only")
|
||||
|
||||
# Generate report
|
||||
report = generate_report(
|
||||
meta, all_results, residual_results,
|
||||
all_results["oof_rows"], residual_results["oof_rows"],
|
||||
)
|
||||
|
||||
with open(REPORT_PATH, "w") as f:
|
||||
f.write(report)
|
||||
print(f"\nReport written to {REPORT_PATH}")
|
||||
|
||||
# Also print summary
|
||||
print("\n" + "="*60)
|
||||
print(" SUMMARY")
|
||||
print("="*60)
|
||||
print(f" All-example: acc={fmt_pct(all_results['full_metrics']['accuracy'])} macro_f1={fmt_float(all_results['full_metrics']['macro_f1'])} false_action={all_results['full_metrics']['false_action_count']}")
|
||||
print(f" Residual: acc={fmt_pct(residual_results['full_metrics']['accuracy'])} macro_f1={fmt_float(residual_results['full_metrics']['macro_f1'])} false_action={residual_results['full_metrics']['false_action_count']}")
|
||||
print(f" Best C (all): {all_results['best_C']}")
|
||||
print(f" Best C (res): {residual_results['best_C']}")
|
||||
print(f" ECE (all): {fmt_float(all_results['calibration']['ece'])}")
|
||||
print(f" ECE (res): {fmt_float(residual_results['calibration']['ece'])}")
|
||||
print(f" Brier (all): {fmt_float(all_results['calibration']['brier'])}")
|
||||
print(f" Brier (res): {fmt_float(residual_results['calibration']['brier'])}")
|
||||
@@ -0,0 +1,273 @@
|
||||
// semantic-router-experiment computes embeddings for the semantic coarse-route
|
||||
// corpus using the deployed multilingual-e5-small ONNX model. It outputs a
|
||||
// JSON file containing every corpus row with its embedding vector, split
|
||||
// assignment, and fold membership for grouped cross-validation.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// MAVEN_ONNX_LIB=/path/to/libonnxruntime.so \
|
||||
// go run ./cmd/semantic-router-experiment/ -out embeddings.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/router/semantic"
|
||||
)
|
||||
|
||||
// CachedRow is one corpus row with its precomputed embedding and split metadata.
|
||||
type CachedRow struct {
|
||||
Text string `json:"text"`
|
||||
Route string `json:"route"`
|
||||
Source string `json:"source"`
|
||||
SourceID string `json:"source_id"`
|
||||
SplitGroup string `json:"split_group"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
FastPathResolved bool `json:"fast_path_resolved"`
|
||||
RouterResidual *bool `json:"router_residual,omitempty"`
|
||||
TextHash string `json:"text_hash"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
EmbedderID string `json:"embedder_id"`
|
||||
FrozenHoldout bool `json:"frozen_holdout"`
|
||||
CVFold int `json:"cv_fold"`
|
||||
DevPool bool `json:"dev_pool"`
|
||||
FamilyID string `json:"family_id"`
|
||||
}
|
||||
|
||||
// ExperimentMeta carries metadata about the experiment run.
|
||||
type ExperimentMeta struct {
|
||||
EmbedderID string `json:"embedder_id"`
|
||||
ModelPath string `json:"model_path"`
|
||||
TokenizerPath string `json:"tokenizer_path"`
|
||||
Dimension int `json:"dimension"`
|
||||
Pooling string `json:"pooling"`
|
||||
Normalization string `json:"normalization"`
|
||||
InputTemplate string `json:"input_template"`
|
||||
TotalExamples int `json:"total_examples"`
|
||||
FrozenCount int `json:"frozen_count"`
|
||||
DevCount int `json:"dev_count"`
|
||||
CVFolds int `json:"cv_folds"`
|
||||
FoldComposition map[int]FoldStats `json:"fold_composition"`
|
||||
RouteCounts map[string]int `json:"route_counts"`
|
||||
SourceCounts map[string]int `json:"source_counts"`
|
||||
FastPathCount int `json:"fast_path_count"`
|
||||
ResidualCount int `json:"residual_count"`
|
||||
HoldoutHash string `json:"holdout_hash"`
|
||||
}
|
||||
|
||||
// FoldStats describes one CV fold.
|
||||
type FoldStats struct {
|
||||
EvalCount int `json:"eval_count"`
|
||||
TrainCount int `json:"train_count"`
|
||||
Routes map[string]int `json:"eval_routes"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
outPath := flag.String("out", "embeddings.json", "output JSON path")
|
||||
folds := flag.Int("folds", 5, "number of CV folds")
|
||||
flag.Parse()
|
||||
|
||||
libPath := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if libPath == "" {
|
||||
log.Fatal("MAVEN_ONNX_LIB must be set to the libonnxruntime.so path")
|
||||
}
|
||||
|
||||
// Resolve model paths relative to the module root (cwd when running with go run).
|
||||
modelPath := "models/embedder/multilingual-e5-small/model_quantized.onnx"
|
||||
tokPath := "models/embedder/multilingual-e5-small/tokenizer.json"
|
||||
|
||||
for _, p := range []string{libPath, modelPath, tokPath} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
log.Fatalf("missing %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load corpus.
|
||||
exs, err := semantic.LoadCorpus()
|
||||
if err != nil {
|
||||
log.Fatalf("load corpus: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "corpus: %d examples\n", len(exs))
|
||||
|
||||
// Initialize embedder.
|
||||
emb, err := router.NewONNXEmbedder(modelPath, tokPath, libPath)
|
||||
if err != nil {
|
||||
log.Fatalf("init embedder: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
fmt.Fprintf(os.Stderr, "embedder: %s (dim=%d)\n", emb.ID(), emb.Dim())
|
||||
|
||||
// Compute frozen holdout / dev pool split.
|
||||
_, devPool, holdoutHash := semantic.FrozenHoldoutSplit(exs)
|
||||
fmt.Fprintf(os.Stderr, "frozen holdout: hash=%s, dev pool: %d examples\n", holdoutHash, len(devPool))
|
||||
|
||||
// Compute grouped CV folds on dev pool only.
|
||||
cvFolds := semantic.GroupedCVFolds(devPool, *folds)
|
||||
fmt.Fprintf(os.Stderr, "cv folds: %d\n", len(cvFolds))
|
||||
|
||||
// Build a lookup: source_id → cv_fold (from dev pool only).
|
||||
foldLookup := make(map[string]int)
|
||||
for _, f := range cvFolds {
|
||||
for _, e := range f.Eval {
|
||||
foldLookup[e.SourceID] = f.Fold
|
||||
}
|
||||
}
|
||||
|
||||
// Build dev set membership lookup.
|
||||
_, devSet, _ := semantic.FrozenHoldoutSplit(exs)
|
||||
devIDs := make(map[string]bool)
|
||||
for _, e := range devSet {
|
||||
devIDs[e.SourceID] = true
|
||||
}
|
||||
|
||||
// Embed all examples.
|
||||
ctx := context.Background()
|
||||
var cached []CachedRow
|
||||
foldComp := make(map[int]*FoldStats)
|
||||
for i := 0; i < *folds; i++ {
|
||||
foldComp[i] = &FoldStats{Routes: make(map[string]int)}
|
||||
}
|
||||
routeCounts := make(map[string]int)
|
||||
sourceCounts := make(map[string]int)
|
||||
fpCount, resCount := 0, 0
|
||||
|
||||
for i, e := range exs {
|
||||
textHash := sha256.Sum256([]byte(e.Text))
|
||||
embedding, err := emb.EmbedQuery(ctx, e.Text)
|
||||
if err != nil {
|
||||
log.Fatalf("embed row %d (%s): %v", i, e.SourceID, err)
|
||||
}
|
||||
|
||||
inDev := devIDs[e.SourceID]
|
||||
fold := -1
|
||||
if inDev {
|
||||
if f, ok := foldLookup[e.SourceID]; ok {
|
||||
fold = f
|
||||
}
|
||||
}
|
||||
|
||||
isFrozen := !inDev
|
||||
|
||||
cr := CachedRow{
|
||||
Text: e.Text,
|
||||
Route: string(e.Route),
|
||||
Source: e.Source,
|
||||
SourceID: e.SourceID,
|
||||
SplitGroup: e.SplitGroup,
|
||||
Tags: e.Tags,
|
||||
FastPathResolved: e.FastPathResolved,
|
||||
RouterResidual: e.RouterResidual,
|
||||
TextHash: hex.EncodeToString(textHash[:]),
|
||||
Embedding: embedding,
|
||||
EmbedderID: emb.ID(),
|
||||
FrozenHoldout: isFrozen,
|
||||
CVFold: fold,
|
||||
DevPool: inDev,
|
||||
FamilyID: e.SplitGroup,
|
||||
}
|
||||
cached = append(cached, cr)
|
||||
|
||||
routeCounts[cr.Route]++
|
||||
sourceCounts[cr.Source]++
|
||||
if e.FastPathResolved {
|
||||
fpCount++
|
||||
} else {
|
||||
resCount++
|
||||
}
|
||||
|
||||
if inDev && fold >= 0 {
|
||||
foldComp[fold].EvalCount++
|
||||
foldComp[fold].Routes[cr.Route]++
|
||||
}
|
||||
}
|
||||
|
||||
// Compute train counts per fold.
|
||||
for i := 0; i < *folds; i++ {
|
||||
foldComp[i].TrainCount = len(devPool) - foldComp[i].EvalCount
|
||||
}
|
||||
|
||||
// Sort route counts for deterministic output.
|
||||
sortedRoutes := make([]string, 0, len(routeCounts))
|
||||
for r := range routeCounts {
|
||||
sortedRoutes = append(sortedRoutes, r)
|
||||
}
|
||||
sort.Strings(sortedRoutes)
|
||||
sortedRouteCounts := make(map[string]int)
|
||||
for _, r := range sortedRoutes {
|
||||
sortedRouteCounts[r] = routeCounts[r]
|
||||
}
|
||||
|
||||
// Build fold stats with sorted keys.
|
||||
finalFoldComp := make(map[int]FoldStats)
|
||||
for i := 0; i < *folds; i++ {
|
||||
finalFoldComp[i] = *foldComp[i]
|
||||
}
|
||||
|
||||
meta := ExperimentMeta{
|
||||
EmbedderID: emb.ID(),
|
||||
ModelPath: modelPath,
|
||||
TokenizerPath: tokPath,
|
||||
Dimension: emb.Dim(),
|
||||
Pooling: "mean-pool + L2-normalize",
|
||||
Normalization: "L2",
|
||||
InputTemplate: "query: <text>",
|
||||
TotalExamples: len(exs),
|
||||
FrozenCount: len(exs) - len(devPool),
|
||||
DevCount: len(devPool),
|
||||
CVFolds: *folds,
|
||||
FoldComposition: finalFoldComp,
|
||||
RouteCounts: sortedRouteCounts,
|
||||
SourceCounts: sourceCounts,
|
||||
FastPathCount: fpCount,
|
||||
ResidualCount: resCount,
|
||||
HoldoutHash: holdoutHash,
|
||||
}
|
||||
|
||||
// Output.
|
||||
output := map[string]any{
|
||||
"meta": meta,
|
||||
"examples": cached,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, data, 0644); err != nil {
|
||||
log.Fatalf("write %s: %v", *outPath, err)
|
||||
}
|
||||
|
||||
// Print summary.
|
||||
fmt.Fprintf(os.Stderr, "\n=== experiment metadata ===\n")
|
||||
fmt.Fprintf(os.Stderr, "embedder: %s\n", meta.EmbedderID)
|
||||
fmt.Fprintf(os.Stderr, "dimension: %d\n", meta.Dimension)
|
||||
fmt.Fprintf(os.Stderr, "total: %d frozen: %d dev: %d\n", meta.TotalExamples, meta.FrozenCount, meta.DevCount)
|
||||
fmt.Fprintf(os.Stderr, "fast-path: %d residual: %d\n", meta.FastPathCount, meta.ResidualCount)
|
||||
fmt.Fprintf(os.Stderr, "routes: %s\n", formatMap(sortedRouteCounts))
|
||||
fmt.Fprintf(os.Stderr, "fold composition:\n")
|
||||
for i := 0; i < *folds; i++ {
|
||||
fs := finalFoldComp[i]
|
||||
fmt.Fprintf(os.Stderr, " fold %d: eval=%d train=%d routes=%s\n",
|
||||
i, fs.EvalCount, fs.TrainCount, formatMap(fs.Routes))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "output: %s (%d bytes)\n", *outPath, len(data))
|
||||
}
|
||||
|
||||
func formatMap(m map[string]int) string {
|
||||
var parts []string
|
||||
for k, v := range m {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, v))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return "{" + strings.Join(parts, ", ") + "}"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 18: Sparse Lexical Action/Non-Action Gate
|
||||
================================================
|
||||
|
||||
Answer: can Maven reliably distinguish executable requests from semantically
|
||||
similar non-actions using lexical/local-order features alone?
|
||||
|
||||
Representations under test (all frozen-population, no e5):
|
||||
A. word 1-2 grams, TF-IDF
|
||||
B. character 3-5 grams, TF-IDF (Unicode, no transliteration)
|
||||
C. [word ; char] combined TF-IDF
|
||||
|
||||
Population reused from slices 15-17: development corpus v2 (dev_pool), router
|
||||
labels, SplitGroup, cv_fold, tags. The e5 embedding vectors are ignored.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import warnings
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
import numpy as np
|
||||
from sklearn.exceptions import ConvergenceWarning
|
||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.metrics import (
|
||||
accuracy_score,
|
||||
average_precision_score,
|
||||
confusion_matrix,
|
||||
f1_score,
|
||||
precision_recall_fscore_support,
|
||||
roc_auc_score,
|
||||
)
|
||||
from sklearn.pipeline import make_pipeline
|
||||
from scipy import sparse
|
||||
|
||||
warnings.filterwarnings("ignore", category=ConvergenceWarning)
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
|
||||
EMBEDDING_PATH = "/tmp/mvn-experiment/embeddings.json"
|
||||
|
||||
# Present generator/contrast families in the v2 dev pool (for leave-family-out)
|
||||
PRESENT_FAMILIES = [
|
||||
"polite_request",
|
||||
"modal_request",
|
||||
"first_person_request",
|
||||
"reordered_target",
|
||||
"capability_question",
|
||||
"question",
|
||||
]
|
||||
|
||||
FAMILY_ALIASES = {
|
||||
"direct_imperative": "direct_imperative",
|
||||
"polite_request": "polite_request",
|
||||
"modal_request": "modal_request",
|
||||
"first_person_request": "first_person_request",
|
||||
"reordered_target": "reordered_target",
|
||||
"capability_question": "capability_question",
|
||||
"question": "question",
|
||||
}
|
||||
|
||||
|
||||
# ─── NormalizeMatchText (replicated from internal/router/matchtext.go) ─────
|
||||
|
||||
def normalize_match_text(s: str) -> str:
|
||||
"""NFKC → lowercase → collapse Unicode whitespace. Keeps punctuation, ё."""
|
||||
out = unicodedata.normalize("NFKC", s).strip().lower()
|
||||
out = re.sub(r"\s+", " ", out)
|
||||
return out
|
||||
|
||||
|
||||
# ─── Data Loading ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_data():
|
||||
with open(EMBEDDING_PATH) as f:
|
||||
data = json.load(f)
|
||||
return data["meta"], data["examples"]
|
||||
|
||||
|
||||
def filter_dev_pool(examples):
|
||||
return [e for e in examples if e["dev_pool"]]
|
||||
|
||||
|
||||
def residual_only(examples):
|
||||
return [e for e in examples if not e["fast_path_resolved"]]
|
||||
|
||||
|
||||
def fmt_pct(v, d=1):
|
||||
return f"{100*v:.{d}f}%"
|
||||
|
||||
|
||||
def ff(v, d=3):
|
||||
return f"{v:.{d}f}"
|
||||
|
||||
|
||||
def strip_punct(text: str) -> str:
|
||||
"""Remove all punctuation (shared with slice 16/17 apply_voice_stress)."""
|
||||
t = re.sub(r"[?.!,;:]+$", "", text.strip())
|
||||
t = re.sub(r"[^\w\s]", "", t)
|
||||
t = t.lower()
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
return t
|
||||
|
||||
|
||||
# ─── Feature Builders ───────────────────────────────────────────────────────
|
||||
|
||||
def build_features(texts, kind):
|
||||
"""Build a TF-IDF matrix for the given representation kind.
|
||||
kind in {'word','char','both'}. Returns (X_sparse, vectorizer)."""
|
||||
if kind == "word":
|
||||
vec = TfidfVectorizer(
|
||||
ngram_range=(1, 2), analyzer="word",
|
||||
strip_accents=None, lowercase=False,
|
||||
min_df=2, sublinear_tf=True,
|
||||
)
|
||||
elif kind == "char":
|
||||
# preserve case (already lowered) and identity of missing diacritics;
|
||||
# token_pattern null => char analyzer
|
||||
vec = TfidfVectorizer(
|
||||
ngram_range=(3, 5), analyzer="char",
|
||||
strip_accents=None, lowercase=False,
|
||||
min_df=2, sublinear_tf=True,
|
||||
)
|
||||
elif kind == "both":
|
||||
vec_word = TfidfVectorizer(
|
||||
ngram_range=(1, 2), analyzer="word",
|
||||
strip_accents=None, lowercase=False, min_df=2, sublinear_tf=True,
|
||||
)
|
||||
vec_char = TfidfVectorizer(
|
||||
ngram_range=(3, 5), analyzer="char",
|
||||
strip_accents=None, lowercase=False, min_df=2, sublinear_tf=True,
|
||||
)
|
||||
Xw = vec_word.fit_transform(texts)
|
||||
Xc = vec_char.fit_transform(texts)
|
||||
X = sparse.hstack([Xw, Xc]).tocsr()
|
||||
return X, ("both", vec_word, vec_char)
|
||||
X = vec.fit_transform(texts)
|
||||
return X, vec
|
||||
|
||||
|
||||
def vocab_size(vectorizer):
|
||||
if isinstance(vectorizer, tuple):
|
||||
_, vw, vc = vectorizer
|
||||
return vw.get_feature_names_out().shape[0] + vc.get_feature_names_out().shape[0]
|
||||
return vectorizer.get_feature_names_out().shape[0]
|
||||
|
||||
|
||||
def transform_texts(texts, vectorizer):
|
||||
"""Apply an already-fitted vectorizer (handles the 2-tuple 'both' case)."""
|
||||
if isinstance(vectorizer, tuple):
|
||||
_, vw, vc = vectorizer
|
||||
Xw = vw.transform(texts)
|
||||
Xc = vc.transform(texts)
|
||||
return sparse.hstack([Xw, Xc]).tocsr()
|
||||
return vectorizer.transform(texts)
|
||||
|
||||
|
||||
# ─── Grouped CV ─────────────────────────────────────────────────────────────
|
||||
|
||||
def run_binary_grouped_cv(X, y, fold_ids, C=1.0):
|
||||
"""Grouped CV for binary action vs not_action. Returns OOF rows + fold metrics."""
|
||||
yb = np.array([1 if t == "action" else 0 for t in y])
|
||||
fold_ids = np.asarray(fold_ids)
|
||||
unique_folds = sorted(set(fold_ids.tolist()))
|
||||
oof_rows = []
|
||||
fold_metrics = []
|
||||
|
||||
for test_fold in unique_folds:
|
||||
tr = fold_ids != test_fold
|
||||
te = fold_ids == test_fold
|
||||
clf = LogisticRegression(C=C, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yb[tr])
|
||||
proba = clf.predict_proba(X[te])[:, 1]
|
||||
pred = (proba >= 0.5).astype(int)
|
||||
yt = yb[te]
|
||||
|
||||
fp = int(((yt == 0) & (pred == 1)).sum())
|
||||
fn = int(((yt == 1) & (pred == 0)).sum())
|
||||
tp = int(((yt == 1) & (pred == 1)).sum())
|
||||
tn = int(((yt == 0) & (pred == 0)).sum())
|
||||
|
||||
roc = roc_auc_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
pr = average_precision_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
acc = accuracy_score(yt, pred)
|
||||
prec = tp / max(tp + fp, 1)
|
||||
rec = tp / max(tp + fn, 1)
|
||||
|
||||
fold_metrics.append({
|
||||
"fold": int(test_fold), "n": int(len(yt)),
|
||||
"roc_auc": roc, "pr_auc": pr,
|
||||
"action_precision": prec, "action_recall": rec,
|
||||
"fp": fp, "fn": fn, "tp": tp, "tn": tn,
|
||||
"acc": acc,
|
||||
})
|
||||
te_idx = np.where(te)[0]
|
||||
for i in range(len(yt)):
|
||||
oof_rows.append({
|
||||
"fold": int(test_fold),
|
||||
"proba": float(proba[i]),
|
||||
"pred": int(pred[i]),
|
||||
"true": int(yt[i]),
|
||||
})
|
||||
|
||||
return oof_rows, fold_metrics
|
||||
|
||||
|
||||
# ─── Metrics from OOF ───────────────────────────────────────────────────────
|
||||
|
||||
def binary_metrics_from_oof(oof_rows):
|
||||
yt = np.array([r["true"] for r in oof_rows])
|
||||
yp = np.array([r["pred"] for r in oof_rows])
|
||||
proba = np.array([r["proba"] for r in oof_rows])
|
||||
n = len(yt)
|
||||
tp = int(((yt == 1) & (yp == 1)).sum())
|
||||
fp = int(((yt == 0) & (yp == 1)).sum())
|
||||
fn = int(((yt == 1) & (yp == 0)).sum())
|
||||
prec = tp / max(tp + fp, 1)
|
||||
rec = tp / max(tp + fn, 1)
|
||||
roc = roc_auc_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
pr = average_precision_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
return {
|
||||
"n": n, "tp": tp, "fp": fp, "fn": fn,
|
||||
"action_precision": prec, "action_recall": rec,
|
||||
"fa_rate": fp / max(n, 1),
|
||||
"roc_auc": roc, "pr_auc": pr,
|
||||
}
|
||||
|
||||
|
||||
def threshold_curve(oof_rows, thresholds):
|
||||
yt = np.array([r["true"] for r in oof_rows])
|
||||
proba = np.array([r["proba"] for r in oof_rows])
|
||||
rows = []
|
||||
for thr in thresholds:
|
||||
yp = (proba >= thr).astype(int)
|
||||
tp = int(((yt == 1) & (yp == 1)).sum())
|
||||
fp = int(((yt == 0) & (yp == 1)).sum())
|
||||
fn = int(((yt == 1) & (yp == 0)).sum())
|
||||
rows.append({
|
||||
"threshold": round(float(thr), 4),
|
||||
"action_precision": round(tp / max(tp + fp, 1), 4),
|
||||
"action_recall": round(tp / max(tp + fn, 1), 4),
|
||||
"fa_count": fp,
|
||||
"fa_rate": round(fp / max(len(yt), 1), 4),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# ─── Leave-Family-Out ───────────────────────────────────────────────────────
|
||||
|
||||
def run_leave_family_out(texts, y, family_per_row, family, kind):
|
||||
"""Train without `family`, evaluate on `family` only."""
|
||||
mask_members = family_per_row == family
|
||||
if mask_members.sum() == 0:
|
||||
return None
|
||||
only_family = (mask_members).astype(bool)
|
||||
train_idx = np.where(~only_family)[0]
|
||||
test_idx = np.where(only_family)[0]
|
||||
X, _ = build_features([texts[i] for i in train_idx], kind)
|
||||
# map test rows onto the full vocabulary
|
||||
tr_texts = [texts[i] for i in train_idx]
|
||||
te_texts = [texts[i] for i in test_idx]
|
||||
all_texts = tr_texts + te_texts
|
||||
Xall, _ = build_features(all_texts, kind)
|
||||
Xtr = Xall[:len(tr_texts)]
|
||||
Xte = Xall[len(tr_texts):]
|
||||
ytr = np.array([1 if y[i] == "action" else 0 for i in train_idx])
|
||||
yte = np.array([1 if y[i] == "action" else 0 for i in test_idx])
|
||||
clf = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(Xtr, ytr)
|
||||
pred = clf.predict(Xte)
|
||||
tp = int(((yte == 1) & (pred == 1)).sum())
|
||||
fp = int(((yte == 0) & (pred == 1)).sum())
|
||||
fn = int(((yte == 1) & (pred == 0)).sum())
|
||||
prec = tp / max(tp + fp, 1)
|
||||
rec = tp / max(tp + fn, 1)
|
||||
acc = accuracy_score(yte, pred)
|
||||
return {
|
||||
"family": family, "rows": int(len(yte)),
|
||||
"action_precision": prec, "action_recall": rec,
|
||||
"fa_count": fp, "acc": acc,
|
||||
"yticks": f"pos={int(yte.sum())} neg={int((yte==0).sum())}",
|
||||
}
|
||||
|
||||
|
||||
# ─── Paired Action/Capability Test ──────────────────────────────────────────
|
||||
|
||||
def paired_action_capability(texts, y, tags_per_row_by_idx, kind, C=1.0):
|
||||
"""For the best sparse representation, evaluate pairwise ordering."""
|
||||
idx = list(range(len(texts)))
|
||||
emb = build_features(texts, kind)[0]
|
||||
clf = LogisticRegression(C=C, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
yb = np.array([1 if t == "action" else 0 for t in y])
|
||||
# use OOF-style: fit on full then derive? We report the pairwise score test;
|
||||
# to avoid leakage we use grouped OOF proba via grouped cv.
|
||||
return None
|
||||
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
meta, examples = load_data()
|
||||
dev = filter_dev_pool(examples)
|
||||
dev_res = residual_only(dev)
|
||||
|
||||
# normalized texts
|
||||
for e in dev:
|
||||
e["n_text"] = normalize_match_text(e["text"])
|
||||
|
||||
texts = [e["n_text"] for e in dev]
|
||||
y = [e["route"] for e in dev]
|
||||
folds = [e["cv_fold"] for e in dev]
|
||||
tags = [set(e.get("tags", [])) for e in dev]
|
||||
split_groups = [e["split_group"] for e in dev]
|
||||
source_ids = [e["source_id"] for e in dev]
|
||||
|
||||
n_action = sum(1 for r in y if r == "action")
|
||||
n_not = len(y) - n_action
|
||||
print(f"Dev pool: {len(dev)} action={n_action} not_action={n_not}")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
|
||||
# ── 1. Build features and run grouped CV for each representation ──────
|
||||
for kind in ["word", "char", "both"]:
|
||||
print(f"\n=== {kind} TF-IDF ===")
|
||||
t0 = time.time()
|
||||
X, vec = build_features(texts, kind)
|
||||
build_t = time.time() - t0
|
||||
vs = vocab_size(vec)
|
||||
print(f" vocab={vs} X.shape={X.shape} build={build_t:.2f}s")
|
||||
|
||||
oof, folds_m = run_binary_grouped_cv(X, y, folds, C=1.0)
|
||||
m = binary_metrics_from_oof(oof)
|
||||
results[kind] = {
|
||||
"vs": vs, "build_t": build_t, "oof": oof, "fold_metrics": folds_m,
|
||||
"metrics": m, "X": X, "vec": vec,
|
||||
"texts": texts, "y": y, "folds": folds,
|
||||
}
|
||||
print(f" ROC={ff(m['roc_auc'])} PR={ff(m['pr_auc'])} P={ff(m['action_precision'])} "
|
||||
f"R={ff(m['action_recall'])} FA={m['fp']} ({fmt_pct(m['fa_rate'])})")
|
||||
|
||||
# fold-level
|
||||
for fm in folds_m:
|
||||
print(f" fold {fm['fold']}: ROC={ff(fm['roc_auc'])} PR={ff(fm['pr_auc'])} "
|
||||
f"P={ff(fm['action_precision'])} R={ff(fm['action_recall'])} "
|
||||
f"FP={fm['fp']} FN={fm['fn']} n={fm['n']}")
|
||||
|
||||
# pick best by PR-AUC
|
||||
best_kind = max(["word", "char", "both"], key=lambda k: results[k]["metrics"]["pr_auc"])
|
||||
print(f"\nBest representation by PR-AUC: {best_kind}")
|
||||
|
||||
# ── 2. Leave-family-out for best kind ─────────────────────────────────
|
||||
print(f"\n=== Leave-family-out ({best_kind}) ===")
|
||||
fam_per_row = []
|
||||
for tg in tags:
|
||||
fam = None
|
||||
# prefer the more specific contrast/request families first (a single
|
||||
# utterance may carry several generator tags, e.g. capability_question
|
||||
# plus direct_imperative). Check the informative ones before the
|
||||
# generic direct_imperative fallback.
|
||||
for f in ["capability_question", "question", "first_person_request",
|
||||
"modal_request", "polite_request", "reordered_target",
|
||||
"direct_imperative"]:
|
||||
if f in tg:
|
||||
fam = f
|
||||
break
|
||||
fam_per_row.append(fam)
|
||||
fam_per_row = np.array(fam_per_row, dtype=object)
|
||||
lfo = {}
|
||||
for fam in PRESENT_FAMILIES:
|
||||
r = run_leave_family_out(texts, y, fam_per_row, fam, best_kind)
|
||||
if r is None:
|
||||
print(f" {fam}: (no rows)")
|
||||
continue
|
||||
lfo[fam] = r
|
||||
print(f" {fam}: rows={r['rows']} ({r['yticks']}) P={ff(r['action_precision'])} "
|
||||
f"R={ff(r['action_recall'])} FA={r['fa_count']} acc={fmt_pct(r['acc'])}")
|
||||
|
||||
# ── 3. Action/capability paired test ─────────────────────────────────
|
||||
print(f"\n=== Paired action/capability test ({best_kind}) ===")
|
||||
# Use grouped-CV OOF proba for ordering (no leakage)
|
||||
oof = results[best_kind]["oof"]
|
||||
# map oof rows back by source_id order
|
||||
# oof rows are appended per fold in dev order; reconstruct
|
||||
# We'll re-embed and get proba via grouped CV with proba recorded per row.
|
||||
# Re-run grouped CV capturing per-row proba aligned to dev indices.
|
||||
X = results[best_kind]["X"]
|
||||
yb = np.array([1 if r == "action" else 0 for r in y])
|
||||
folds_arr = np.array(folds)
|
||||
dev_proba = np.zeros(len(dev))
|
||||
for te_fold in sorted(set(folds)):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
clf = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yb[tr])
|
||||
dev_proba[te] = clf.predict_proba(X[te])[:, 1]
|
||||
|
||||
# group action seeds: pair each capability-question row with action rows
|
||||
# sharing the same target object noun and the same act (turn_on/turn_off/…).
|
||||
# Both were generated over a common entity+event inventory, so the object
|
||||
# lexeme is the semantic link between a knowledge question and its
|
||||
# executable sibling.
|
||||
DEVICES = [
|
||||
"свет", "люстру", "люстра", "жалюзи", "вытяжку", "вытяжка",
|
||||
"вентилятор", "кондиционер", "телевизор", "лампу", "лампа",
|
||||
"музыку", "музыка", "плеер", "колонку", "колонки", "чайник",
|
||||
"бойлер", "обогреватель", "пылесос", "пылесосом", "пол",
|
||||
"поливалки", "полив", "арка", "шторы", "штору", "штору",
|
||||
"динамики", "дверь", "двери", "замок", "гараж", "ворота",
|
||||
"кофе", "пасту", "зубы", "крючки", "лаймо", "куртку",
|
||||
"будильник", "таймер", "напоминание", "расписание",
|
||||
]
|
||||
|
||||
def object_nouns(t):
|
||||
found = set()
|
||||
tl = t.lower()
|
||||
for d in DEVICES:
|
||||
# match as standalone word (handle Russian case endings loosely via prefix)
|
||||
if re.search(r"\b" + re.escape(d), tl):
|
||||
found.add(d)
|
||||
return found
|
||||
|
||||
# verb/act family per row from source_id (e.g. ha-light-off -> off)
|
||||
def act_family(src):
|
||||
# pull the 3rd token-ish: ha-light-off => 'off'; ha-light-on => 'on'
|
||||
m = re.search(r"^(\w+)-([a-z_]+)-([a-zA-Z_]+)", src)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
# generic fallback
|
||||
return src.split("-")[0]
|
||||
|
||||
cap_rows = [i for i in range(len(dev)) if "capability_question" in tags[i]]
|
||||
act_idxs = [i for i in range(len(dev)) if y[i] == "action"]
|
||||
|
||||
# For each capability row, candidate sibling actions: same object noun
|
||||
# AND same act (turn_on vs turn_off), i.e. same domain+object. We accept
|
||||
# any action row sharing an object and matching the on/off sense if present.
|
||||
pairs = []
|
||||
for cidx in cap_rows:
|
||||
c_obj = object_nouns(texts[cidx])
|
||||
if not c_obj:
|
||||
continue
|
||||
# cap rows are kq-cap-<domain>-<n>; domain token after 'kq-cap-'
|
||||
dom = re.search(r"kq-cap-([^-]+)", source_ids[cidx])
|
||||
dom = dom.group(1) if dom else None
|
||||
for aidx in act_idxs:
|
||||
a_obj = object_nouns(texts[aidx])
|
||||
if not (c_obj & a_obj):
|
||||
continue
|
||||
# require same home domain when both carry one
|
||||
a_dom = re.search(r"^([a-z]+)-", source_ids[aidx])
|
||||
a_dom = a_dom.group(1) if a_dom else None
|
||||
if dom and a_dom and dom != a_dom:
|
||||
continue
|
||||
pairs.append((cidx, aidx))
|
||||
|
||||
# keep it bounded: cap row pairs with many actions (one per room); that's fine
|
||||
order_ok = 0
|
||||
margins = []
|
||||
reversed_pairs = []
|
||||
for cidx, aidx in pairs:
|
||||
pc = dev_proba[cidx]
|
||||
pa = dev_proba[aidx]
|
||||
margins.append(pa - pc)
|
||||
if pa > pc:
|
||||
order_ok += 1
|
||||
else:
|
||||
reversed_pairs.append((texts[cidx][:40], pc, texts[aidx][:40], pa))
|
||||
if pairs:
|
||||
order_acc = order_ok / len(pairs)
|
||||
margins_arr = np.array(margins)
|
||||
print(f" pairs={len(pairs)} order_acc={ff(order_acc)} mean_margin={ff(margins_arr.mean())} "
|
||||
f"median_margin={ff(np.median(margins_arr))} reversed={len(reversed_pairs)}")
|
||||
for rev in reversed_pairs[:12]:
|
||||
print(f" REV: cap '{rev[0]}' P={rev[1]:.3f} < act '{rev[2]}' P={rev[3]:.3f}")
|
||||
else:
|
||||
order_acc = None
|
||||
print(" (no matched pairs)")
|
||||
|
||||
paired = {
|
||||
"pairs": len(pairs), "order_acc": order_acc,
|
||||
"mean_margin": float(np.mean(margins)) if margins else None,
|
||||
"median_margin": float(np.median(margins)) if margins else None,
|
||||
"reversed": len(reversed_pairs),
|
||||
}
|
||||
|
||||
# ── 4. Voice stress scoring ───────────────────────────────────────────
|
||||
print(f"\n=== Voice-like stress ({best_kind}) ===")
|
||||
# Build stress variants, embed, score with a model trained on normal text
|
||||
# Train one model on full dev (normal punctuation); score stressed variants.
|
||||
clf_full = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf_full.fit(X, yb)
|
||||
|
||||
stress_texts = []
|
||||
stress_meta = [] # (orig_index, mode)
|
||||
for i in range(len(dev)):
|
||||
orig = texts[i]
|
||||
s_nofinal = re.sub(r"[?.!,;:]+$", "", orig)
|
||||
s_all = strip_punct(dev[i]["text"])
|
||||
if s_nofinal != orig:
|
||||
stress_texts.append(s_nofinal); stress_meta.append((i, "no_final"))
|
||||
if s_all != orig:
|
||||
stress_texts.append(s_all); stress_meta.append((i, "all"))
|
||||
if stress_texts:
|
||||
# reuse the already-fitted best-kind vectorizer to keep the feature space
|
||||
# identical to what the full model was trained on.
|
||||
X_stress = transform_texts(stress_texts, results[best_kind]["vec"])
|
||||
proba_stress = clf_full.predict_proba(X_stress)[:, 1]
|
||||
else:
|
||||
proba_stress = []
|
||||
# classify ability: capability-question false-action rate and modal-action recall
|
||||
stress_by_mode = defaultdict(list)
|
||||
for (i, mode), p in zip(stress_meta, proba_stress):
|
||||
stress_by_mode[mode].append((i, p, y[i], tags[i]))
|
||||
voice = {}
|
||||
for mode, rows in stress_by_mode.items():
|
||||
cap_q = [p for (i, p, lbl, tg) in rows if "capability_question" in tg]
|
||||
cap_fa = sum(1 for p in cap_q if p >= 0.5)
|
||||
mod_act = [p for (i, p, lbl, tg) in rows if lbl == "action" and ("polite_request" in tg or "modal_request" in tg)]
|
||||
mod_rec = sum(1 for p in mod_act if p >= 0.5) / max(len(mod_act), 1)
|
||||
voice[mode] = {
|
||||
"n": len(rows),
|
||||
"cap_q_fa": cap_fa / max(len(cap_q), 1), "cap_q_n": len(cap_q),
|
||||
"modal_action_recall": mod_rec, "modal_n": len(mod_act),
|
||||
}
|
||||
print(f" {mode}: n={len(rows)} cap_q_FA={fmt_pct(voice[mode]['cap_q_fa'])} ({voice[mode]['cap_q_n']}) "
|
||||
f"modal_recall={ff(voice[mode]['modal_action_recall'])} ({voice[mode]['modal_n']})")
|
||||
|
||||
# ── 5. Punctuation ablation ───────────────────────────────────────────
|
||||
print(f"\n=== Punctuation ablation ({best_kind}) ===")
|
||||
texts_stripped = [strip_punct(dev[i]["text"]) for i in range(len(dev))]
|
||||
X_stripped_train, vec_stripped = build_features(texts_stripped, best_kind)
|
||||
clf_stripped = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf_stripped.fit(X_stripped_train, yb)
|
||||
# eval on stripped (in-train) and punctuated (out-of-train) via same vectorizer
|
||||
X_orig = transform_texts(texts, vec_stripped)
|
||||
p_orig = clf_stripped.predict_proba(X_orig)[:, 1]
|
||||
p_strip = clf_stripped.predict_proba(X_stripped_train)[:, 1]
|
||||
def report_ablation(proba, name):
|
||||
yp = (proba >= 0.5).astype(int)
|
||||
tp = int(((yb == 1) & (yp == 1)).sum())
|
||||
fp = int(((yb == 0) & (yp == 1)).sum())
|
||||
fn = int(((yb == 1) & (yp == 0)).sum())
|
||||
prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)
|
||||
print(f" trained-stripped, eval {name}: P={ff(prec)} R={ff(rec)} FA={fp} ({fmt_pct(fp/len(yb))})")
|
||||
report_ablation(p_orig, "punctuated")
|
||||
report_ablation(p_strip, "stripped")
|
||||
|
||||
# ── 6. Threshold curve for best kind ──────────────────────────────────
|
||||
print(f"\n=== Threshold curve ({best_kind}) ===")
|
||||
oof = results[best_kind]["oof"]
|
||||
thresh = np.arange(0.10, 0.995, 0.015).tolist()
|
||||
tcurve = threshold_curve(oof, thresh)
|
||||
print(f"{'thr':>6} {'P':>6} {'R':>6} {'FA':>5} {'FArate':>8}")
|
||||
for t in tcurve:
|
||||
marker = ""
|
||||
if t["action_precision"] >= 0.95:
|
||||
marker = " ← P>=0.95"
|
||||
print(f"{t['threshold']:>6.3f} {t['action_precision']:>6.3f} {t['action_recall']:>6.3f} "
|
||||
f"{t['fa_count']:>5} {t['fa_rate']:>8.4f}{marker}")
|
||||
p95 = [t for t in tcurve if t["action_precision"] >= 0.95 and t["action_recall"] > 0.01]
|
||||
print(f"\nP>=0.95 region: {len(p95)} points; best recall there = "
|
||||
f"{max((t['action_recall'] for t in p95), default=0.0):.4f}")
|
||||
|
||||
# threshold curve using the (no_leak) dev proba instead of .5-threshold OOF
|
||||
# OOF pred used fixed 0.5; curve above re-derives from proba. Good.
|
||||
|
||||
# ── 7. False-action decomposition ────────────────────────────────────
|
||||
print(f"\n=== False-action decomposition ({best_kind}) ===")
|
||||
# recompute OOF predictions at 0.5 from stored 'pred'
|
||||
fa_by_family = Counter()
|
||||
fa_by_group = Counter()
|
||||
# need oof aligned to source_ids — oof stored without source id; rebuild
|
||||
# Re-do grouped cv capturing source_id + tag + split_group
|
||||
fake_oof = []
|
||||
for te_fold in sorted(set(folds_arr)):
|
||||
tr = folds_arr != te_fold; te = folds_arr == te_fold
|
||||
clf = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yb[tr])
|
||||
p = clf.predict_proba(X[te])[:, 1]
|
||||
pr = (p >= 0.5).astype(int)
|
||||
te_idx = np.where(te)[0]
|
||||
for k, i in enumerate(te_idx):
|
||||
fake_oof.append({
|
||||
"source_id": source_ids[i], "split_group": split_groups[i],
|
||||
"tags": tags[i], "true": y[i], "proba": float(p[k]),
|
||||
"pred": int(pr[k]), "fold": int(te_fold),
|
||||
})
|
||||
|
||||
def classify_semantic_family(row):
|
||||
tg = row["tags"]
|
||||
if "capability_question" in tg: return "capability_question"
|
||||
if "question" in tg: return "ordinary_question"
|
||||
if "remember" in tg or "note" in tg or "idea" in tg or "free_form" in tg: return "memory_write"
|
||||
if "version" in tg or "system" in tg or "health" in tg or "status" in tg: return "system"
|
||||
if "recall" in tg or "world" in tg or "definition" in tg or "aggregate" in tg: return "knowledge_general"
|
||||
if "greeting" in tg or "goodbye" in tg or "thanks" in tg: return "conversation"
|
||||
if row["true"].startswith("system"): return "system"
|
||||
return "other"
|
||||
|
||||
for r in fake_oof:
|
||||
if r["pred"] == 1 and r["true"] != "action":
|
||||
fam = classify_semantic_family(r)
|
||||
fa_by_family[fam] += 1
|
||||
fa_by_group[r["split_group"]] += 1
|
||||
|
||||
print("By family:")
|
||||
for k, v in fa_by_family.most_common():
|
||||
print(f" {k}: {v}")
|
||||
print("By SplitGroup (top 15):")
|
||||
for k, v in fa_by_group.most_common(15):
|
||||
print(f" {k}: {v}")
|
||||
|
||||
# ── 8. Six-way probe on best representation ──────────────────────────
|
||||
print(f"\n=== Six-way probe ({best_kind}) ===")
|
||||
routes = ["action", "conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
y6 = np.array(y)
|
||||
# fit grouped cv 6-way
|
||||
oof6 = []
|
||||
fold6 = []
|
||||
for te_fold in sorted(set(folds_arr)):
|
||||
tr = folds_arr != te_fold; te = folds_arr == te_fold
|
||||
clf = LogisticRegression(C=1.0, max_iter=3000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], y6[tr])
|
||||
pr = clf.predict(X[te])
|
||||
p6 = clf.predict_proba(X[te])
|
||||
classes = clf.classes_
|
||||
te_idx = np.where(te)[0]
|
||||
for k, i in enumerate(te_idx):
|
||||
oof6.append({
|
||||
"true": y6[i], "pred": pr[k], "proba": {c: float(p6[k][j]) for j, c in enumerate(classes)},
|
||||
"tags": tags[i], "split_group": split_groups[i], "source_id": source_ids[i],
|
||||
})
|
||||
yt6 = [r["true"] for r in oof6]; yp6 = [r["pred"] for r in oof6]
|
||||
acc6 = accuracy_score(yt6, yp6)
|
||||
macro6 = f1_score(yt6, yp6, average="macro", zero_division=0)
|
||||
prec6, rec6, f16, sup6 = precision_recall_fscore_support(yt6, yp6, labels=routes, zero_division=0)
|
||||
fa6 = sum(1 for t, p in zip(yt6, yp6) if t != "action" and p == "action")
|
||||
ap6 = sum(1 for t, p in zip(yt6, yp6) if t == "action" and p == "action") / max(sum(1 for p in yp6 if p == "action"), 1)
|
||||
ar6 = sum(1 for t, p in zip(yt6, yp6) if t == "action" and p == "action") / max(sum(1 for t in yt6 if t == "action"), 1)
|
||||
print(f" acc={fmt_pct(acc6)} macroF1={ff(macro6)} actionP={ff(ap6)} actionR={ff(ar6)} FA={fa6} ({fmt_pct(fa6/len(yt6))})")
|
||||
for i, r in enumerate(routes):
|
||||
print(f" {r:<12} P={ff(prec6[i])} R={ff(rec6[i])} F1={ff(f16[i])} n={int(sup6[i])}")
|
||||
|
||||
# ── 9. Artifact size / runtime ───────────────────────────────────────
|
||||
print(f"\n=== Artifact size / runtime ===")
|
||||
for kind in ["word", "char", "both"]:
|
||||
rr = results[kind]
|
||||
vec = rr["vec"]
|
||||
ncoef = rr["metrics"]["n"]
|
||||
# non-zero coefficients = vocab (TF-IDF), logistic has 1 weight per vocab
|
||||
print(f" {kind}: vocab={rr['vs']} fp32 model bytes={rr['vs']*4} "
|
||||
f"build={rr['build_t']:.3f}s")
|
||||
|
||||
# done
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 19 tokenizers, derived from the development corpus only.
|
||||
|
||||
A. CharVocab — codepoint ids over the dev corpus (deterministic order)
|
||||
B. BpeVocab2048 — byte-level BPE, vocab ~2048, trained on dev corpus only
|
||||
|
||||
Records for §4 of the brief: vocab size, OOV behaviour, serialized tokenizer size.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from tokenizers import Tokenizer
|
||||
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
|
||||
from tokenizers.models import BPE
|
||||
from tokenizers.pre_tokenizers import ByteLevel as ByteLevelPreTokenizer
|
||||
from tokenizers.trainers import BpeTrainer
|
||||
|
||||
|
||||
class CharVocab:
|
||||
"""Codepoint ids from the dev corpus, sorted by codepoint value."""
|
||||
|
||||
def __init__(self, texts):
|
||||
chars = set()
|
||||
for t in texts:
|
||||
chars.update(t)
|
||||
self.id_to_char = sorted(chars)
|
||||
self.char_to_id = {c: i + 1 for i, c in enumerate(self.id_to_char)} # 0 = PAD
|
||||
self.pad = 0
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return len(self.id_to_char) + 1
|
||||
|
||||
def encode(self, text, max_len):
|
||||
ids = [self.char_to_id.get(c, 0) for c in text] # 0 doubles as UNK/OOV
|
||||
return ids[:max_len]
|
||||
|
||||
|
||||
class BpeVocab:
|
||||
"""Byte-level BPE, trained only on the strings it is given."""
|
||||
|
||||
def __init__(self, texts, vocab_size=2048, sep="▁"):
|
||||
self.tok = Tokenizer(BPE())
|
||||
self.tok.pre_tokenizer = ByteLevelPreTokenizer(trim_offsets=False)
|
||||
self.tok.decoder = ByteLevelDecoder()
|
||||
trainer = BpeTrainer(vocab_size=vocab_size, special_tokens=["[PAD]"],
|
||||
show_progress=False)
|
||||
# train on the corpus *strings*, byte-level BPE handles all codepoints
|
||||
self.tok.train_from_iterator(texts, trainer=trainer)
|
||||
self.pad_id = self.tok.token_to_id("[PAD]")
|
||||
self._vocab = self.tok.get_vocab()
|
||||
self._n = len(self._vocab)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._n
|
||||
|
||||
def encode(self, text):
|
||||
return self.tok.encode(text).ids
|
||||
|
||||
def serialized_bytes(self):
|
||||
# measure the serialized tokenizer size on disk
|
||||
import os
|
||||
d = self.tok.to_str()
|
||||
return len(d.encode("utf-8"))
|
||||
|
||||
|
||||
def normalize_match_text(s: str) -> str:
|
||||
"""NFKC → lowercase → collapse whitespace. Punctuation kept."""
|
||||
out = unicodedata.normalize("NFKC", s).strip().lower()
|
||||
out = re.sub(r"\s+", " ", out)
|
||||
return out
|
||||
|
||||
|
||||
def strip_punct(text: str) -> str:
|
||||
"""Remove safe punctuation from an already-normalized text."""
|
||||
t = re.sub(r"[^\w\s]", " ", text)
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
return t
|
||||
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 19: tiny sequence-sensitive pragmatics specialists (binary action gate)
|
||||
=============================================================================
|
||||
|
||||
A narrow binary specialist: executable request vs semantically similar
|
||||
non-executable utterance, generalizing across surface-generator families.
|
||||
|
||||
Commands
|
||||
grouped — grouped semantic CV (existing cv_fold), all arch/sizes, saves
|
||||
per-config per-fold model checkpoints + OOF proba per variant
|
||||
metrics — aggregate saved grouped-CV results into the report tables
|
||||
(binary metrics, threshold curves, pair ordering, stress)
|
||||
lfo — leave-generator-out. All sizes on capability_question; the
|
||||
other present families for the leading config only.
|
||||
e5baseline— frozen-e5 logistic + MLP baselines: grouped, cap-Q LOFO, pair
|
||||
ordering (stress flagged NA — no re-embed on this box)
|
||||
runtime — params / sizes / latency / tokenization for each candidate
|
||||
|
||||
Primary metrics (brief §2): cap-Q LOFO FA, pair ordering acc, pair margin,
|
||||
grouped P/R, voice stress, fold variance. Aggregate accuracy is secondary.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import resource
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from slice19_bpe import BpeVocab, CharVocab, normalize_match_text, strip_punct
|
||||
|
||||
EMBEDDING_PATH = "/tmp/mvn-experiment/embeddings.json"
|
||||
RESULTS_DIR = "/tmp/mvn-s19"
|
||||
SEED = 42
|
||||
MAX_CHAR = 64
|
||||
MAX_BPE = 24
|
||||
BPE_VOCAB = 2048
|
||||
|
||||
PRESENT_FAMILIES = [
|
||||
"polite_request",
|
||||
"modal_request",
|
||||
"first_person_request",
|
||||
"reordered_target",
|
||||
"capability_question",
|
||||
"question",
|
||||
]
|
||||
ABSENT_FAMILIES = ["negation", "reported_speech", "quotation", "hypothetical"]
|
||||
|
||||
FAMILY_PRIORITY = [
|
||||
"capability_question", "question", "first_person_request",
|
||||
"modal_request", "polite_request", "reordered_target", "direct_imperative",
|
||||
]
|
||||
|
||||
DEVICES = [
|
||||
"свет", "люстру", "люстра", "жалюзи", "вытяжку", "вытяжка",
|
||||
"вентилятор", "кондиционер", "телевизор", "лампу", "лампа",
|
||||
"музыку", "музыка", "плеер", "колонку", "колонки", "чайник",
|
||||
"бойлер", "обогреватель", "пылесос", "пылесосом", "пол",
|
||||
"поливалки", "полив", "арка", "шторы", "штору",
|
||||
"динамики", "дверь", "двери", "замок", "гараж", "ворота",
|
||||
"кофе", "пасту", "зубы", "крючки", "лаймо", "куртку",
|
||||
"будильник", "таймер", "напоминание", "расписание",
|
||||
]
|
||||
|
||||
ARCH_CONFIGS = {
|
||||
"char_cnn": ["tiny", "medium"],
|
||||
"bigru": ["tiny", "medium", "large"],
|
||||
"tiny_transformer": ["small", "medium"],
|
||||
}
|
||||
|
||||
TRAIN_HYPER = {
|
||||
"char_cnn": dict(epochs=20, lr=1e-3, bs=64, clip=None),
|
||||
"bigru": dict(epochs=15, lr=5e-4, bs=64, clip=1.0),
|
||||
"tiny_transformer": dict(epochs=25, lr=5e-4, bs=64, clip=1.0),
|
||||
}
|
||||
|
||||
VARIANTS = ["orig", "nofinal", "strip"]
|
||||
|
||||
|
||||
# ─── data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dev():
|
||||
with open(EMBEDDING_PATH) as f:
|
||||
data = json.load(f)
|
||||
rows = [e for e in data["examples"] if e["dev_pool"]]
|
||||
out = []
|
||||
for e in rows:
|
||||
n_text = normalize_match_text(e["text"])
|
||||
out.append({
|
||||
"text_orig": n_text,
|
||||
"text_nofinal": re.sub(r"[?.!,;:]+$", "", n_text),
|
||||
"text_strip": strip_punct(n_text),
|
||||
"route": e["route"],
|
||||
"y": 1 if e["route"] == "action" else 0,
|
||||
"cv_fold": e["cv_fold"],
|
||||
"split_group": e["split_group"],
|
||||
"tags": set(e.get("tags", [])),
|
||||
"source_id": e["source_id"],
|
||||
"emb": np.asarray(e["embedding"], dtype=np.float32),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def family_of(tags):
|
||||
for f in FAMILY_PRIORITY:
|
||||
if f in tags:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def build_pairs(rows, texts):
|
||||
"""Capability-question vs action-by-shared-object pairs (eval only)."""
|
||||
def object_nouns(t):
|
||||
found = set()
|
||||
tl = t.lower()
|
||||
for d in DEVICES:
|
||||
if re.search(r"\b" + re.escape(d), tl):
|
||||
found.add(d)
|
||||
return found
|
||||
|
||||
pairs = []
|
||||
for cidx, r in enumerate(rows):
|
||||
if "capability_question" not in r["tags"]:
|
||||
continue
|
||||
c_obj = object_nouns(texts[cidx])
|
||||
if not c_obj:
|
||||
continue
|
||||
dom = re.search(r"kq-cap-([^-]+)", r["source_id"])
|
||||
dom = dom.group(1) if dom else None
|
||||
for aidx, ra in enumerate(rows):
|
||||
if ra["y"] != 1:
|
||||
continue
|
||||
a_obj = object_nouns(texts[aidx])
|
||||
if not (c_obj & a_obj):
|
||||
continue
|
||||
a_dom = re.search(r"^([a-z]+)-", ra["source_id"])
|
||||
a_dom = a_dom.group(1) if a_dom else None
|
||||
if dom and a_dom and dom != a_dom:
|
||||
continue
|
||||
pairs.append((cidx, aidx))
|
||||
return pairs
|
||||
|
||||
|
||||
def pair_metrics(pairs, proba):
|
||||
if not pairs:
|
||||
return {"pairs": 0}
|
||||
margins = []
|
||||
ties = 0
|
||||
order = 0
|
||||
rev = 0
|
||||
for cidx, aidx in pairs:
|
||||
pc, pa = proba[cidx], proba[aidx]
|
||||
margins.append(pa - pc)
|
||||
if pa > pc:
|
||||
order += 1
|
||||
elif pa == pc:
|
||||
ties += 1
|
||||
else:
|
||||
rev += 1
|
||||
m = np.array(margins)
|
||||
return {
|
||||
"pairs": len(pairs),
|
||||
"ordering_acc": order / len(pairs),
|
||||
"mean_margin": float(m.mean()),
|
||||
"median_margin": float(np.median(m)),
|
||||
"ties": ties,
|
||||
"reversed": rev,
|
||||
}
|
||||
|
||||
|
||||
# ─── tokenizers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_tokenizers(rows):
|
||||
nat_texts = [r["text_orig"] for r in rows]
|
||||
char_vocab = CharVocab(nat_texts)
|
||||
bpe = BpeVocab(nat_texts, vocab_size=BPE_VOCAB)
|
||||
return char_vocab, bpe
|
||||
|
||||
|
||||
def encode_all(rows, tokenizer, kind):
|
||||
"""Return dict variant -> (N, max_len) int64 array."""
|
||||
max_len = MAX_CHAR if kind == "char" else MAX_BPE
|
||||
out = {}
|
||||
for v in VARIANTS:
|
||||
arr = np.zeros((len(rows), max_len), dtype=np.int64)
|
||||
for i, r in enumerate(rows):
|
||||
if kind == "char":
|
||||
ids = tokenizer.encode(r[f"text_{v}"], max_len)
|
||||
arr[i, :len(ids)] = ids
|
||||
else:
|
||||
ids = tokenizer.encode(r[f"text_{v}"])[:max_len]
|
||||
arr[i, :len(ids)] = ids
|
||||
out[v] = arr
|
||||
return out
|
||||
|
||||
|
||||
# ─── training ───────────────────────────────────────────────────────────────
|
||||
|
||||
def build_model(arch, size, vocab_size, max_len):
|
||||
import torch
|
||||
from slice19_models import CharCNN, BiGRU, TinyTransformer
|
||||
if arch == "char_cnn":
|
||||
c = (dict(embed_dim=32, filters=64, widths=[3, 4, 5]) if size == "tiny"
|
||||
else dict(embed_dim=64, filters=160, widths=[2, 3, 4, 5]))
|
||||
return CharCNN(vocab_size, c["embed_dim"], c["filters"], c["widths"])
|
||||
if arch == "bigru":
|
||||
c = (dict(embed_dim=64, hidden=64) if size == "tiny" else
|
||||
(dict(embed_dim=128, hidden=128) if size == "medium" else
|
||||
dict(embed_dim=256, hidden=256)))
|
||||
return BiGRU(vocab_size, c["embed_dim"], c["hidden"])
|
||||
c = (dict(d_model=128, n_layers=2, n_heads=4) if size == "small" else
|
||||
dict(d_model=192, n_layers=4, n_heads=4))
|
||||
return TinyTransformer(vocab_size, c["d_model"], c["n_layers"], c["n_heads"],
|
||||
max_len=max_len)
|
||||
|
||||
|
||||
def train_binary(X, y, arch, size, vocab_size, seed_offset=0, log=False):
|
||||
import torch
|
||||
torch.manual_seed(SEED + seed_offset)
|
||||
np.random.seed(SEED + seed_offset)
|
||||
Xt = torch.from_numpy(X)
|
||||
yt = torch.from_numpy(y.astype(np.float32))
|
||||
model = build_model(arch, size, vocab_size, X.shape[1])
|
||||
h = TRAIN_HYPER[arch]
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=h["lr"], weight_decay=1e-4)
|
||||
lossf = torch.nn.BCEWithLogitsLoss()
|
||||
n = X.shape[0]
|
||||
model.train()
|
||||
t0 = time.time()
|
||||
for epoch in range(h["epochs"]):
|
||||
perm = torch.randperm(n)
|
||||
running = 0.0
|
||||
n_b = 0
|
||||
for start in range(0, n, h["bs"]):
|
||||
idx = perm[start:start + h["bs"]]
|
||||
xb = Xt[idx]
|
||||
if xb.dim() == 1:
|
||||
xb = xb.unsqueeze(0)
|
||||
logits = model(xb)
|
||||
loss = lossf(logits, yt[idx])
|
||||
opt.zero_grad()
|
||||
loss.backward()
|
||||
if h["clip"]:
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), h["clip"])
|
||||
opt.step()
|
||||
running += float(loss)
|
||||
n_b += 1
|
||||
if log and (epoch + 1) % 5 == 0:
|
||||
print(f" epoch {epoch+1}/{h['epochs']} loss {running/max(n_b,1):.4f}")
|
||||
return model, time.time() - t0
|
||||
|
||||
|
||||
def predict_proba(model, X, bs=256):
|
||||
import torch
|
||||
model.eval()
|
||||
out = []
|
||||
with torch.no_grad():
|
||||
Xt = torch.from_numpy(X)
|
||||
for start in range(0, X.shape[0], bs):
|
||||
xb = Xt[start:start + bs]
|
||||
if xb.dim() == 1:
|
||||
xb = xb.unsqueeze(0)
|
||||
logits = model(xb)
|
||||
out.append(torch.sigmoid(logits).numpy())
|
||||
return np.concatenate(out)
|
||||
|
||||
|
||||
# ─── metrics helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def binary_metrics(yt, proba, thr=0.5):
|
||||
yp = (proba >= thr).astype(int)
|
||||
tp = int(((yt == 1) & (yp == 1)).sum())
|
||||
fp = int(((yt == 0) & (yp == 1)).sum())
|
||||
fn = int(((yt == 1) & (yp == 0)).sum())
|
||||
from sklearn.metrics import roc_auc_score, average_precision_score
|
||||
roc = roc_auc_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
pr = average_precision_score(yt, proba) if len(np.unique(yt)) > 1 else 0.0
|
||||
return {
|
||||
"n": int(len(yt)), "tp": tp, "fp": fp, "fn": fn,
|
||||
"P": tp / max(tp + fp, 1), "R": tp / max(tp + fn, 1),
|
||||
"FA": fp, "FA_rate": fp / max(len(yt), 1),
|
||||
"ROC_AUC": float(roc), "PR_AUC": float(pr),
|
||||
}
|
||||
|
||||
|
||||
def threshold_curve(yt, proba, thr_grid):
|
||||
rows = []
|
||||
for thr in thr_grid:
|
||||
yp = (proba >= thr).astype(int)
|
||||
tp = int(((yt == 1) & (yp == 1)).sum())
|
||||
fp = int(((yt == 0) & (yp == 1)).sum())
|
||||
fn = int(((yt == 1) & (yp == 0)).sum())
|
||||
P = tp / max(tp + fp, 1)
|
||||
R = tp / max(tp + fn, 1)
|
||||
rows.append({
|
||||
"thr": round(float(thr), 4), "P": round(P, 4), "R": round(R, 4),
|
||||
"FA": fp, "FA_rate": round(fp / max(len(yt), 1), 4),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def operating_points(rows, pair_proba, pairs):
|
||||
"""Report P>=0.95/0.98/0.99 points with pair separation at threshold."""
|
||||
res = {}
|
||||
for target in (0.95, 0.98, 0.99):
|
||||
pts = [r for r in rows if r["P"] >= target and r["R"] > 0.0]
|
||||
if not pts:
|
||||
res[str(target)] = None
|
||||
continue
|
||||
best = max(pts, key=lambda r: r["R"])
|
||||
# pair separation at that operating point
|
||||
sep = pair_sep_at(best["thr"], pair_proba, pairs)
|
||||
best = dict(best); best["pair_sep"] = round(sep, 4)
|
||||
res[str(target)] = best
|
||||
return res
|
||||
|
||||
|
||||
def pair_sep_at(thr, pair_proba, pairs):
|
||||
"""Fraction of pairs where action>=thr and cap<thr."""
|
||||
if not pairs:
|
||||
return 0.0
|
||||
ok = 0
|
||||
for cidx, aidx in pairs:
|
||||
if pair_proba[aidx] >= thr and pair_proba[cidx] < thr:
|
||||
ok += 1
|
||||
return ok / len(pairs)
|
||||
|
||||
|
||||
def fold_variance(fold_rows):
|
||||
return {
|
||||
"folds": [
|
||||
{
|
||||
"fold": fr["fold"],
|
||||
"ROC_AUC": fr["ROC_AUC"], "PR_AUC": fr["PR_AUC"],
|
||||
"P": fr["P"], "R": fr["R"], "FA": fr["FA"], "n": fr["n"],
|
||||
}
|
||||
for fr in fold_rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ─── subcommands ────────────────────────────────────────────────────────────
|
||||
|
||||
def _result_path():
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
return RESULTS_DIR
|
||||
|
||||
|
||||
def cmd_grouped(args):
|
||||
rows = load_dev()
|
||||
char_vocab, bpe = build_tokenizers(rows)
|
||||
y = np.array([r["y"] for r in rows])
|
||||
folds = np.array([r["cv_fold"] for r in rows])
|
||||
X_char = encode_all(rows, char_vocab, "char")
|
||||
X_bpe = encode_all(rows, bpe, "bpe")
|
||||
tokenizers = {"char": char_vocab, "bpe": bpe}
|
||||
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
# save tokenizer metadata for reproducibility
|
||||
meta = {
|
||||
"char_vocab": char_vocab.size,
|
||||
"char_vocab_sample": char_vocab.id_to_char[:50],
|
||||
"bpe_vocab": bpe.size,
|
||||
"bpe_serialized_bytes": bpe.serialized_bytes(),
|
||||
"max_char": MAX_CHAR, "max_bpe": MAX_BPE,
|
||||
"n": len(rows),
|
||||
}
|
||||
with open(os.path.join(RESULTS_DIR, "corpus_meta.json"), "w") as f:
|
||||
json.dump(meta, f)
|
||||
|
||||
kind_of = {"char_cnn": "char", "bigru": "bpe", "tiny_transformer": "bpe"}
|
||||
for arch, sizes in ARCH_CONFIGS.items():
|
||||
kind = kind_of[arch]
|
||||
X = X_char if kind == "char" else X_bpe
|
||||
for size in sizes:
|
||||
name = f"{arch}_{size}"
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "models"), exist_ok=True)
|
||||
probs = {v: np.zeros(len(rows)) for v in VARIANTS}
|
||||
fold_rows = []
|
||||
for fold in range(5):
|
||||
tr = folds != fold
|
||||
te = folds == fold
|
||||
Xtr = X["strip"][tr]
|
||||
model, secs = train_binary(Xtr, y[tr], arch, size,
|
||||
vocab_size=tokenizers[kind].size,
|
||||
seed_offset=fold, log=args.verbose)
|
||||
torch_models_dir = os.path.join(RESULTS_DIR, "models")
|
||||
import torch
|
||||
torch.save(model.state_dict(), os.path.join(torch_models_dir, f"{name}_fold{fold}.pt"))
|
||||
for v in VARIANTS:
|
||||
probs[v][te] = predict_proba(model, X[v][te])
|
||||
fold_m = binary_metrics(y[te], probs["strip"][te])
|
||||
fold_m["fold"] = fold
|
||||
fold_rows.append(fold_m)
|
||||
print(f" {name} fold {fold}: ROC={fold_m['ROC_AUC']:.3f} "
|
||||
f"PR={fold_m['PR_AUC']:.3f} P={fold_m['P']:.3f} R={fold_m['R']:.3f} "
|
||||
f"FA={fold_m['FA']} n={fold_m['n']} ({secs:.1f}s)")
|
||||
np.savez(os.path.join(RESULTS_DIR, f"{name}_probs.npz"),
|
||||
var_orig=probs["orig"], var_nofinal=probs["nofinal"],
|
||||
var_strip=probs["strip"])
|
||||
summary = binary_metrics(y, probs["strip"])
|
||||
print(f" {name} OOF: ROC={summary['ROC_AUC']:.3f} PR={summary['PR_AUC']:.3f} "
|
||||
f"P={summary['P']:.3f} R={summary['R']:.3f} FA={summary['FA']}")
|
||||
print("grouped done")
|
||||
|
||||
|
||||
def cmd_metrics(args):
|
||||
rows = load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
pairs = build_pairs(rows, [r["text_strip"] for r in rows])
|
||||
print(f"pairs={len(pairs)}")
|
||||
|
||||
out = {}
|
||||
for arch, sizes in ARCH_CONFIGS.items():
|
||||
for size in sizes:
|
||||
name = f"{arch}_{size}"
|
||||
fp = os.path.join(RESULTS_DIR, f"{name}_probs.npz")
|
||||
if not os.path.exists(fp):
|
||||
continue
|
||||
z = np.load(fp)
|
||||
entry = {"name": name, "arch": arch, "size": size}
|
||||
# OOF binary on strip variant (primary training input)
|
||||
entry["strip"] = binary_metrics(y, z["var_strip"])
|
||||
entry["pairs"] = {}
|
||||
entry["pairs"]["strip"] = pair_metrics(pairs, z["var_strip"])
|
||||
entry["pairs"]["orig"] = pair_metrics(pairs, z["var_orig"])
|
||||
entry["pairs"]["nofinal"] = pair_metrics(pairs, z["var_nofinal"])
|
||||
# stress: same OOF models, per-variant metrics
|
||||
entry["stress"] = {}
|
||||
for v in VARIANTS:
|
||||
p = z[f"var_{v}"]
|
||||
entry["stress"][v] = {
|
||||
"all_FA": binary_metrics(y, p)["FA_rate"],
|
||||
"capQ_FA": capq_fa(tags, y, p),
|
||||
"modal_recall": modal_recall(tags, y, p),
|
||||
}
|
||||
# threshold curve + operating points on strip
|
||||
entry["curve"] = threshold_curve(y, z["var_strip"],
|
||||
np.arange(0.30, 1.0, 0.02))
|
||||
entry["ops"] = operating_points(entry["curve"], z["var_strip"], pairs)
|
||||
out[name] = entry
|
||||
|
||||
with open(os.path.join(RESULTS_DIR, "metrics.json"), "w") as f:
|
||||
json.dump(out, f, indent=2, default=str)
|
||||
print(json.dumps(out, indent=2, default=str))
|
||||
|
||||
|
||||
def capq_fa(tags, y, proba):
|
||||
mask = np.array(["capability_question" in t for t in tags])
|
||||
if mask.sum() == 0:
|
||||
return 0.0
|
||||
sub = proba[mask]
|
||||
return float((sub >= 0.5).sum() / mask.sum())
|
||||
|
||||
|
||||
def modal_recall(tags, y, proba):
|
||||
mask = np.array([
|
||||
(y[i] == 1 and ("polite_request" in tags[i] or "modal_request" in tags[i]))
|
||||
for i in range(len(y))
|
||||
])
|
||||
if mask.sum() == 0:
|
||||
return 0.0
|
||||
sub = proba[mask]
|
||||
return float((sub >= 0.5).sum() / mask.sum())
|
||||
|
||||
|
||||
def cmd_lfo(args):
|
||||
rows = load_dev()
|
||||
char_vocab, bpe = build_tokenizers(rows)
|
||||
y = np.array([r["y"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
X_char = encode_all(rows, char_vocab, "char")["strip"]
|
||||
X_bpe = encode_all(rows, bpe, "bpe")["strip"]
|
||||
kind_of = {"char_cnn": "char", "bigru": "bpe", "tiny_transformer": "bpe"}
|
||||
|
||||
results = {}
|
||||
tokenizers = {"char": char_vocab, "bpe": bpe}
|
||||
# capability_question LOFO for every config/size (the critical case)
|
||||
for arch, sizes in ARCH_CONFIGS.items():
|
||||
X = X_char if kind_of[arch] == "char" else X_bpe
|
||||
src_idx = np.array(["capability_question" not in t for t in tags])
|
||||
tgt_idx = np.array(["capability_question" in t for t in tags])
|
||||
for size in sizes:
|
||||
model, _ = train_binary(X[src_idx], y[src_idx], arch, size,
|
||||
vocab_size=tokenizers[kind_of[arch]].size,
|
||||
seed_offset=17)
|
||||
p = predict_proba(model, X[tgt_idx])
|
||||
m = binary_metrics(y[tgt_idx], p)
|
||||
results[f"{arch}_{size}:capability_question"] = m
|
||||
print(f"LFO ability {arch}_{size}: cap rows={m['n']} "
|
||||
f"pos={int(y[tgt_idx].sum())} FA={m['FA']} FA_rate={m['FA_rate']:.3f} "
|
||||
f"P={m['P']:.3f} R={m['R']:.3f} acc={1-m['FA_rate']:.3f}")
|
||||
|
||||
# full family LOFO for the leading config per architecture
|
||||
leading = {"char_cnn": "char_cnn_medium", "bigru": "bigru_tiny",
|
||||
"tiny_transformer": "tiny_transformer_small"}
|
||||
for arch, name in leading.items():
|
||||
X = X_char if kind_of[arch] == "char" else X_bpe
|
||||
for fam in PRESENT_FAMILIES:
|
||||
src = np.array([fam not in t for t in tags])
|
||||
tgt = np.array([fam in t for t in tags])
|
||||
model, _ = train_binary(X[src], y[src], arch, name.split("_")[-1],
|
||||
vocab_size=tokenizers[kind_of[arch]].size,
|
||||
seed_offset=41)
|
||||
p = predict_proba(model, X[tgt])
|
||||
m = binary_metrics(y[tgt], p)
|
||||
results[f"{name}:{fam}"] = m
|
||||
print(f"LFO {fam}: {name} rows={m['n']} pos={int(y[tgt].sum())} "
|
||||
f"P={m['P']:.3f} R={m['R']:.3f} FA={m['FA']} acc={1-m['FA_rate']:.3f}")
|
||||
|
||||
with open(os.path.join(RESULTS_DIR, "lfo.json"), "w") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print("lfo done")
|
||||
|
||||
|
||||
def cmd_e5baseline(args):
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.neural_network import MLPClassifier
|
||||
rows = load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
folds = np.array([r["cv_fold"] for r in rows])
|
||||
X = np.vstack([r["emb"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
pairs = build_pairs(rows, [r["text_strip"] for r in rows])
|
||||
|
||||
out = {}
|
||||
for model_name, model, extra in [
|
||||
("e5_linear", LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42), {}),
|
||||
("e5_mlp_h32", MLPClassifier(hidden_layer_sizes=(32,), activation="relu",
|
||||
solver="adam", alpha=0.01, random_state=42,
|
||||
max_iter=800, early_stopping=True,
|
||||
validation_fraction=0.15, n_iter_no_change=10), {}),
|
||||
]:
|
||||
proba = np.zeros(len(rows))
|
||||
for fold in range(5):
|
||||
tr = folds != fold
|
||||
te = folds == fold
|
||||
m2 = type(model)(**{k: v for k, v in model.get_params().items()})
|
||||
m2.fit(X[tr], y[tr])
|
||||
proba[te] = m2.predict_proba(X[te])[:, 1]
|
||||
entry = {
|
||||
"grouped": binary_metrics(y, proba),
|
||||
"pairs": pair_metrics(pairs, proba),
|
||||
}
|
||||
# cap-Q leave-generator-out (train without the family)
|
||||
src = np.array(["capability_question" not in t for t in tags])
|
||||
tgt = np.array(["capability_question" in t for t in tags])
|
||||
m3 = LogisticRegression(C=1.0, max_iter=2000, solver="lbfgs", random_state=42) \
|
||||
if model_name == "e5_linear" else \
|
||||
MLPClassifier(hidden_layer_sizes=(32,), alpha=0.01, random_state=42, max_iter=800)
|
||||
m3.fit(X[src], y[src])
|
||||
p3 = m3.predict_proba(X[tgt])[:, 1]
|
||||
entry["capq_lofo"] = binary_metrics(y[tgt], p3)
|
||||
entry["stress"] = "NA (no re-embed on this box)"
|
||||
out[model_name] = entry
|
||||
print(f"{model_name}: grouped FA_rate={entry['grouped']['FA_rate']:.4f} "
|
||||
f"PR={entry['grouped']['PR_AUC']:.3f} capQ_LOFO_FA_rate={entry['capq_lofo']['FA_rate']:.4f} "
|
||||
f"pairs={entry['pairs']['ordering_acc']:.3f}")
|
||||
with open(os.path.join(RESULTS_DIR, "e5baseline.json"), "w") as f:
|
||||
json.dump(out, f, indent=2, default=str)
|
||||
print("e5baseline done")
|
||||
|
||||
|
||||
def cmd_runtime(args):
|
||||
import torch
|
||||
rows = load_dev()
|
||||
char_vocab, bpe = build_tokenizers(rows)
|
||||
X_char = encode_all(rows, char_vocab, "char")["strip"]
|
||||
X_bpe = encode_all(rows, bpe, "bpe")["strip"]
|
||||
kind_of = {"char_cnn": "char", "bigru": "bpe", "tiny_transformer": "bpe"}
|
||||
report = {}
|
||||
for arch, sizes in ARCH_CONFIGS.items():
|
||||
X = X_char if kind_of[arch] == "char" else X_bpe
|
||||
for size in sizes:
|
||||
name = f"{arch}_{size}"
|
||||
model = build_model(arch, size, int(X.max()) + 1, X.shape[1])
|
||||
n_params = sum(p.numel() for p in model.parameters())
|
||||
fp32 = n_params * 4
|
||||
int8 = n_params
|
||||
model.eval()
|
||||
# warmup + latency (batch-1, eval mode)
|
||||
xb = torch.from_numpy(X[:1])
|
||||
with torch.no_grad():
|
||||
for _ in range(20):
|
||||
model(xb)
|
||||
# tokenization latency
|
||||
if kind_of[arch] == "char":
|
||||
t0 = time.perf_counter()
|
||||
for r in rows[:1000]:
|
||||
char_vocab.encode(r["text_strip"], MAX_CHAR)
|
||||
tl = (time.perf_counter() - t0) / 1000
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
for r in rows[:1000]:
|
||||
bpe.encode(r["text_strip"])
|
||||
tl = (time.perf_counter() - t0) / 1000
|
||||
lat = []
|
||||
for _ in range(300):
|
||||
t0 = time.perf_counter()
|
||||
model(xb)
|
||||
lat.append(time.perf_counter() - t0)
|
||||
lat = np.array(lat) * 1e6
|
||||
report[name] = {
|
||||
"params": n_params, "fp32_bytes": fp32, "int8_bytes": int8,
|
||||
"latency_us_mean": float(lat.mean()), "latency_us_p50": float(np.median(lat)),
|
||||
"latency_us_p95": float(np.percentile(lat, 95)),
|
||||
"throughput_b1": round(1e6 / float(lat.mean()), 1),
|
||||
"tok_us": round(tl * 1e6, 1),
|
||||
"tokenizer": "char" if kind_of[arch] == "char" else "bpe",
|
||||
}
|
||||
print(f"{name}: {n_params} params fp32={fp32/1024:.0f}KiB "
|
||||
f"lat={lat.mean():.0f}us tok={tl*1e6:.1f}us")
|
||||
with open(os.path.join(RESULTS_DIR, "runtime.json"), "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print("runtime done")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("cmd", choices=["grouped", "metrics", "lfo", "e5baseline", "runtime"])
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
t0 = time.time()
|
||||
globals()[f"cmd_{args.cmd}"](args)
|
||||
print(f"elapsed {time.time()-t0:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 19 model zoo: three genuinely sequence-sensitive tiny models, trained
|
||||
from scratch on Maven's narrow binary pragmatics task.
|
||||
|
||||
A. CharCNN — codepoint ids → char embedding → parallel small 1D convs
|
||||
(several kernel widths) → global max-pool → linear head
|
||||
B. BiGRU — subword ids → token embedding → 1-layer BiGRU →
|
||||
maxpool[final] → linear head
|
||||
C. TinyTransformer — subword ids → token embedding + sine position →
|
||||
N self-attention encoder blocks (heads, FFN 4x, PreNorm) →
|
||||
CLS → linear head
|
||||
|
||||
All expose :forward(ids) returning the binary logit, plus .n_params().
|
||||
Deterministic: everything is plain torch ops.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
class CharCNN(nn.Module):
|
||||
def __init__(self, vocab_size, embed_dim, filters, widths, pad_idx=0, dropout=0.3):
|
||||
super().__init__()
|
||||
self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
|
||||
self.convs = nn.ModuleList([
|
||||
nn.Conv1d(embed_dim, filters, k, padding=(k - 1) // 2)
|
||||
for k in widths
|
||||
])
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.head = nn.Linear(filters * len(widths), 1)
|
||||
|
||||
def forward(self, ids):
|
||||
# ids: (B, T)
|
||||
x = self.embed(ids).transpose(1, 2) # (B, D, T)
|
||||
hiddens = [F.relu(conv(x)) for conv in self.convs] # each (B, F, T)
|
||||
pooled = torch.cat([h.max(dim=2).values for h in hiddens], dim=1) # (B, F*W)
|
||||
return self.head(self.dropout(pooled)).squeeze(-1)
|
||||
|
||||
def n_params(self):
|
||||
return sum(p.numel() for p in self.parameters())
|
||||
|
||||
|
||||
class BiGRU(nn.Module):
|
||||
def __init__(self, vocab_size, embed_dim, hidden, pad_idx=0, dropout=0.3):
|
||||
super().__init__()
|
||||
self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
|
||||
self.encoder = nn.GRU(embed_dim, hidden, num_layers=1, bidirectional=True,
|
||||
batch_first=True)
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.head = nn.Linear(hidden * 2, 1)
|
||||
|
||||
def forward(self, ids):
|
||||
mask = (ids != 0).float() # (B, T)
|
||||
x = self.embed(ids)
|
||||
lens = mask.sum(dim=1).clamp(min=1).long()
|
||||
x_p = nn.utils.rnn.pack_padded_sequence(x, lens.cpu(), batch_first=True,
|
||||
enforce_sorted=False)
|
||||
out, _ = self.encoder(x_p)
|
||||
out, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True,
|
||||
total_length=mask.size(1))
|
||||
out = out * mask.unsqueeze(-1)
|
||||
maxed = out.max(dim=1).values # (B, 2H)
|
||||
return self.head(self.dropout(maxed)).squeeze(-1)
|
||||
|
||||
def n_params(self):
|
||||
return sum(p.numel() for p in self.parameters())
|
||||
|
||||
|
||||
class TinyTransformer(nn.Module):
|
||||
def __init__(self, vocab_size, d_model, n_layers, n_heads, ff_mult=4,
|
||||
max_len=64, pad_idx=0, dropout=0.1):
|
||||
super().__init__()
|
||||
self.d_model = d_model
|
||||
self.embed = nn.Embedding(vocab_size, d_model, padding_idx=pad_idx)
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.pos = nn.Parameter(torch.empty(1, max_len, d_model))
|
||||
nn.init.normal_(self.pos, std=0.02)
|
||||
blocks = []
|
||||
for _ in range(n_layers):
|
||||
blocks.append(TransformerBlock(d_model, n_heads, ff_mult, dropout))
|
||||
self.blocks = nn.ModuleList(blocks)
|
||||
self.ln_out = nn.LayerNorm(d_model)
|
||||
self.head = nn.Linear(d_model, 1)
|
||||
|
||||
def forward(self, ids):
|
||||
B, T = ids.shape
|
||||
mask = (ids != 0)
|
||||
x = self.embed(ids) * math.sqrt(self.d_model) + self.pos[:, :T, :]
|
||||
x = self.dropout(x)
|
||||
for blk in self.blocks:
|
||||
x = blk(x, mask)
|
||||
x = self.ln_out(x)
|
||||
pooled = x.masked_fill(~mask.unsqueeze(-1), float("-inf")).max(dim=1).values
|
||||
return self.head(pooled).squeeze(-1)
|
||||
|
||||
def n_params(self):
|
||||
return sum(p.numel() for p in self.parameters())
|
||||
|
||||
|
||||
class TransformerBlock(nn.Module):
|
||||
def __init__(self, d_model, n_heads, ff_mult, dropout):
|
||||
super().__init__()
|
||||
self.attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout,
|
||||
batch_first=True)
|
||||
self.ln1 = nn.LayerNorm(d_model)
|
||||
self.ff = nn.Sequential(
|
||||
nn.Linear(d_model, d_model * ff_mult),
|
||||
nn.GELU(),
|
||||
nn.Linear(d_model * ff_mult, d_model),
|
||||
)
|
||||
self.ln2 = nn.LayerNorm(d_model)
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
|
||||
def forward(self, x, mask):
|
||||
# mask: (B, T) bool of non-pad; MultiheadAttention uses key_padding_mask
|
||||
h = self.ln1(x)
|
||||
h = self.attn(h, h, h, key_padding_mask=~mask,
|
||||
need_weights=False, is_causal=False)[0]
|
||||
x = x + self.dropout(h)
|
||||
h = self.ln2(x)
|
||||
x = x + self.dropout(self.ff(h))
|
||||
return x
|
||||
|
||||
|
||||
# ─── Sizes ladder ───────────────────────────────────────────────────────────
|
||||
|
||||
def make_model(arch, size, char_vocab, bpe_vocab):
|
||||
if arch == "char_cnn":
|
||||
configs = {
|
||||
"tiny": dict(embed_dim=32, filters=64, widths=[3, 4, 5]),
|
||||
"medium": dict(embed_dim=64, filters=160, widths=[2, 3, 4, 5]),
|
||||
}
|
||||
c = configs[size]
|
||||
return CharCNN(char_vocab, c["embed_dim"], c["filters"], c["widths"])
|
||||
if arch == "bigru":
|
||||
configs = {
|
||||
"tiny": dict(embed_dim=64, hidden=64),
|
||||
"medium": dict(embed_dim=128, hidden=128),
|
||||
"large": dict(embed_dim=256, hidden=256),
|
||||
}
|
||||
c = configs[size]
|
||||
return BiGRU(bpe_vocab, c["embed_dim"], c["hidden"])
|
||||
if arch == "tiny_transformer":
|
||||
configs = {
|
||||
"small": dict(d_model=128, n_layers=2, n_heads=4),
|
||||
"medium": dict(d_model=192, n_layers=4, n_heads=4),
|
||||
}
|
||||
c = configs[size]
|
||||
return TinyTransformer(bpe_vocab, c["d_model"], c["n_layers"], c["n_heads"])
|
||||
raise ValueError(arch)
|
||||
|
||||
|
||||
def n_params_of(arch, size, char_vocab, bpe_vocab):
|
||||
return make_model(arch, size, char_vocab, bpe_vocab).n_params()
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 20 tokenizer audit (§2 of the brief) and corpus sequence-length
|
||||
statistics (§3). Runs before any training. If the audit shows catastrophic
|
||||
Cyrillic / mixed-identifier loss it is the gate to stop.
|
||||
|
||||
Loads the frozen dev corpus exactly like slice 19 (same normalization),
|
||||
so regime A (natural text) is `text_orig` and regime B (punct-stripped) is
|
||||
`text_strip`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
import slice19_main as s19
|
||||
|
||||
RESULTS_DIR = "/tmp/mvn-s20"
|
||||
MODEL_DIR = ("/tmp/mvn-s20/hf-cache/models--cointegrated--rubert-tiny/"
|
||||
"snapshots/5441c5ea8026d4f6d7505ec004845409f1259fb1")
|
||||
|
||||
MIXED_CYRILLIC_LATIN = re.compile(r"[а-яёА-ЯЁ]+[a-zA-Z]+|[a-zA-Z]+[а-яёА-ЯЁ]+")
|
||||
HAS_CYRILLIC = re.compile(r"[а-яёА-ЯЁ]")
|
||||
HAS_LATIN = re.compile(r"[a-zA-Z]")
|
||||
NUMERIC = re.compile(r"[0-9]")
|
||||
TOKEN_RE = re.compile(r"[^\W\d_]+", re.UNICODE)
|
||||
|
||||
SAMPLES = [
|
||||
"выключи свет в спальне пожалуйста",
|
||||
"turn off the lights",
|
||||
"перезапусти сервис mavend",
|
||||
"что такое Nexus",
|
||||
"включи телевизор, пожалуйста",
|
||||
"как дела у Мэйвен",
|
||||
"поставь таймер на 5 минут",
|
||||
"кто такой Home Assistant",
|
||||
"открой настройки устройства ha_cam_12",
|
||||
"Покажи статус сервера Proxmox",
|
||||
"аутентифицируй на сайте 2fa.ru",
|
||||
"сообщи погоду завтра в 18:30",
|
||||
]
|
||||
|
||||
|
||||
def is_toolish(word):
|
||||
# Maven sibling service names / HA-like identifiers: mixed case, digits,
|
||||
# underscores, or short Latin words that are not in the vocab as whole
|
||||
# words. Rough heuristic for the fragmentation probe.
|
||||
return bool(re.search(r"[A-Z0-9_/.-]", word))
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
tok = AutoTokenizer.from_pretrained(MODEL_DIR)
|
||||
rows = s19.load_dev()
|
||||
texts = {
|
||||
"orig": [r["text_orig"] for r in rows],
|
||||
"nofinal": [r["text_nofinal"] for r in rows],
|
||||
"strip": [r["text_strip"] for r in rows],
|
||||
}
|
||||
vv = tok.vocab_size
|
||||
unk = tok.unk_token_id
|
||||
|
||||
rep = {"model": "cointegrated/rubert-tiny",
|
||||
"sha": "5441c5ea8026d4f6d7505ec004845409f1259fb1",
|
||||
"tokenizer": type(tok).__name__,
|
||||
"vocab_size": vv}
|
||||
|
||||
# per-character stats (natural texts)
|
||||
chars = [len(t) for t in texts["orig"]]
|
||||
rep["char_len"] = {
|
||||
"mean": round(statistics.mean(chars), 2),
|
||||
"p50": int(sorted(chars)[len(chars) // 2]),
|
||||
"p95": sorted(chars)[int(len(chars) * .95)],
|
||||
"p99": sorted(chars)[int(len(chars) * .99)],
|
||||
"max": max(chars),
|
||||
}
|
||||
|
||||
for view in ("orig", "strip"):
|
||||
ids = tok(texts[view], add_special_tokens=True, padding=False,
|
||||
truncation=False)["input_ids"]
|
||||
lens = [len(x) for x in ids]
|
||||
n_tok = sum(lens)
|
||||
n_unk = sum(x.count(unk) for x in ids)
|
||||
n_chars = sum(len(t) for t in texts[view])
|
||||
rep[view] = {
|
||||
"tokens_per_utt_mean": round(n_tok / len(rows), 2),
|
||||
"tokens_per_char": round(n_tok / max(n_chars, 1), 4),
|
||||
"unk_count": n_unk,
|
||||
"unk_rate": round(n_unk / max(n_tok, 1), 5),
|
||||
"seq_len_p50": int(sorted(lens)[len(lens) // 2]),
|
||||
"seq_len_p90": sorted(lens)[int(len(lens) * .90)],
|
||||
"seq_len_p95": sorted(lens)[int(len(lens) * .95)],
|
||||
"seq_len_p99": sorted(lens)[int(len(lens) * .99)],
|
||||
"seq_len_max": max(lens),
|
||||
"above_96": sum(1 for x in lens if x > 96),
|
||||
"above_128": sum(1 for x in lens if x > 128),
|
||||
}
|
||||
rep[view]["p99_plus_margin"] = rep[view]["seq_len_p99"] + 6
|
||||
|
||||
# mixed Cyrillic/Latin behaviour over natural texts
|
||||
mixed_words = []
|
||||
for t in texts["orig"]:
|
||||
for w in t.split():
|
||||
if MIXED_CYRILLIC_LATIN.search(w):
|
||||
mixed_words.append(w)
|
||||
rep["mixed_cyr_lat_rows"] = len({w for w in mixed_words})
|
||||
rep["mixed_cyr_lat_stats"] = {"word_count": len(mixed_words),
|
||||
"unique_words": len(set(mixed_words))}
|
||||
|
||||
# entity/tool-name fragmentation: unique word-like tokens containing a digit
|
||||
# or underscore, or non-trivial Latin, and how many BPE/WordPiece pieces they
|
||||
# split into. Sample the extremes.
|
||||
fragments = []
|
||||
vocab = set(tok.get_vocab().keys())
|
||||
for t in texts["orig"]:
|
||||
# split into "clean" tokens (word chars + _ / digit boundaries)
|
||||
for w in re.findall(r"[A-Za-z0-9_]+\b", t):
|
||||
w2 = re.sub(r"_\b", "", w)
|
||||
if len(w2) < 3 or not is_toolish(w2):
|
||||
continue
|
||||
n_pieces = len(tok.tokenize(w2).replace("##", "_").rstrip())
|
||||
fragments.append((n_pieces, w2))
|
||||
frag = sorted(set(fragments))[-30:]
|
||||
rep["entity_fragment_examples"] = [
|
||||
{"token": w, "pieces": n} for n, w in frag
|
||||
]
|
||||
|
||||
# representative samples: full tokenization
|
||||
rep["samples"] = []
|
||||
for s in SAMPLES:
|
||||
e = tok(s, add_special_tokens=True, padding=False, truncation=False)
|
||||
rep["samples"].append({
|
||||
"text": s,
|
||||
"tokens": tok.convert_ids_to_tokens(e["input_ids"]),
|
||||
"pieces": len(e["input_ids"]),
|
||||
"unk": e["input_ids"].count(unk),
|
||||
})
|
||||
|
||||
with open(os.path.join(RESULTS_DIR, "tokenizer_audit.json"), "w") as f:
|
||||
json.dump(rep, f, indent=2, ensure_ascii=False)
|
||||
print(json.dumps({k: v for k, v in rep.items() if k not in ("samples",)}, indent=2, ensure_ascii=False))
|
||||
print("\n--- samples ---")
|
||||
for s in rep["samples"]:
|
||||
print(f'{s["pieces"]:>3} unk={s["unk"]} {s["text"]:50} ->'
|
||||
f' {" ".join(s["tokens"])}')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 20: fine-tune cointegrated/rubert-tiny (11.9M, 3-layer BERT) end-to-end
|
||||
for the binary executable-intent boundary (action vs not_action) on the frozen
|
||||
v2 dev corpus, following the slice 20 brief.
|
||||
|
||||
Rules honoured:
|
||||
- full end-to-end fine-tuning, CLS-pooled native classification head
|
||||
- tokenizer used unchanged (audit in slice20_audit.py)
|
||||
- max length from corpus stats (p99+margin, cap 128): 25 here
|
||||
- narrow search: LR in {1e-5, 2e-5, 5e-5}, <= 6 epochs, early stop on a
|
||||
development (within-fold) split, best checkpoint restored
|
||||
- >= 3 seeds (42/17/7) for every config
|
||||
- grouped 5-fold CV reuse; cap-Q leave-generator-out as primary stress case
|
||||
- two input regimes: A = natural text (orig), B = punctuation-stripped (strip)
|
||||
|
||||
Artifacts under /tmp/mvn-s20/:
|
||||
pre/{regime}_ids.npy, _attn.npy tokenized corpus (all three views)
|
||||
oof/{regime}_{lr}_{seed}_probs.npz OOF probs per view (var_orig/nofinal/strip)
|
||||
oof/{regime}_{lr}_{seed}_metrics.json
|
||||
lfo/{regime}_{lr}_{seed}.json capability-Q LOFO (held-out family)
|
||||
results/summary.json
|
||||
models/{regime}_{lr}_{seed}_fold{i}.pt, lfo_{seed}.pt
|
||||
|
||||
CLI: slice20_pretrained.py {pre, grouped, lfo, metrics, runtime, onnx}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import AutoConfig, AutoTokenizer
|
||||
from transformers import BertForSequenceClassification
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import slice19_main as s19
|
||||
|
||||
RESULTS_DIR = "/tmp/mvn-s20"
|
||||
MODEL_DIR = ("/tmp/mvn-s20/hf-cache/models--cointegrated--rubert-tiny/"
|
||||
"snapshots/5441c5ea8026d4f6d7505ec004845409f1259fb1")
|
||||
|
||||
MODEL_NAME = "cointegrated/rubert-tiny"
|
||||
MODEL_SHA = "5441c5ea8026d4f6d7505ec004845409f1259fb1"
|
||||
|
||||
REGIMES = ["A", "B"]
|
||||
LRS = [1e-5, 2e-5, 5e-5]
|
||||
SEEDS = [42, 17, 7]
|
||||
VIEWS = ["orig", "nofinal", "strip"]
|
||||
MAX_LEN = 25
|
||||
BATCH = 32
|
||||
MAX_EPOCHS = 4
|
||||
EARLY_STOP = 1 # patience in epochs on val PR-AUC
|
||||
VAL_FRACTION = 0.12
|
||||
WEIGHT_DECAY = 0.01
|
||||
|
||||
torch.set_num_threads(4)
|
||||
|
||||
|
||||
# ─── tokenizer / input preparation ──────────────────────────────────────────
|
||||
|
||||
def _load_tokenizer():
|
||||
return AutoTokenizer.from_pretrained(MODEL_DIR)
|
||||
|
||||
|
||||
def tokenize(texts, tok):
|
||||
e = tok(list(texts), add_special_tokens=True, padding="max_length",
|
||||
truncation=True, max_length=MAX_LEN)
|
||||
return np.array(e["input_ids"], np.int64), np.array(e["attention_mask"], np.int64)
|
||||
|
||||
|
||||
def cmd_pre(args):
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "pre"), exist_ok=True)
|
||||
tok = _load_tokenizer()
|
||||
rows = s19.load_dev()
|
||||
# distributed over all variants, all rows, both regimes
|
||||
for regime in REGIMES:
|
||||
train_view = "orig" if regime == "A" else "strip"
|
||||
tsrc = [r[f"text_{train_view}"] for r in rows]
|
||||
ids, attn = tokenize(tsrc, tok)
|
||||
np.save(os.path.join(RESULTS_DIR, "pre", f"{regime}_ids.npy"), ids)
|
||||
np.save(os.path.join(RESULTS_DIR, "pre", f"{regime}_attn.npy"), attn)
|
||||
# eval stress views tokenized under the same regime's vocab/format
|
||||
for v in VIEWS:
|
||||
ids_v, attn_v = tokenize([r[f"text_{v}"] for r in rows], tok)
|
||||
np.save(os.path.join(RESULTS_DIR, "pre", f"ev_{regime}_{v}_ids.npy"), ids_v)
|
||||
np.save(os.path.join(RESULTS_DIR, "pre", f"ev_{regime}_{v}_attn.npy"), attn_v)
|
||||
print(f"regime {regime} done, train view={train_view}")
|
||||
|
||||
|
||||
def _make_model():
|
||||
cfg = AutoConfig.from_pretrained(MODEL_DIR)
|
||||
cfg.num_labels = 1 # sine logit, BCEWithLogits — matches slice 19 head
|
||||
model = BertForSequenceClassification.from_pretrained(MODEL_DIR, config=cfg)
|
||||
return model
|
||||
|
||||
|
||||
# ─── training ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _val_split(rows_idx, y, seed):
|
||||
rng = np.random.RandomState(seed)
|
||||
idx = rng.permutation(rows_idx)
|
||||
# stratified: keep the y-ratio in both parts
|
||||
pos = idx[y[idx] == 1]
|
||||
neg = idx[y[idx] == 0]
|
||||
nv_pos = max(1, int(round(len(pos) * VAL_FRACTION)))
|
||||
nv_neg = max(1, int(round(len(neg) * VAL_FRACTION)))
|
||||
v = np.concatenate([pos[:nv_pos], neg[:nv_neg]])
|
||||
t = np.concatenate([pos[nv_pos:], neg[nv_neg:]])
|
||||
return t, v
|
||||
|
||||
|
||||
def train_model(id_arr, attn, y, tr_idx, val_idx, lr, seed, builder=None):
|
||||
"""Fine-tune the full encoder; early-stop on val PR-AUC; return best state."""
|
||||
net = (builder or _make_model)()
|
||||
opt = torch.optim.AdamW([p for p in net.parameters()],
|
||||
lr=lr, weight_decay=WEIGHT_DECAY)
|
||||
lossf = nn.BCEWithLogitsLoss()
|
||||
from sklearn.metrics import average_precision_score
|
||||
tr = torch.from_numpy(np.ascontiguousarray(id_arr[tr_idx]))
|
||||
ta = torch.from_numpy(np.ascontiguousarray(attn[tr_idx]))
|
||||
ty = torch.from_numpy(y[tr_idx].astype(np.float32))
|
||||
va = torch.from_numpy(np.ascontiguousarray(id_arr[val_idx]))
|
||||
vaa = torch.from_numpy(np.ascontiguousarray(attn[val_idx]))
|
||||
vy = y[val_idx]
|
||||
|
||||
best_pr = -1.0
|
||||
best_state = None
|
||||
best_epoch = 0
|
||||
patience = 0
|
||||
n = len(tr_idx)
|
||||
rng = np.random.RandomState(seed * 97 % 2**31)
|
||||
|
||||
for epoch in range(MAX_EPOCHS):
|
||||
net.train()
|
||||
perm = rng.permutation(n)
|
||||
running = 0.0
|
||||
nb = 0
|
||||
for st in range(0, n, BATCH):
|
||||
bidx = torch.from_numpy(perm[st:st + BATCH])
|
||||
logits = net(input_ids=tr[bidx], attention_mask=ta[bidx]).logits.squeeze(-1)
|
||||
loss = lossf(logits, ty[bidx])
|
||||
opt.zero_grad()
|
||||
loss.backward()
|
||||
opt.step()
|
||||
running += float(loss)
|
||||
nb += 1
|
||||
net.eval()
|
||||
with torch.no_grad():
|
||||
pval = torch.sigmoid(net(input_ids=va, attention_mask=vaa).logits.squeeze(-1)).numpy()
|
||||
if len(np.unique(vy)) > 1:
|
||||
pr = average_precision_score(vy, pval)
|
||||
else:
|
||||
pr = 0.0
|
||||
if pr > best_pr:
|
||||
best_pr = pr
|
||||
best_state = {k: v.detach().clone() for k, v in net.state_dict().items()}
|
||||
best_epoch = epoch + 1
|
||||
patience = 0
|
||||
else:
|
||||
patience += 1
|
||||
if patience >= EARLY_STOP:
|
||||
break
|
||||
net.load_state_dict(best_state)
|
||||
return net, best_epoch, best_pr, running / max(nb, 1)
|
||||
|
||||
|
||||
def predict_proba(net, id_arr, attn, idx=None):
|
||||
net.eval()
|
||||
idx = np.arange(len(id_arr)) if idx is None else idx
|
||||
out = []
|
||||
with torch.no_grad():
|
||||
for st in range(0, len(idx), BATCH * 4):
|
||||
bi = idx[st:st + BATCH * 4]
|
||||
iid = torch.from_numpy(np.ascontiguousarray(id_arr[bi]))
|
||||
att = torch.from_numpy(np.ascontiguousarray(attn[bi]))
|
||||
out.append(torch.sigmoid(net(input_ids=iid, attention_mask=att).logits.squeeze(-1)).numpy())
|
||||
return np.concatenate(out)
|
||||
|
||||
|
||||
# ─── grouped CV ─────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_grouped(args):
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "oof"), exist_ok=True)
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "models"), exist_ok=True)
|
||||
rows = s19.load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
folds = np.array([r["cv_fold"] for r in rows])
|
||||
for regime in REGIMES:
|
||||
ids = np.load(os.path.join(RESULTS_DIR, "pre", f"{regime}_ids.npy"))
|
||||
attn = np.load(os.path.join(RESULTS_DIR, "pre", f"{regime}_attn.npy"))
|
||||
ev = {v: (np.load(os.path.join(RESULTS_DIR, "pre", f"ev_{regime}_{v}_ids.npy")),
|
||||
np.load(os.path.join(RESULTS_DIR, "pre", f"ev_{regime}_{v}_attn.npy")))
|
||||
for v in VIEWS}
|
||||
for lr in LRS:
|
||||
for seed in SEEDS:
|
||||
probs = {v: np.zeros(len(rows)) for v in VIEWS}
|
||||
fold_records = []
|
||||
for fold in range(5):
|
||||
tr = np.where(folds != fold)[0]
|
||||
te = np.where(folds == fold)[0]
|
||||
t_idx, v_idx = _val_split(tr, y, seed + 100 * fold)
|
||||
net, ep, best_pr, _ = train_model(ids, attn, y, t_idx, v_idx, lr, seed + fold)
|
||||
torch.save(net.state_dict(),
|
||||
os.path.join(RESULTS_DIR, "models",
|
||||
f"{regime}_{lr}_{seed}_fold{fold}.pt"))
|
||||
for v in VIEWS:
|
||||
probs[v][te] = predict_proba(net, *ev[v], te)
|
||||
fold_records.append({"fold": fold, "epochs": ep, "val_pr": best_pr})
|
||||
np.savez(os.path.join(RESULTS_DIR, "oof", f"{regime}_{lr}_{seed}_probs.npz"),
|
||||
var_orig=probs["orig"], var_nofinal=probs["nofinal"],
|
||||
var_strip=probs["strip"])
|
||||
with open(os.path.join(RESULTS_DIR, "oof", f"{regime}_{lr}_{seed}_metrics.json"), "w") as f:
|
||||
json.dump({"folds": fold_records}, f, indent=2)
|
||||
m = s19.binary_metrics(y, probs["strip"])
|
||||
print(f"[{regime}] lr={lr:.0e} seed={seed} "
|
||||
f"PR={m['PR_AUC']:.3f} P={m['P']:.3f} R={m['R']:.3f} "
|
||||
f"FA={m['FA']} epochs={[fr['epochs'] for fr in fold_records]}",
|
||||
flush=True)
|
||||
print("grouped done")
|
||||
|
||||
|
||||
# ─── cap-Q leave-generator-out ──────────────────────────────────────────────
|
||||
|
||||
def cmd_lfo(args):
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "lfo"), exist_ok=True)
|
||||
rows = s19.load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
src = np.array(["capability_question" not in t for t in tags])
|
||||
tgt = ~src
|
||||
for regime in REGIMES:
|
||||
ids = np.load(os.path.join(RESULTS_DIR, "pre", f"{regime}_ids.npy"))
|
||||
attn = np.load(os.path.join(RESULTS_DIR, "pre", f"{regime}_attn.npy"))
|
||||
for lr in LRS:
|
||||
for seed in SEEDS:
|
||||
s_idx = np.where(src)[0]
|
||||
t_idx, v_idx = _val_split(s_idx, y, seed + 7)
|
||||
net, ep, best_pr, _ = train_model(ids, attn, y, t_idx, v_idx, lr, seed)
|
||||
p = predict_proba(net, ids, attn, np.where(tgt)[0])
|
||||
yt = y[tgt]
|
||||
out = {
|
||||
"regime": regime, "lr": lr, "seed": seed,
|
||||
"rows": int(tgt.sum()), "epochs": ep, "val_pr": best_pr,
|
||||
"mean_action_proba": float(np.mean(p)),
|
||||
"max_action_proba": float(np.max(p)),
|
||||
"acc": float(((p >= 0.5) == (yt == 1)).mean()),
|
||||
"FA": int(((p >= 0.5) & (yt == 0)).sum()),
|
||||
"FA_rate": float(((p >= 0.5) & (yt == 0)).mean()),
|
||||
}
|
||||
with open(os.path.join(RESULTS_DIR, "lfo", f"{regime}_{lr}_{seed}.json"), "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
print(f"[{regime}] lr={lr:.0e} seed={seed} capQ LOFO "
|
||||
f"acc={out['acc']:.3f} FA_rate={out['FA_rate']:.3f} "
|
||||
f"mean_p={out['mean_action_proba']:.3f} epochs={ep}", flush=True)
|
||||
print("lfo done")
|
||||
|
||||
|
||||
# ─── metrics aggregation ────────────────────────────────────────────────────
|
||||
|
||||
def cmd_metrics(args):
|
||||
rows = s19.load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
pairs = s19.build_pairs(rows, [r["text_strip"] for r in rows])
|
||||
summary = {}
|
||||
for regime in REGIMES:
|
||||
summary[regime] = {}
|
||||
for lr in LRS:
|
||||
per_seed = []
|
||||
for seed in SEEDS:
|
||||
z = np.load(os.path.join(RESULTS_DIR, "oof", f"{regime}_{lr}_{seed}_probs.npz"))
|
||||
entry = {"seed": seed,
|
||||
"views": {v: {"FA_rate": s19.binary_metrics(y, z[f"var_{v}"])["FA_rate"],
|
||||
"PR": s19.binary_metrics(y, z[f"var_{v}"])["PR_AUC"],
|
||||
"capQ_FA": s19.capq_fa(tags, y, z[f"var_{v}"])}
|
||||
for v in VIEWS},
|
||||
"strip": s19.binary_metrics(y, z["var_strip"]),
|
||||
"pairs": {v: s19.pair_metrics(pairs, z[f"var_{v}"]) for v in VIEWS},
|
||||
"curve": s19.threshold_curve(y, z["var_strip"], np.arange(0.30, 1.0, 0.02)),
|
||||
"ops": s19.operating_points(s19.threshold_curve(
|
||||
y, z["var_strip"], np.arange(0.30, 1.0, 0.02)),
|
||||
z["var_strip"], pairs),
|
||||
}
|
||||
with open(os.path.join(RESULTS_DIR, "oof", f"{regime}_{lr}_{seed}_metrics.json")) as f:
|
||||
entry["folds"] = json.load(f)["folds"]
|
||||
per_seed.append(entry)
|
||||
# LOFO
|
||||
lfos = []
|
||||
for seed in SEEDS:
|
||||
with open(os.path.join(RESULTS_DIR, "lfo", f"{regime}_{lr}_{seed}.json")) as f:
|
||||
lfos.append(json.load(f))
|
||||
summary[regime][str(lr)] = {"seeds": per_seed, "lfo": lfos}
|
||||
# primary selection: min mean cap-Q LOFO FA_rate across seeds
|
||||
sel = {}
|
||||
for regime in REGIMES:
|
||||
best_lr, best_mean = None, 9e9
|
||||
for lr in LRS:
|
||||
mean_fa = np.mean([l["FA_rate"] for l in summary[regime][str(lr)]["lfo"]])
|
||||
sel[f"{regime}_{lr}"] = round(float(mean_fa), 4)
|
||||
if mean_fa < best_mean:
|
||||
best_lr, best_mean = lr, mean_fa
|
||||
sel[f"{regime}_selected"] = str(best_lr)
|
||||
summary["selection"] = sel
|
||||
os.makedirs(os.path.join(RESULTS_DIR, "results"), exist_ok=True)
|
||||
with open(os.path.join(RESULTS_DIR, "results", "summary.json"), "w") as f:
|
||||
json.dump(summary, f, indent=2, default=str)
|
||||
print(json.dumps(sel))
|
||||
print("metrics done")
|
||||
|
||||
|
||||
def _tokenizer_bytes():
|
||||
return sum(os.path.getsize(os.path.join(MODEL_DIR, f))
|
||||
for f in ["vocab.txt", "tokenizer.json"]
|
||||
if os.path.exists(os.path.join(MODEL_DIR, f)))
|
||||
|
||||
|
||||
# ─── runtime + ONNX ─────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_runtime(args):
|
||||
import time
|
||||
net = _make_model()
|
||||
tok = _load_tokenizer()
|
||||
rows = s19.load_dev()
|
||||
x = [r["text_orig"] for r in rows][:200]
|
||||
e = tok(x, padding="max_length", truncation=True,
|
||||
max_length=MAX_LEN, return_tensors="pt")
|
||||
ids, attn = e["input_ids"], e["attention_mask"]
|
||||
net.eval()
|
||||
with torch.no_grad():
|
||||
# warmup
|
||||
for _ in range(3):
|
||||
net(input_ids=ids[:1], attention_mask=attn[:1])
|
||||
# batch-1 latency
|
||||
lat = []
|
||||
for i in range(200):
|
||||
t0 = time.perf_counter()
|
||||
net(input_ids=ids[i:i + 1], attention_mask=attn[i:i + 1])
|
||||
lat.append((time.perf_counter() - t0) * 1e6)
|
||||
# tokenization latency
|
||||
t0 = time.perf_counter()
|
||||
for i in range(200):
|
||||
tok(x[i])
|
||||
tok_us = (time.perf_counter() - t0) / 200 * 1e6
|
||||
n_params = sum(p.numel() for p in net.parameters())
|
||||
fp32 = n_params * 4
|
||||
rep = {
|
||||
"model": MODEL_NAME, "sha": MODEL_SHA,
|
||||
"params": n_params, "fp32_bytes": fp32,
|
||||
"fp16_bytes": fp32 // 2, "int8_bytes": n_params,
|
||||
"tokenizer_bytes": _tokenizer_bytes(),
|
||||
"latency_us_mean": float(np.mean(lat)),
|
||||
"latency_us_p50": float(np.median(lat)),
|
||||
"latency_us_p95": float(np.percentile(lat, 95)),
|
||||
"max_len": MAX_LEN,
|
||||
"tok_us": round(tok_us, 2),
|
||||
"num_threads": 12,
|
||||
}
|
||||
with open(os.path.join(RESULTS_DIR, "runtime.json"), "w") as f:
|
||||
json.dump(rep, f, indent=2)
|
||||
print(json.dumps(rep, indent=2))
|
||||
print("runtime done")
|
||||
|
||||
|
||||
def cmd_onnx(args):
|
||||
net = _make_model()
|
||||
net.eval()
|
||||
tok = _load_tokenizer()
|
||||
rows = s19.load_dev()
|
||||
try:
|
||||
import torch.onnx
|
||||
dummy = {
|
||||
"input_ids": torch.zeros(1, MAX_LEN, dtype=torch.long),
|
||||
"attention_mask": torch.ones(1, MAX_LEN, dtype=torch.long),
|
||||
}
|
||||
with torch.no_grad():
|
||||
torch.onnx.export(net, (dummy,), os.path.join(RESULTS_DIR, "rubert-tiny-gate.onnx"),
|
||||
input_names=["input_ids", "attention_mask"],
|
||||
output_names=["logits"], opset_version=14,
|
||||
dynamic_axes={"input_ids": {0: "batch"},
|
||||
"attention_mask": {0: "batch"}})
|
||||
# parity on a fixed sample
|
||||
import numpy as np
|
||||
samp = [(r["text_orig"], r["y"]) for r in rows[:200]]
|
||||
e = tok([s[0] for s in samp], padding="max_length", truncation=True,
|
||||
max_length=MAX_LEN, return_tensors="pt")
|
||||
with torch.no_grad():
|
||||
pt = torch.sigmoid(net(**e).logits.squeeze(-1)).numpy()
|
||||
import onnxruntime as ort
|
||||
so = ort.SessionOptions()
|
||||
so.intra_op_num_threads = 12
|
||||
sess = ort.InferenceSession(os.path.join(RESULTS_DIR, "rubert-tiny-gate.onnx"),
|
||||
sess_options=so, providers=["CPUExecutionProvider"])
|
||||
on = sess.run(None, {"input_ids": e["input_ids"].numpy(),
|
||||
"attention_mask": e["attention_mask"].numpy()})[0]
|
||||
on = 1 / (1 + np.exp(-on).squeeze(-1))
|
||||
mx = float(np.max(np.abs(pt - on)))
|
||||
size = os.path.getsize(os.path.join(RESULTS_DIR, "rubert-tiny-gate.onnx"))
|
||||
rep = {"max_logit_diff": mx, "onnx_bytes": size,
|
||||
"parity_n": len(samp), "provider": "CPUExecutionProvider"}
|
||||
with open(os.path.join(RESULTS_DIR, "onnx.json"), "w") as f:
|
||||
json.dump(rep, f, indent=2)
|
||||
print(json.dumps(rep))
|
||||
except Exception as ex:
|
||||
print("onnx export/parity failed:", ex)
|
||||
with open(os.path.join(RESULTS_DIR, "onnx.json"), "w") as f:
|
||||
json.dump({"error": str(ex)}, f, indent=2)
|
||||
print("onnx done")
|
||||
|
||||
|
||||
# ─── optional capacity/pretraining ceiling (brief §13) ──────────────────────
|
||||
# Trigger: tiny1 clearly improved over from-scratch on in-pool but missed the
|
||||
# LOFO boundary. tiny2 is the same 3-layer 312-hidden BERT family; it tests
|
||||
# whether a *newer, larger-vocab* pretraining of the same family generalises
|
||||
# where tiny1 failed — disambiguating "this family is the wrong prior" from a
|
||||
# one-off pretraining. It cannot test capacity (same depth/size).
|
||||
|
||||
MODEL2_DIR = ("/tmp/mvn-s20/hf-tiny2-cache/models--cointegrated--rubert-tiny2/"
|
||||
"snapshots/e8ed3b0c8bbf4fb6984c3de043bf7d2f4e5969ae")
|
||||
MODEL2_SHA = "e8ed3b0c8bbf4fb6984c3de043bf7d2f4e5969ae"
|
||||
CEIL_RESULTS = os.path.join(RESULTS_DIR, "tiny2")
|
||||
|
||||
|
||||
def cmd_ceiling(args):
|
||||
import torch
|
||||
os.makedirs(CEIL_RESULTS, exist_ok=True)
|
||||
os.makedirs(os.path.join(CEIL_RESULTS, "models"), exist_ok=True)
|
||||
tok = AutoTokenizer.from_pretrained(MODEL2_DIR)
|
||||
rows = s19.load_dev()
|
||||
y = np.array([r["y"] for r in rows])
|
||||
tags = [r["tags"] for r in rows]
|
||||
folds = np.array([r["cv_fold"] for r in rows])
|
||||
pairs = s19.build_pairs(rows, [r["text_strip"] for r in rows])
|
||||
# audit: is the tiny2 tokenizer sane on the corpus before anything else
|
||||
n_unk = 0
|
||||
n_tok = 0
|
||||
lens = []
|
||||
for r in rows:
|
||||
e = tok(r["text_orig"])
|
||||
n_unk += e["input_ids"].count(tok.unk_token_id)
|
||||
n_tok += len(e["input_ids"])
|
||||
lens.append(len(e["input_ids"]))
|
||||
audit = {"vocab_size": tok.vocab_size,
|
||||
"unk_count": int(n_unk),
|
||||
"unk_rate": round(n_unk / max(n_tok, 1), 5),
|
||||
"seq_len_p99": sorted(lens)[int(len(lens) * .99)],
|
||||
"seq_len_max": max(lens)}
|
||||
with open(os.path.join(CEIL_RESULTS, "audit.json"), "w") as f:
|
||||
json.dump(audit, f, indent=2)
|
||||
print("tiny2 audit:", audit)
|
||||
|
||||
# tokenize the corpus (regime A only — natural text, the in-pool best)
|
||||
ids_a, attn_a = tokenize([r["text_orig"] for r in rows], tok)
|
||||
ev = {v: tokenize([r[f"text_{v}"] for r in rows], tok) for v in VIEWS}
|
||||
|
||||
def make2():
|
||||
cfg = AutoConfig.from_pretrained(MODEL2_DIR)
|
||||
cfg.num_labels = 1
|
||||
m = BertForSequenceClassification.from_pretrained(MODEL2_DIR, config=cfg)
|
||||
return m
|
||||
|
||||
# cap-Q LOFO, 3 seeds, matching the A@2e-5 tiny1 config
|
||||
lfors = []
|
||||
src = np.where(np.array(["capability_question" not in t for t in tags]))[0]
|
||||
tgt = np.where(np.array(["capability_question" in t for t in tags]))[0]
|
||||
for seed in SEEDS:
|
||||
t_idx, v_idx = _val_split(src, y, seed + 7)
|
||||
net, ep, best_pr, _ = train_model(ids_a, attn_a, y, t_idx, v_idx, 2e-5, seed, builder=make2)
|
||||
p = predict_proba(net, ids_a, attn_a, tgt)
|
||||
yt = y[tgt]
|
||||
lfors.append({"regime": "A(tiny2)", "lr": 2e-5, "seed": seed,
|
||||
"rows": int(len(tgt)), "epochs": ep,
|
||||
"mean_action_proba": float(np.mean(p)),
|
||||
"acc": float(((p >= 0.5) == (yt == 1)).mean()),
|
||||
"FA": int(((p >= 0.5) & (yt == 0)).sum()),
|
||||
"FA_rate": float(((p >= 0.5) & (yt == 0)).mean())})
|
||||
with open(os.path.join(CEIL_RESULTS, "lfo.json"), "w") as f:
|
||||
json.dump(lfors, f, indent=2)
|
||||
print("tiny2 LOFO:", [round(l["FA_rate"], 3) for l in lfors])
|
||||
|
||||
# grouped CV for the same best config + in-pool pairs / capQ
|
||||
oof = {v: np.zeros(len(rows)) for v in VIEWS}
|
||||
for fold in range(5):
|
||||
tr = np.where(folds != fold)[0]
|
||||
te = np.where(folds == fold)[0]
|
||||
t_idx, v_idx = _val_split(tr, y, 42 + 100 * fold)
|
||||
net, _, _, _ = train_model(ids_a, attn_a, y, t_idx, v_idx, 2e-5, 42 + fold, builder=make2)
|
||||
for v in VIEWS:
|
||||
oof[v][te] = predict_proba(net, *ev[v], te)
|
||||
inpool = {"strip": s19.binary_metrics(y, oof["strip"]),
|
||||
"pairs": {v: s19.pair_metrics(pairs, oof[v]) for v in VIEWS},
|
||||
"capQ_inpool": {v: s19.capq_fa(tags, y, oof[v]) for v in VIEWS}}
|
||||
with open(os.path.join(CEIL_RESULTS, "grouped.json"), "w") as f:
|
||||
json.dump(inpool, f, indent=2, default=str)
|
||||
b = inpool["strip"]
|
||||
print(f"tiny2 grouped A@2e-5: PR={b['PR_AUC']:.3f} P={b['P']:.3f} R={b['R']:.3f} "
|
||||
f"FA={b['FA']} pairs_strip={inpool['pairs']['strip']['ordering_acc']:.3f} "
|
||||
f"capQ_strip={inpool['capQ_inpool']['strip']:.3f}", flush=True)
|
||||
print("ceiling done")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("cmd", choices=["pre", "grouped", "lfo", "metrics", "runtime", "onnx", "ceiling"])
|
||||
args = ap.parse_args()
|
||||
t0 = time.time()
|
||||
globals()[f"cmd_{args.cmd}"](args)
|
||||
print(f"elapsed {time.time()-t0:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
// Fixture is one brief-specified utterance and the eligibility it must earn.
|
||||
// Fixtures are EXACTLY the slice-21 brief examples plus the corpus-verified
|
||||
// structural discriminators. They are eval material, not corpus additions:
|
||||
// nothing here is inserted into any pool file (brief §2, §18).
|
||||
type Fixture struct {
|
||||
Utterance string
|
||||
Want Eligibility
|
||||
Family string
|
||||
Note string
|
||||
}
|
||||
|
||||
// Fixtures covers every family the brief's §15 list exercises, including the
|
||||
// four that have no dev-pool rows at all (negation, reported_speech,
|
||||
// quotation, hypothetical):
|
||||
//
|
||||
// direct imperative already plural in dev; fixtures pin the canonical
|
||||
// cases together with the politeness-negative modal.
|
||||
// polite request polite imperative + bare "можешь …, пожалуйста".
|
||||
// modal request "можно …", English "can you …, please".
|
||||
// first-person request "я хочу …", "мне нужно …", "надо …".
|
||||
// reordered target "свет выключи, пожалуйста" (target-first command).
|
||||
// capability question ты/умеешь/сможешь/способна + "… ли" permission.
|
||||
// ordinary question "что запущено" — no execution pressure.
|
||||
// negation "не выключай свет" (parser-covered); advisory "не
|
||||
// надо выключать свет".
|
||||
// reported speech past/third-person report verbs over a command.
|
||||
// quotation quoted command, with and without a reporting frame.
|
||||
// hypothetical если-scopes that are not real condition→command.
|
||||
var Fixtures = []Fixture{
|
||||
// negation — direct commands the prosecutor forbids
|
||||
{"не выключай свет", Blocked, "negation", "direct prohibition"},
|
||||
{"не перезапускай сервер", Blocked, "negation", "direct prohibition"},
|
||||
{"не включай nginx", Blocked, "negation", "direct prohibition"},
|
||||
{"не надо выключать свет", Blocked, "negation", "advisory negative"},
|
||||
{"не стоит перезапускать nginx", Blocked, "negation", "advisory negative"},
|
||||
{"не забудь напомнить про свет", Permissive, "negation", "prohibition-parser reminder exemption"},
|
||||
|
||||
// reported speech — reports an order, does not issue one
|
||||
{"он сказал выключить свет", Blocked, "reported_speech", "past report verb + infinitive"},
|
||||
{"она попросила перезапустить nginx", Blocked, "reported_speech", "past report verb + infinitive"},
|
||||
{"мне сказали включить свет", Blocked, "reported_speech", "passive report + infinitive"},
|
||||
{"он написал: «перезапусти nginx»", Blocked, "reported_speech", "report verb + quoted imperative"},
|
||||
{"скажи мне, что он сказал про свет", Permissive, "reported_speech", "request to report, no commanded clause"},
|
||||
{"расскажи про свет", Permissive, "reported_speech", "narrative request, not a reported order"},
|
||||
|
||||
// quotation — quoted text is referenced, not issued
|
||||
{"фраза «выключи свет»", Blocked, "quotation", "reporting noun + quoted imperative"},
|
||||
{"он сказал «выключи свет»", Blocked, "quotation", "report verb + quoted imperative"},
|
||||
{"«выключи свет»", Ambiguous, "quotation", "bare quoted command, no frame"},
|
||||
{"выключи свет", Permissive, "quotation", "unquoted imperative is a live command"},
|
||||
|
||||
// hypothetical
|
||||
{"если выключить свет...", Blocked, "hypothetical", "conditional + infinitive + ellipsis"},
|
||||
{"если бы перезапустить nginx...", Blocked, "hypothetical", "conditional + бы + infinitive"},
|
||||
{"что будет если выключить свет", Blocked, "hypothetical", "question-scoped conditional"},
|
||||
{"если будет дождь, выключи полив", Permissive, "hypothetical", "real condition → imperative"},
|
||||
{"выключи свет если будет дождь", Permissive, "hypothetical", "imperative → real condition"},
|
||||
|
||||
// capability question — blocked even polite
|
||||
{"ты можешь выключить свет?", Blocked, "capability_question", "ты + можешь + ?"},
|
||||
{"ты можешь выключить свет", Blocked, "capability_question", "ты + можешь, no ?"},
|
||||
{"ты можешь выключить свет, пожалуйста", Blocked, "capability_question", "ты + можешь + politeness (42/42 non-action)"},
|
||||
{"умеешь ли ты выключить свет", Blocked, "capability_question", "ability form + ли"},
|
||||
{"сможешь открыть окно, пожалуйста", Blocked, "capability_question", "bare future + politeness (7/7 non-action)"},
|
||||
{"ты способна выключить свет", Blocked, "capability_question", "ты + способна"},
|
||||
{"могу ли я выключить свет", Blocked, "capability_question", "first-person can + ли"},
|
||||
{"можно ли выключить свет", Blocked, "capability_question", "можно + ли permission question"},
|
||||
{"ты выключишь свет?", Ambiguous, "capability_question", "future tense + ? without can-form"},
|
||||
|
||||
// modal / polite requests — permissive
|
||||
{"можешь выключить свет, пожалуйста", Permissive, "modal_request", "bare можешь + politeness (127/127 action)"},
|
||||
{"пожалуйста, выключи свет", Permissive, "polite_request", "leading politeness + imperative"},
|
||||
{"выключи свет, пожалуйста", Permissive, "polite_request", "imperative + trailing politeness"},
|
||||
{"выключи свет", Permissive, "direct_imperative", "plain imperative"},
|
||||
{"свет выключи, пожалуйста", Permissive, "reordered_target", "target-first imperative"},
|
||||
{"can you выключи свет, please", Permissive, "modal_request", "English frame + Russian imperative + please (96/96 action)"},
|
||||
{"can you выключи свет", Ambiguous, "modal_request", "English can without politeness"},
|
||||
{"не мог бы ты выключить свет", Permissive, "polite_request", "conditional politeness, prohibition-parser exemption"},
|
||||
{"можно выключить свет", Permissive, "modal_request", "можно + infinitive permission-implicature request"},
|
||||
|
||||
// first-person requests
|
||||
{"я хочу выключить свет", Permissive, "first_person_request", "first-person + illocution"},
|
||||
{"я хочу чтобы ты выключил свет", Permissive, "first_person_request", "first-person + embedded ya-you wish"},
|
||||
{"надо выключить свет", Permissive, "first_person_request", "impersonal need"},
|
||||
{"мне нужно включить свет", Permissive, "first_person_request", "first-person oblique + need"},
|
||||
|
||||
// ordinary questions — no execution pressure even when answerable
|
||||
{"что запущено", Ambiguous, "question", "status question, no request evidence"},
|
||||
{"какие службы работают", Ambiguous, "question", "question, no request evidence"},
|
||||
{"сколько ламп включено", Ambiguous, "question", "question, no request evidence"},
|
||||
{"что ты можешь включить", Blocked, "capability_question", "open question with ты + можешь"},
|
||||
{"покажи что запущено", Permissive, "first_person_request", "imperative lead over a status question"},
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
// Guard is the slice-21 deterministic execution-frame engine (experiment-only).
|
||||
//
|
||||
// It answers one question: given an utterance, what is its execution-frame
|
||||
// eligibility as a three-way gate — permissive, blocked, ambiguous — and why.
|
||||
// It never decides what an utterance IS (that stays with the route classifier);
|
||||
// it only decides whether an utterance may become an executable action at all.
|
||||
// The policy is asymmetric on purpose: blocked and ambiguous must never
|
||||
// execute, and permissive only means "no blocking speech-act evidence exists",
|
||||
// not "execute this".
|
||||
//
|
||||
// It reuses the shipped deterministic routers rather than inventing new ones:
|
||||
//
|
||||
// router.ParseCommandProhibition / IsCommandProhibition direct negative commands
|
||||
// morph.IsVerbForm / morph.Lemma verb mood and finiteness
|
||||
// lexicon.IsFillerParticle / FirstPerson() politeness and first-person frames
|
||||
//
|
||||
// Everything else is closed-class evidence measured on the frozen slice-20 dev
|
||||
// pool (§"measured discriminators" in the brief): 126 capability-question rows
|
||||
// split 42/42/42 across ты-addressed, bare ability (умеешь), and bare future
|
||||
// (сможешь) modality; 127 bare "можешь, пожалуйста" rows are 100% action;
|
||||
// "can you … , please" (English frame + Russian imperative) is 100% action.
|
||||
// The rules below are the encoding of precisely those numbers.
|
||||
//
|
||||
// The reason vocabulary is a closed set. Additions are design decisions that
|
||||
// must land in the report, not silent new branches.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Eligibility is the three-way execution-frame verdict.
|
||||
type Eligibility int
|
||||
|
||||
const (
|
||||
Permissive Eligibility = iota // no blocking speech-act evidence; downstream route decides
|
||||
Blocked // a speech act forbids execution (negation, question, report, …)
|
||||
Ambiguous // not enough evidence either way; must not execute
|
||||
)
|
||||
|
||||
func (e Eligibility) String() string {
|
||||
switch e {
|
||||
case Permissive:
|
||||
return "permissive"
|
||||
case Blocked:
|
||||
return "blocked"
|
||||
default:
|
||||
return "ambiguous"
|
||||
}
|
||||
}
|
||||
|
||||
// Reason is a closed set of structural explanations for a verdict.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
ReasonCommandProhibition Reason = "command_prohibition"
|
||||
ReasonCapabilityQuestion Reason = "capability_question"
|
||||
ReasonReportedSpeech Reason = "reported_speech"
|
||||
ReasonQuotation Reason = "quotation"
|
||||
ReasonHypothetical Reason = "hypothetical"
|
||||
ReasonNegatedCommand Reason = "negated_command"
|
||||
ReasonExplicitRequest Reason = "explicit_request"
|
||||
ReasonAmbiguousModal Reason = "ambiguous_modal"
|
||||
ReasonNoRequestEvidence Reason = "no_request_evidence"
|
||||
)
|
||||
|
||||
func (r Reason) String() string { return string(r) }
|
||||
|
||||
// Frame is the verdict for one utterance. Eligibility decides; Reasons explain.
|
||||
// A frame may carry more than one reason (e.g. a quoted reported command).
|
||||
type Frame struct {
|
||||
Eligibility Eligibility
|
||||
Reasons []Reason
|
||||
}
|
||||
|
||||
// maybeWord is a single-token or multi-token closed expression, e.g. the
|
||||
// token "не мог бы" covers the three tokens не мog бы when matched as a
|
||||
// contiguous run ("бы" is itself a bound marker). Multi-token members are
|
||||
// matched over the reconstructed token text, never over raw text, so
|
||||
// punctuation boundaries do not defeat them.
|
||||
type maybeWord struct {
|
||||
single []string
|
||||
multi []string // matched as contiguous lowercased token runs
|
||||
}
|
||||
|
||||
func (w maybeWord) in(toks []string, joined string) bool {
|
||||
if hasAny(toks, w.single) {
|
||||
return true
|
||||
}
|
||||
for _, m := range w.multi {
|
||||
tm := strings.Join(tokens(m), " ")
|
||||
if tm != "" && strings.Contains(joined, tm) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── closed evidence sets (all measured on the slice-20 dev pool) ──────────
|
||||
|
||||
// wakeAddr is stripped from the left of an utterance before command-form
|
||||
// detection: "мавен, выключи свет" and "выключи свет" must ride the same
|
||||
// frame. Closed: the names Maven answers to in the dev pool.
|
||||
var wakeAddr = []string{"мавен", "maven", "мавэн", "алекса", "алиса", "окей", "эй", "hey"}
|
||||
|
||||
// ruAddress are the second-person Russian address tokens. "ты можешь …"
|
||||
// (with or without politeness) is 42/42 capability-question in the dev pool,
|
||||
// so any addressed Russian can-form is a capability question, never a request.
|
||||
var ruAddress = []string{"ты", "тебе", "тебя", "тобой", "тобою", "вы", "вас", "вам", "вами"}
|
||||
|
||||
// enAddress is the English second-person address. Unlike Russian, "can you …
|
||||
// , please" is 96/96 action in the dev pool (English modal frame around a
|
||||
// Russian imperative), so English address alone never blocks: it routes to the
|
||||
// politeness arm.
|
||||
var enAddress = []string{"you", "u", "your"}
|
||||
|
||||
// ruCanForms are the present-can verb forms. Bare (no address) "можешь …,
|
||||
// пожалуйста" is 127/127 action; bare "можешь …" with no politeness is the
|
||||
// ambiguous bucket (no such rows exist in dev — conservative default).
|
||||
var ruCanForms = []string{"можешь", "можете", "могу", "можем"}
|
||||
|
||||
// ruAbilityForms are future/ability modal forms that read as a question of
|
||||
// capability regardless of politeness: "сможешь открыть окно, пожалуйста" and
|
||||
// "умеешь ли ты …" are 0/84 action in the dev pool, so even a polite bare
|
||||
// form never grants execution. "мог(ла) бы …" and "смог(ла) бы …" are the
|
||||
// conditional-politeness mask over the same boundary — except the leading
|
||||
// politeness construction "не мог бы ты …", which the prohibition parser
|
||||
// already classifies as ordinary modal politeness and must stay permissive.
|
||||
var ruAbilityForms = maybeWord{
|
||||
single: []string{
|
||||
"сможешь", "сможете", "смогу", "сможем", "сумеешь", "сумеете",
|
||||
"умеешь", "умеете", "способна", "способен", "способно", "способны",
|
||||
"смог", "смогла", "смогли", "мог", "могла", "могли",
|
||||
},
|
||||
multi: []string{
|
||||
"смог бы", "смогла бы", "смогли бы", "мог бы", "могла бы", "могли бы",
|
||||
"смочь бы", "мочь бы",
|
||||
},
|
||||
}
|
||||
|
||||
// politeNegativeModal is the leading "не мог бы ты/вы …" politeness framing the
|
||||
// prohibition parser exempts as ordinary modal politeness. When it leads the
|
||||
// utterance the capability stage declines and the frame reads as a request.
|
||||
var politeNegativeModal = []string{
|
||||
"не мог бы", "не могла бы", "не могли бы", "не смог бы", "не смогла бы", "не смогли бы",
|
||||
}
|
||||
|
||||
// enCanForms are the English modal can/could tokens.
|
||||
var enCanForms = []string{"can", "could"}
|
||||
|
||||
// politeness is the closed set of politeness fillers. пожалуйста/плиз/please
|
||||
// are already closed-class filler particles in the lexicon; the добр-forms
|
||||
// are the only additions the dev pool exercises.
|
||||
var politeness = maybeWord{
|
||||
single: []string{"пожалуйста", "плиз", "please"},
|
||||
multi: []string{"будь добр", "будьте добры", "был бы добр", "были бы добры"},
|
||||
}
|
||||
|
||||
// reportVerbs are the past/third-person report verbs — the frame that reports
|
||||
// a command rather than issuing it. Second-person imperatives ("скажи",
|
||||
// "расскажи", "напомни") are deliberately absent: those are requests to
|
||||
// report, and their clause forms part of the current utterance, not a
|
||||
// replayed order. Matched as closed list (a report verb outside it is a data
|
||||
// gap, noted in the report).
|
||||
var reportVerbs = []string{
|
||||
"сказал", "сказала", "сказали", "говорил", "говорила", "говорили",
|
||||
"говорит", "говорят", "попросил", "попросила", "попросили",
|
||||
"просил", "просила", "просили", "написал", "написала", "написали",
|
||||
"пишет", "приказал", "приказала", "приказали", "велел", "велела",
|
||||
"велели", "скомандовал", "скомандовала", "рекомендовал", "рекомендовала",
|
||||
"посоветовал", "посоветовала", "сообщил", "сообщила", "сообщили",
|
||||
"объявил", "объявила", "велено", "сказано", "написано", "записано",
|
||||
}
|
||||
|
||||
// reportNouns name a quoted or reported text: "фраза «выключи свет»" is a
|
||||
// quotation, not a command.
|
||||
var reportNouns = []string{
|
||||
"фраза", "фразы", "фразе", "фразу", "слово", "слова", "слове", "словом",
|
||||
"выражение", "выражения", "цитата", "цитату", "цитате",
|
||||
"название", "текст", "сообщение", "письмо", "заметка", "заметку",
|
||||
}
|
||||
|
||||
// hypothesisMarkers open a conditional scope.
|
||||
var hypothesisMarkers = []string{"если", "ежели", "коли", "кабы", "if"}
|
||||
|
||||
// illocutionVerbs make a first-person or impersonal clause a request even
|
||||
// without an imperative form ("я хочу …", "мне нужно …", "надо …").
|
||||
var illocutionVerbs = maybeWord{
|
||||
single: []string{
|
||||
"хочу", "хотел", "хотела", "хотелось", "желаю", "прошу", "просим",
|
||||
"просил", "просила", "просили", "попросить",
|
||||
"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется",
|
||||
"могу", "давай", "давайте",
|
||||
},
|
||||
multi: []string{
|
||||
"хотел бы", "хотела бы", "хочу чтобы", "хотел чтобы", "хотела чтобы",
|
||||
"могу ли",
|
||||
},
|
||||
}
|
||||
|
||||
// ── token helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// tokens lowercases and splits on anything that is not a letter or digit,
|
||||
// matching the router's planTokens discipline ("что-дальше" tokenises like
|
||||
// "что дальше").
|
||||
func tokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
func hasTok(toks []string, w string) bool {
|
||||
for _, t := range toks {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAny(toks, ws []string) bool {
|
||||
for _, w := range ws {
|
||||
if hasTok(toks, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indexTok(toks []string, w string) int {
|
||||
for i, t := range toks {
|
||||
if t == w {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// isFiniteVerb reports a verb form that is not the dictionary (infinitive)
|
||||
// form: "выключи" is finite, "выключить" is not. A finite verb at command
|
||||
// position is positive request evidence; an infinitive is not.
|
||||
func isFiniteVerb(tok string) bool {
|
||||
if !morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
return morph.Lemma(tok) != tok
|
||||
}
|
||||
|
||||
// isInfinitive reports a token that morph resolves to its own dictionary form
|
||||
// (the lemma ends in the infinitive ending by construction).
|
||||
func isInfinitive(tok string) bool {
|
||||
if !morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
return morph.Lemma(tok) == tok
|
||||
}
|
||||
|
||||
func anyInfinitive(toks []string) bool {
|
||||
for _, t := range toks {
|
||||
if isInfinitive(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyFiniteVerb(toks []string) bool {
|
||||
for _, t := range toks {
|
||||
if isFiniteVerb(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAnyVerb(toks []string) bool {
|
||||
for _, t := range toks {
|
||||
if morph.IsVerbForm(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func blocked(rs ...Reason) Frame { return Frame{Eligibility: Blocked, Reasons: rs} }
|
||||
func ambiguous(rs ...Reason) Frame {
|
||||
return Frame{Eligibility: Ambiguous, Reasons: rs}
|
||||
}
|
||||
func permissive(rs ...Reason) Frame {
|
||||
return Frame{Eligibility: Permissive, Reasons: rs}
|
||||
}
|
||||
|
||||
// ── quoted spans ─────────────────────────────────────────────────────────
|
||||
|
||||
// quotedSpan is a maximal quoted interval in the normalized text.
|
||||
type quotedSpan struct{ content string }
|
||||
|
||||
// quotePairs covers the quoting styles the dev pool and brief fixtures use:
|
||||
// Russian guillemets, curly double/single quotes, and straight quotes.
|
||||
var quotePairs = []struct{ open, close string }{
|
||||
{"«", "»"}, {"„", "\""}, {"“", "”"}, {"‚", "‘"}, {"‘", "’"}, {"'", "'"}, {"\"", "\""},
|
||||
}
|
||||
|
||||
// extractQuotedSpans returns the contents of quoted spans in order, in rune
|
||||
// index space (the text is normalized, so glyphs are single runes). An
|
||||
// unbalanced delimiter yields no span (best-effort; the conservative
|
||||
// fallback then applies).
|
||||
func extractQuotedSpans(t string) []quotedSpan {
|
||||
runes := []rune(t)
|
||||
var out []quotedSpan
|
||||
i := 0
|
||||
for i < len(runes) {
|
||||
matched := false
|
||||
for _, p := range quotePairs {
|
||||
po := []rune(p.open)
|
||||
pc := []rune(p.close)
|
||||
if i+len(po) > len(runes) || string(runes[i:i+len(po)]) != p.open {
|
||||
continue
|
||||
}
|
||||
j := i + len(po)
|
||||
for j+len(pc) <= len(runes) && string(runes[j:j+len(pc)]) != p.close {
|
||||
j++
|
||||
}
|
||||
out = append(out, quotedSpan{content: string(runes[i+len(po) : j])})
|
||||
i = j + len(pc)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── rule stages (evaluated in this order; a decision is final) ────────────
|
||||
|
||||
// Evaluate derives the execution-frame verdict for one utterance.
|
||||
func Evaluate(text string) Frame {
|
||||
t := router.NormalizeMatchText(text)
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return ambiguous(ReasonNoRequestEvidence)
|
||||
}
|
||||
toks := tokens(t)
|
||||
joined := strings.Join(toks, " ")
|
||||
|
||||
// 1. Quotation: a command inside a quoted span is not a command being
|
||||
// issued now. With a reporting frame outside it is a quotation; a bare
|
||||
// quote is at best ambiguous.
|
||||
if f, ok := stageQuotation(t, toks, joined); ok {
|
||||
return f
|
||||
}
|
||||
|
||||
// 2. Reported speech: a past/third-person report verb governing a command
|
||||
// clause reports an order to someone else, it does not issue one.
|
||||
if f, ok := stageReport(t, toks, joined); ok {
|
||||
return f
|
||||
}
|
||||
|
||||
// 3. Hypothetical: a command scope opened by "если/if" that does not
|
||||
// continue as a real condition→command is not an execution request.
|
||||
if f, ok := stageHypothesis(toks, joined); ok {
|
||||
return f
|
||||
}
|
||||
|
||||
// 4. Direct negative commands: the shipped prohibition parser.
|
||||
if router.IsCommandProhibition(t) {
|
||||
return blocked(ReasonCommandProhibition)
|
||||
}
|
||||
|
||||
// 5. Advisory negatives: "не надо/не стоит/не нужно …".
|
||||
if f, ok := stageAdvisoryNegation(toks); ok {
|
||||
return f
|
||||
}
|
||||
|
||||
// 6. Capability and permission modality (the measured core).
|
||||
if f, ok := stageCapability(toks, joined); ok {
|
||||
return f
|
||||
}
|
||||
|
||||
// 7. Trailing question mark with no modal at play: an uncertain posture,
|
||||
// never a confirmed executable request.
|
||||
if strings.HasSuffix(t, "?") {
|
||||
return ambiguous(ReasonAmbiguousModal)
|
||||
}
|
||||
|
||||
// 8. Positive request evidence.
|
||||
if hasRequestEvidence(toks, joined) {
|
||||
return permissive(ReasonExplicitRequest)
|
||||
}
|
||||
|
||||
// 9. No execution pressure at all.
|
||||
return ambiguous(ReasonNoRequestEvidence)
|
||||
}
|
||||
|
||||
// stageQuotation blocks a quoted command when a reporting frame surrounds it.
|
||||
func stageQuotation(t string, toks []string, joined string) (Frame, bool) {
|
||||
spans := extractQuotedSpans(t)
|
||||
if len(spans) == 0 {
|
||||
return Frame{}, false
|
||||
}
|
||||
commandSpan := false
|
||||
for _, sp := range spans {
|
||||
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
|
||||
commandSpan = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !commandSpan {
|
||||
return Frame{}, false
|
||||
}
|
||||
reasons := []Reason{ReasonQuotation}
|
||||
if hasReportFrame(toks, joined) {
|
||||
reasons = append(reasons, ReasonReportedSpeech)
|
||||
return Frame{Eligibility: Blocked, Reasons: reasons}, true
|
||||
}
|
||||
// a bare quoted command has no reporting frame: refusable but not a
|
||||
// definite prohibition either (it is at least ambiguous)
|
||||
return Frame{Eligibility: Ambiguous, Reasons: reasons}, true
|
||||
}
|
||||
|
||||
// isCommandishWithin reports the span content carrying command or capability
|
||||
// polarity itself — imperative, prohibition, or a can-form.
|
||||
func isCommandishWithin(toks []string, joined string) bool {
|
||||
if len(toks) == 0 {
|
||||
return false
|
||||
}
|
||||
if router.IsCommandProhibition(strings.Join(toks, " ")) {
|
||||
return true
|
||||
}
|
||||
if hasAny(toks, ruCanForms) || ruAbilityForms.in(toks, joined) || hasAny(toks, enCanForms) {
|
||||
return true
|
||||
}
|
||||
return anyFiniteVerb(toks)
|
||||
}
|
||||
|
||||
func hasReportFrame(toks []string, joined string) bool {
|
||||
if hasAny(toks, reportVerbs) {
|
||||
return true
|
||||
}
|
||||
return hasAny(toks, reportNouns)
|
||||
}
|
||||
|
||||
// stageReport blocks when a report frame governs a command clause: an
|
||||
// infinitive after the report verb, or a quoted imperative. Second-person
|
||||
// imperatives like "скажи/расскажи" are not in reportVerbs, so a request to
|
||||
// report ("расскажи мне, что сказал папа") passes through.
|
||||
func stageReport(t string, toks []string, joined string) (Frame, bool) {
|
||||
if !hasReportFrame(toks, joined) {
|
||||
return Frame{}, false
|
||||
}
|
||||
last := -1
|
||||
for i, w := range toks {
|
||||
if hasTok(reportVerbs, w) || hasTok(reportNouns, w) {
|
||||
last = i
|
||||
}
|
||||
}
|
||||
if last < 0 {
|
||||
return Frame{}, false
|
||||
}
|
||||
after := toks[last+1:]
|
||||
if len(after) == 0 {
|
||||
return Frame{}, false
|
||||
}
|
||||
// a quoted command after the frame counts as the governed clause
|
||||
for _, sp := range extractQuotedSpans(t) {
|
||||
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
|
||||
return blocked(ReasonReportedSpeech, ReasonQuotation), true
|
||||
}
|
||||
}
|
||||
if anyInfinitive(after) || hasAny(after, []string{"что", "чтобы", "чтоб"}) {
|
||||
return blocked(ReasonReportedSpeech), true
|
||||
}
|
||||
return Frame{}, false
|
||||
}
|
||||
|
||||
// stageHypothesis blocks a conditional scope whose clauses are hypothetical
|
||||
// (infinitive or subjunctive "бы") rather than a real condition→command.
|
||||
// "если будет дождь, выключи полив" keeps its imperative continuation and
|
||||
// passes through; it is a real conditional request, not a hypothetical.
|
||||
func stageHypothesis(toks []string, joined string) (Frame, bool) {
|
||||
idx := -1
|
||||
for _, m := range hypothesisMarkers {
|
||||
if i := indexTok(toks, m); i >= 0 && (idx < 0 || i < idx) {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return Frame{}, false
|
||||
}
|
||||
post := toks[idx+1:]
|
||||
if len(post) == 0 || hasTok(post, "бы") || anyInfinitive(post) {
|
||||
return blocked(ReasonHypothetical), true
|
||||
}
|
||||
// a real condition clause is not hypothetical: «если будет дождь,
|
||||
// выключи полив» is a request. The dev dict does not cover «будет», so
|
||||
// the imperative is looked for anywhere, not just after the marker
|
||||
// («выключи свет, если будет дождь»).
|
||||
if !anyFiniteVerb(toks) {
|
||||
return blocked(ReasonHypothetical), true
|
||||
}
|
||||
return permissive(ReasonExplicitRequest), true
|
||||
}
|
||||
|
||||
// stageAdvisoryNegation blocks "не надо/не нужно/не стоит/не следует …".
|
||||
// (absent from the dev pool; covered by brief fixtures)
|
||||
func stageAdvisoryNegation(toks []string) (Frame, bool) {
|
||||
if len(toks) < 3 || toks[0] != "не" {
|
||||
return Frame{}, false
|
||||
}
|
||||
if !hasTok(toks[1:2], "надо") && !hasTok(toks[1:2], "нужно") &&
|
||||
!hasTok(toks[1:2], "стоит") && !hasTok(toks[1:2], "следует") &&
|
||||
!hasTok(toks[1:2], "требуется") {
|
||||
return Frame{}, false
|
||||
}
|
||||
rest := toks[2:]
|
||||
if anyInfinitive(rest) || anyFiniteVerb(rest) || hasAnyVerb(rest) {
|
||||
return blocked(ReasonNegatedCommand), true
|
||||
}
|
||||
return Frame{}, false
|
||||
}
|
||||
|
||||
// stageCapability encodes the measured modal matrix. Returns a decision when
|
||||
// modality alone settles the frame.
|
||||
func stageCapability(toks []string, joined string) (Frame, bool) {
|
||||
// "не мог бы ты …, пожалуйста" style conditional politeness is ordinary
|
||||
// modal politeness (the prohibition parser exempts it as such): a request.
|
||||
for _, pref := range politeNegativeModal {
|
||||
if strings.HasPrefix(joined, pref) {
|
||||
return permissive(ReasonExplicitRequest), true
|
||||
}
|
||||
}
|
||||
|
||||
// "… ли" directly after a can-form is a polar capability question:
|
||||
// "могу ли я …", "можешь ли ты …", "умеешь ли ты …", "можно ли …".
|
||||
// Checked before the modality arms so the polar reading wins.
|
||||
if hasTok(toks, "ли") {
|
||||
for i := 1; i < len(toks); i++ {
|
||||
if toks[i] != "ли" {
|
||||
continue
|
||||
}
|
||||
prev := toks[i-1]
|
||||
if hasTok(ruCanForms, prev) || prev == "можно" || hasTok(ruAbilityForms.single, prev) {
|
||||
return blocked(ReasonCapabilityQuestion), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ruAddr := hasAny(toks, ruAddress)
|
||||
ruCan := hasAny(toks, ruCanForms)
|
||||
ruAbil := ruAbilityForms.in(toks, joined)
|
||||
enCan := hasAny(toks, enCanForms)
|
||||
polite := politeness.in(toks, joined)
|
||||
|
||||
// addressed Russian can-form: capability question, always blocked.
|
||||
// ("ты можешь выключить свет, пожалуйста" included — 42/42 non-action.)
|
||||
if ruAddr && (ruCan || ruAbil) {
|
||||
return blocked(ReasonCapabilityQuestion), true
|
||||
}
|
||||
|
||||
// ability forms (future/conditional/умеешь) are capability even bare and
|
||||
// even polite: "сможешь открыть окно, пожалуйста" is 7/7 non-action.
|
||||
if ruAbil && !ruCan {
|
||||
return blocked(ReasonCapabilityQuestion), true
|
||||
}
|
||||
|
||||
// bare Russian present can-form: politeness is the request marker.
|
||||
if ruCan && !ruAddr {
|
||||
if polite {
|
||||
return Frame{}, false // modal-request positive evidence is found later
|
||||
}
|
||||
return ambiguous(ReasonAmbiguousModal), true
|
||||
}
|
||||
|
||||
// English can/could: "can you …, please" is a request (96/96 action in the
|
||||
// dev pool; the frame wraps a Russian imperative). Without politeness it
|
||||
// reads as a capability question and stays ambiguous.
|
||||
if enCan && !ruAddr {
|
||||
if polite {
|
||||
return Frame{}, false // positive modal-request evidence later
|
||||
}
|
||||
return ambiguous(ReasonAmbiguousModal), true
|
||||
}
|
||||
|
||||
// "можно" (permission): "можно ли …" is a permission question; a bare
|
||||
// "можно …" is a politeness-implicature request.
|
||||
if hasTok(toks, "можно") {
|
||||
if hasTok(toks, "ли") {
|
||||
return blocked(ReasonCapabilityQuestion), true
|
||||
}
|
||||
return Frame{}, false
|
||||
}
|
||||
|
||||
// bare "могу": a self-capability statement, not a request.
|
||||
if hasTok(toks, "могу") && !hasTok(toks, "ли") {
|
||||
return ambiguous(ReasonAmbiguousModal), true
|
||||
}
|
||||
|
||||
return Frame{}, false
|
||||
}
|
||||
|
||||
// hasRequestEvidence is the positive permissive trigger, reached only after
|
||||
// every block/ambiguity stage above has declined.
|
||||
func hasRequestEvidence(toks []string, joined string) bool {
|
||||
polite := politeness.in(toks, joined)
|
||||
enCan := hasAny(toks, enCanForms)
|
||||
|
||||
// 1. politeness + a verb (or an English modal) is explicit request
|
||||
// evidence: "можешь выключить свет, пожалуйста", "can you останови …,
|
||||
// please", "выключи свет, пожалуйста".
|
||||
if polite && (hasAnyVerb(toks) || enCan) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. first-person illocution frame: "я хочу …", "мне нужно …".
|
||||
if hasAny(toks, lexicon.FirstPerson()) && illocutionVerbs.in(toks, joined) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. impersonal need: "надо …", "нужно …", "пора …".
|
||||
if hasAny(toks, []string{"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется"}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. permission-implicature request: "можно выключить свет".
|
||||
if hasTok(toks, "можно") && !hasTok(toks, "ли") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 5. reminder request in the parser's own exemption scope: the
|
||||
// prohibition parser declines «не забудь напомнить про свет» as a
|
||||
// reminder, not a prohibition — carry that into a request.
|
||||
if strings.HasPrefix(joined, "не забудь") && hasAny(toks, lexicon.ReminderVerbs()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 6. leading finite verb (imperative or otherwise tensed verb at command
|
||||
// position): "выключи свет", "покажи что запущено". Address and filler
|
||||
// particles are stripped first, so "мавен, выключи свет" rides the same
|
||||
// frame.
|
||||
lead := toks
|
||||
for len(lead) > 0 {
|
||||
first := lead[0]
|
||||
if !lexicon.IsFillerParticle(first) && !hasTok(wakeAddr, first) && !hasTok(ruAddress, first) {
|
||||
break
|
||||
}
|
||||
lead = lead[1:]
|
||||
}
|
||||
if len(lead) > 0 && isFiniteVerb(lead[0]) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
func TestEvaluateFixtures(t *testing.T) {
|
||||
for _, fx := range Fixtures {
|
||||
got := Evaluate(fx.Utterance)
|
||||
if got.Eligibility != fx.Want {
|
||||
t.Errorf("%s: want %s got %s (reasons %v)", fx.Utterance, fx.Want, got.Eligibility, got.Reasons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMeasuredDiscriminators pins the corpus-verified numbers on the frozen
|
||||
// pool. These are the exact measurements the rules were built on, so a change
|
||||
// that moves them is a rule regression visible in the slice.
|
||||
func loadPool(t *testing.T) []Row {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("/tmp/mvn-s21/pool.json")
|
||||
if os.IsNotExist(err) {
|
||||
t.Skip("pool.json missing; run slice21_emit.py first")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []Row
|
||||
if err := json.Unmarshal(b, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestDevCapabilityProhibition(t *testing.T) {
|
||||
if !morph.Available() {
|
||||
t.Skip("morph dict unavailable")
|
||||
}
|
||||
rows := loadPool(t)
|
||||
var n, pass, blockedN, ambig int
|
||||
for _, r := range rows {
|
||||
if !hasTok(r.Tags, "capability_question") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
switch Evaluate(variants(r.NText)[vOrig]).Eligibility {
|
||||
case Permissive:
|
||||
pass++
|
||||
case Blocked:
|
||||
blockedN++
|
||||
case Ambiguous:
|
||||
ambig++
|
||||
}
|
||||
}
|
||||
if n != 126 {
|
||||
t.Fatalf("capability-question rows = %d, want 126", n)
|
||||
}
|
||||
if pass != 0 {
|
||||
t.Fatalf("capability-question dangerous pass = %d, want 0", pass)
|
||||
}
|
||||
if blockedN != 126 && ambig != 0 {
|
||||
t.Errorf("blocked=%d ambig=%d, expect all 126 blocked", blockedN, ambig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevBareCanPoliteIsAction(t *testing.T) {
|
||||
if !morph.Available() {
|
||||
t.Skip("morph dict unavailable")
|
||||
}
|
||||
rows := loadPool(t)
|
||||
total, action, perm := 0, 0, 0
|
||||
for _, r := range rows {
|
||||
toks := tokens(r.NText)
|
||||
if !hasTok(toks, "можешь") || hasAny(toks, ruAddress) ||
|
||||
hasAny(toks, append([]string{}, ruAbilityForms.single...)) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.Route == "action" {
|
||||
action++
|
||||
}
|
||||
if Evaluate(r.NText).Eligibility == Permissive {
|
||||
perm++
|
||||
}
|
||||
}
|
||||
if total != 127 || action != 127 {
|
||||
t.Fatalf("bare-можешь+polite: n=%d action=%d, want 127/127", total, action)
|
||||
}
|
||||
if perm != 127 {
|
||||
t.Fatalf("bare-можешь+polite permissive=%d, want 127", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevEnglishCanPoliteIsAction(t *testing.T) {
|
||||
if !morph.Available() {
|
||||
t.Skip("morph dict unavailable")
|
||||
}
|
||||
rows := loadPool(t)
|
||||
total, action, perm := 0, 0, 0
|
||||
for _, r := range rows {
|
||||
toks := tokens(r.NText)
|
||||
if !hasAny(toks, enCanForms) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.Route == "action" {
|
||||
action++
|
||||
}
|
||||
if Evaluate(r.NText).Eligibility == Permissive {
|
||||
perm++
|
||||
}
|
||||
}
|
||||
if total != 96 || action != 96 {
|
||||
t.Fatalf("can-rows: n=%d action=%d, want 96/96", total, action)
|
||||
}
|
||||
if perm != 96 {
|
||||
t.Fatalf("can-rows permissive=%d, want 96", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoPermissiveRowEndsInQuestion(t *testing.T) {
|
||||
// asymmetric posture: the guard must never approve a trailing-? frame --
|
||||
// the corpus has 0 action rows ending in "?", and approving any would
|
||||
// bet on punctuation the slice has ruled uncertain.
|
||||
rows := loadPool(t)
|
||||
for _, r := range rows {
|
||||
if !strings.HasSuffix(r.NText, "?") {
|
||||
continue
|
||||
}
|
||||
if got := Evaluate(r.NText).Eligibility; got == Permissive {
|
||||
t.Errorf("canonical '?': %q approved (%s)", r.Text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// Slice 21 runner: report the deterministic execution-frame guard against the
|
||||
// frozen slice-20 dev pool.
|
||||
//
|
||||
// Reads the emit step's compact files (pool.json, pairs.json, sparse_oof.json
|
||||
// in /tmp/mvn-s21) and prints the report tables plus a machine-readable
|
||||
// guard_results.json. Reuses router/morph/lexicon parsers live inside this
|
||||
// module — the pool texts are the only data, no embedding is recomputed.
|
||||
//
|
||||
// Usage: go run ./cmd/semantic-router-experiment/slice21
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const sparseThreshold = 0.715 // slice-18 §4 strict operating point (P>=0.95 best recall)
|
||||
|
||||
var familyPriority = []string{
|
||||
"capability_question", "question", "first_person_request",
|
||||
"modal_request", "polite_request", "reordered_target", "direct_imperative",
|
||||
}
|
||||
|
||||
func familyOf(tags []string) string {
|
||||
for _, f := range familyPriority {
|
||||
if hasTok(tags, f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// ── pool row ──────────────────────────────────────────────────────────────
|
||||
|
||||
type Row struct {
|
||||
Idx int `json:"idx"`
|
||||
Text string `json:"text"`
|
||||
NText string `json:"n_text"`
|
||||
Route string `json:"route"`
|
||||
Y int `json:"y"`
|
||||
Tags []string `json:"tags"`
|
||||
CVFold int `json:"cv_fold"`
|
||||
SplitGp string `json:"split_group"`
|
||||
SourceID string `json:"source_id"`
|
||||
Family string
|
||||
VariantOf int
|
||||
}
|
||||
|
||||
type Pair struct{ Cap, Act int }
|
||||
|
||||
// ── stress variants ───────────────────────────────────────────────────────
|
||||
|
||||
var nofinalRe = regexp.MustCompile(`[?.!,;:]+$`)
|
||||
|
||||
// strip_punct mirrors the slice-18/19 python strip_punct: trailing sentence
|
||||
// punctuation, then every non-word/non-space rune.
|
||||
func stripPunct(t string) string {
|
||||
t = nofinalRe.ReplaceAllString(strings.TrimSpace(t), "")
|
||||
out := make([]rune, 0, len(t))
|
||||
var prevSpace bool
|
||||
for _, r := range t {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
out = append(out, r)
|
||||
prevSpace = false
|
||||
} else if !prevSpace {
|
||||
out = append(out, ' ')
|
||||
prevSpace = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func variants(nText string) [3]string {
|
||||
return [3]string{
|
||||
nText,
|
||||
nofinalRe.ReplaceAllString(strings.TrimSpace(nText), ""),
|
||||
stripPunct(nText),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
vOrig = iota
|
||||
vNofinal
|
||||
vStrip
|
||||
)
|
||||
|
||||
var variantName = [3]string{"orig", "nofinal", "strip"}
|
||||
|
||||
type result struct {
|
||||
Frame Frame `json:"frame"`
|
||||
}
|
||||
|
||||
// ── metrics ──────────────────────────────────────────────────────────────
|
||||
|
||||
type triTab struct {
|
||||
Permissive, Blocked, Ambiguous int
|
||||
PermNonact, BlockedAction, AmbAction int
|
||||
}
|
||||
|
||||
type runAgg struct {
|
||||
n, action, tp, fp, fn int
|
||||
approved int
|
||||
capPermissive int
|
||||
}
|
||||
|
||||
func (a *runAgg) addApproved(approved bool, route string) {
|
||||
a.n++
|
||||
if route == "action" {
|
||||
a.action++
|
||||
}
|
||||
if approved {
|
||||
a.approved++
|
||||
if route == "action" {
|
||||
a.tp++
|
||||
} else {
|
||||
a.fp++
|
||||
}
|
||||
} else if route == "action" {
|
||||
a.fn++
|
||||
}
|
||||
}
|
||||
|
||||
func (a *runAgg) P() string { return fmtPct(frac(a.tp, a.tp+a.fp)) }
|
||||
func (a *runAgg) R() string { return fmtPct(frac(a.tp, a.action)) }
|
||||
func (a *runAgg) FA() int { return a.fp }
|
||||
func (a *runAgg) FArate() string {
|
||||
return fmtPct(frac(a.fp, a.n))
|
||||
}
|
||||
|
||||
func maxi(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// frac is the guarded ratio the tables print (0/0 is 0).
|
||||
func frac(num, den int) float64 { return float64(num) / float64(maxi(den, 1)) }
|
||||
|
||||
func fmtPct(v float64) string { return fmt.Sprintf("%.1f%%", 100*v) }
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
poolPath := flag.String("pool", "/tmp/mvn-s21/pool.json", "dev pool rows")
|
||||
pairsPath := flag.String("pairs", "/tmp/mvn-s21/pairs.json", "cap-vs-action pairs")
|
||||
sparsePath := flag.String("sparse", "/tmp/mvn-s21/sparse_oof.json", "slice-18 both OOF proba")
|
||||
outPath := flag.String("out", "/tmp/mvn-s21/guard_results.json", "machine-readable results")
|
||||
flag.Parse()
|
||||
|
||||
rows := mustLoad[[]Row](*poolPath)
|
||||
// pairs.json is bare [cap, act] index pairs; adapt into typed pairs.
|
||||
rawPairs := mustLoad[[][2]int](*pairsPath)
|
||||
pairs := make([]Pair, 0, len(rawPairs))
|
||||
for _, rp := range rawPairs {
|
||||
pairs = append(pairs, Pair{Cap: rp[0], Act: rp[1]})
|
||||
}
|
||||
sparseOOF := mustLoad[[]struct {
|
||||
Idx int `json:"idx"`
|
||||
Proba float64 `json:"proba"`
|
||||
}](*sparsePath)
|
||||
proba := make([]float64, len(rows))
|
||||
for _, s := range sparseOOF {
|
||||
proba[s.Idx] = s.Proba
|
||||
}
|
||||
|
||||
famPrio := 0
|
||||
for i := range rows {
|
||||
rows[i].Family = familyOf(rows[i].Tags)
|
||||
if rows[i].Family != "other" {
|
||||
famPrio++
|
||||
}
|
||||
}
|
||||
_ = famPrio
|
||||
|
||||
// verdicts per variant
|
||||
type rowRes struct {
|
||||
Idx int `json:"idx"`
|
||||
Text string `json:"text"`
|
||||
Route string `json:"route"`
|
||||
Family string `json:"family"`
|
||||
Tags []string `json:"tags"`
|
||||
Frames map[string]string `json:"frames"` // variant -> eligibility
|
||||
}
|
||||
|
||||
perVariant := make([][3]Frame, len(rows))
|
||||
fmt.Println("slice 21 — deterministic execution-frame guard on slice-20 dev pool")
|
||||
fmt.Println("==================================================================")
|
||||
|
||||
for i, r := range rows {
|
||||
vs := variants(r.NText)
|
||||
var fr [3]Frame
|
||||
for vi := 0; vi < 3; vi++ {
|
||||
fr[vi] = Evaluate(vs[vi])
|
||||
}
|
||||
perVariant[i] = fr
|
||||
}
|
||||
|
||||
// ── §3 three-way cross-tab (orig) ─────────────────────────────────────
|
||||
fmt.Println("\n## 1. Three-way eligibility × route (orig)")
|
||||
tab := triTab{}
|
||||
for i, r := range rows {
|
||||
switch perVariant[i][vOrig].Eligibility {
|
||||
case Permissive:
|
||||
tab.Permissive++
|
||||
if r.Route != "action" {
|
||||
tab.PermNonact++
|
||||
}
|
||||
case Blocked:
|
||||
tab.Blocked++
|
||||
if r.Route == "action" {
|
||||
tab.BlockedAction++
|
||||
}
|
||||
case Ambiguous:
|
||||
tab.Ambiguous++
|
||||
if r.Route == "action" {
|
||||
tab.AmbAction++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("permissive: %d blocked: %d ambiguous: %d\n", tab.Permissive, tab.Blocked, tab.Ambiguous)
|
||||
fmt.Printf(" permissive non-action: %d blocked action: %d ambiguous action: %d\n",
|
||||
tab.PermNonact, tab.BlockedAction, tab.AmbAction)
|
||||
|
||||
// ── §4 binary executable-gate metrics on orig ─────────────────────────
|
||||
fmt.Println("\n## 2. Binary executable gate (approve = permissive; deny = blocked|ambiguous)")
|
||||
a := runAgg{}
|
||||
capCov, capPerm, capAmb := 0, 0, 0
|
||||
for i, r := range rows {
|
||||
el := perVariant[i][vOrig].Eligibility
|
||||
a.addApproved(el == Permissive, r.Route)
|
||||
if hasTok(r.Tags, "capability_question") {
|
||||
capCov++
|
||||
switch el {
|
||||
case Permissive:
|
||||
capPerm++
|
||||
case Ambiguous:
|
||||
capAmb++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("approved: %d denied: %d (n=%d, action=%d)\n", a.approved, a.n-a.approved, a.n, a.action)
|
||||
fmt.Printf("action precision %s recall %s FA %d (%s)\n", a.P(), a.R(), a.FA(), a.FArate())
|
||||
fmt.Printf("capability-question dangerous pass: %d / %d (rate %s)\n",
|
||||
capPerm, capCov, fmtPct(frac(capPerm, capCov)))
|
||||
fmt.Printf("capability-question blocked %d, ambiguous %d\n", capCov-capPerm-capAmb, capAmb)
|
||||
|
||||
// ── §15 family stress (orig) ──────────────────────────────────────────
|
||||
fmt.Println("\n## 3. Family stress (orig; counts per eligibility)")
|
||||
fmt.Printf("%-24s %8s %8s %8s %8s\n", "family", "n", "perm", "block", "ambig")
|
||||
famOrder := []string{"direct_imperative", "polite_request", "modal_request", "first_person_request",
|
||||
"reordered_target", "capability_question", "question", "other"}
|
||||
famAgg := map[string]*triTab{}
|
||||
for _, f := range famOrder {
|
||||
famAgg[f] = &triTab{}
|
||||
}
|
||||
for i, r := range rows {
|
||||
t := famAgg[r.Family]
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
switch perVariant[i][vOrig].Eligibility {
|
||||
case Permissive:
|
||||
t.Permissive++
|
||||
if r.Route != "action" {
|
||||
t.PermNonact++
|
||||
}
|
||||
case Blocked:
|
||||
t.Blocked++
|
||||
case Ambiguous:
|
||||
t.Ambiguous++
|
||||
}
|
||||
}
|
||||
for _, f := range famOrder {
|
||||
t := famAgg[f]
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
n := t.Permissive + t.Blocked + t.Ambiguous
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-24s %8d %8d %8d %8d\n", f, n, t.Permissive, t.Blocked, t.Ambiguous)
|
||||
}
|
||||
|
||||
// ── §cap-Q LOFO across stress variants ────────────────────────────────
|
||||
fmt.Println("\n## 4. Capability-question dangerous pass by stress variant")
|
||||
for vi := 0; vi < 3; vi++ {
|
||||
cp, cb, ca := 0, 0, 0
|
||||
for i, r := range rows {
|
||||
if !hasTok(r.Tags, "capability_question") {
|
||||
continue
|
||||
}
|
||||
switch perVariant[i][vi].Eligibility {
|
||||
case Permissive:
|
||||
cp++
|
||||
case Blocked:
|
||||
cb++
|
||||
case Ambiguous:
|
||||
ca++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %-8s dangerous-pass %d blocked %d ambiguous %d\n",
|
||||
variantName[vi], cp, cb, ca)
|
||||
}
|
||||
|
||||
// ── §pair test ────────────────────────────────────────────────────────
|
||||
fmt.Println("\n## 5. Paired action/capability (cap row must never clear)")
|
||||
capClear, actPerm, actAmbig, actBlock := 0, 0, 0, 0
|
||||
for _, p := range pairs {
|
||||
cel := perVariant[p.Cap][vOrig].Eligibility
|
||||
ael := perVariant[p.Act][vOrig].Eligibility
|
||||
if cel == Permissive {
|
||||
capClear++
|
||||
}
|
||||
switch ael {
|
||||
case Permissive:
|
||||
actPerm++
|
||||
case Ambiguous:
|
||||
actAmbig++
|
||||
case Blocked:
|
||||
actBlock++
|
||||
}
|
||||
}
|
||||
fmt.Printf("pairs %d: cap cleared %d (rate %s), action permissive %d, action ambiguous %d, action blocked %d\n",
|
||||
len(pairs), capClear, fmtPct(frac(capClear, len(pairs))),
|
||||
actPerm, actAmbig, actBlock)
|
||||
|
||||
// ── �safe composition §17 ─────────────────────────────────────────────
|
||||
fmt.Println("\n## 6. Composition: guard-alone / sparse-alone / guard→sparse (orig)")
|
||||
compose := map[string]*runAgg{
|
||||
"guard_alone": {},
|
||||
"sparse_alone": {},
|
||||
"guard_sparse": {},
|
||||
}
|
||||
for i, r := range rows {
|
||||
gPerm := perVariant[i][vOrig].Eligibility == Permissive
|
||||
sPerm := proba[i] >= sparseThreshold
|
||||
compose["guard_alone"].addApproved(gPerm, r.Route)
|
||||
compose["sparse_alone"].addApproved(sPerm, r.Route)
|
||||
compose["guard_sparse"].addApproved(gPerm && sPerm, r.Route)
|
||||
}
|
||||
fmt.Printf("%-14s %8s %8s %6s %10s %6s %10s\n", "policy", "P", "R", "FA", "FA rate", "capQ", "capQ rate")
|
||||
for _, name := range []string{"guard_alone", "sparse_alone", "guard_sparse"} {
|
||||
agg := compose[name]
|
||||
capQ := 0
|
||||
for i, r := range rows {
|
||||
if !hasTok(r.Tags, "capability_question") {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
switch name {
|
||||
case "guard_alone":
|
||||
ok = perVariant[i][vOrig].Eligibility == Permissive
|
||||
case "sparse_alone":
|
||||
ok = proba[i] >= sparseThreshold
|
||||
case "guard_sparse":
|
||||
ok = perVariant[i][vOrig].Eligibility == Permissive && proba[i] >= sparseThreshold
|
||||
}
|
||||
if ok {
|
||||
capQ++
|
||||
}
|
||||
}
|
||||
fmt.Printf("%-14s %8s %8s %6d %10s %6d %10s\n", name, agg.P(), agg.R(), agg.FA(),
|
||||
agg.FArate(), capQ, fmtPct(float64(capQ)/126))
|
||||
}
|
||||
|
||||
// composition on strip too (brief §16 voice stress)
|
||||
fmt.Println("\n## 7. Composition on punctuation-stripped text (strip)")
|
||||
c2 := runAgg{}
|
||||
capQ2 := 0
|
||||
for i, r := range rows {
|
||||
gPerm := perVariant[i][vStrip].Eligibility == Permissive
|
||||
ok := gPerm && proba[i] >= sparseThreshold
|
||||
c2.addApproved(ok, r.Route)
|
||||
if hasTok(r.Tags, "capability_question") && ok {
|
||||
capQ2++
|
||||
}
|
||||
}
|
||||
fmt.Printf("guard→sparse strip: P %s R %s FA %d (%s) capQ pass %d\n",
|
||||
c2.P(), c2.R(), c2.FA(), c2.FArate(), capQ2)
|
||||
|
||||
// ── §19 manual classification scratch ─────────────────────────────────
|
||||
fmt.Println("\n## 8. Manual classification (scan material written to manual_class.json)")
|
||||
var dangerous []map[string]any
|
||||
var permNonact []map[string]any
|
||||
var deniedAction []map[string]any
|
||||
for i, r := range rows {
|
||||
fr := perVariant[i][vOrig]
|
||||
if hasTok(r.Tags, "capability_question") && fr.Eligibility == Permissive {
|
||||
dangerous = append(dangerous, map[string]any{
|
||||
"idx": r.Idx, "text": r.Text, "route": r.Route,
|
||||
"reasons": fr.Reasons,
|
||||
})
|
||||
}
|
||||
if fr.Eligibility == Permissive && r.Route != "action" {
|
||||
permNonact = append(permNonact, map[string]any{
|
||||
"idx": r.Idx, "text": r.Text, "route": r.Route,
|
||||
"family": r.Family, "reasons": fr.Reasons,
|
||||
})
|
||||
}
|
||||
if fr.Eligibility != Permissive && r.Route == "action" {
|
||||
deniedAction = append(deniedAction, map[string]any{
|
||||
"idx": r.Idx, "text": r.Text, "family": r.Family,
|
||||
"eligibility": fr.Eligibility.String(), "reasons": fr.Reasons,
|
||||
})
|
||||
}
|
||||
}
|
||||
writeManual(permNonact, deniedAction, dangerous)
|
||||
fmt.Printf("dangerous passes: %d permissive non-action: %d denied action: %d\n",
|
||||
len(dangerous), len(permNonact), len(deniedAction))
|
||||
groupAndSample("permissive non-action by reason+family", permNonact, 4)
|
||||
groupAndSample("denied action by reason+family", deniedAction, 4)
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────
|
||||
fmt.Println("\n## 9. Brief fixtures")
|
||||
pass := 0
|
||||
for _, fx := range Fixtures {
|
||||
got := Evaluate(fx.Utterance)
|
||||
mark := "ok "
|
||||
if got.Eligibility != fx.Want {
|
||||
mark = "FAIL"
|
||||
} else {
|
||||
pass++
|
||||
}
|
||||
if got.Eligibility != fx.Want {
|
||||
fmt.Printf(" %s %-14s want %-10s got %-10s %s\n", mark, fx.Family,
|
||||
fx.Want, got.Eligibility.String(), fx.Utterance)
|
||||
}
|
||||
}
|
||||
fmt.Printf("fixtures: %d/%d passed\n", pass, len(Fixtures))
|
||||
|
||||
// write result file
|
||||
rr := make([]rowRes, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
fr := [3]string{"", "", ""}
|
||||
for vi := 0; vi < 3; vi++ {
|
||||
fr[vi] = perVariant[i][vi].Eligibility.String()
|
||||
}
|
||||
rr = append(rr, rowRes{
|
||||
Idx: r.Idx, Text: r.Text, Route: r.Route, Family: r.Family, Tags: r.Tags,
|
||||
Frames: map[string]string{
|
||||
"orig": fr[vOrig], "nofinal": fr[vNofinal], "strip": fr[vStrip],
|
||||
},
|
||||
})
|
||||
}
|
||||
if *outPath != "" {
|
||||
mustSave(*outPath, map[string]any{
|
||||
"pool": "/tmp/mvn-s21/pool.json",
|
||||
"rows": rr,
|
||||
"aggregates": map[string]any{
|
||||
"tab": tab,
|
||||
"capq_pass": capPerm,
|
||||
"capq_blocked": capCov - capPerm - capAmb,
|
||||
"capq_ambiguous": capAmb,
|
||||
"binary": map[string]any{"tp": a.tp, "fp": a.fp, "fn": a.fn, "approved": a.approved, "n": a.n},
|
||||
"pairs": map[string]any{"n": len(pairs), "cap_cleared": capClear, "act_permissive": actPerm},
|
||||
"guard_sparse": map[string]any{"tp": compose["guard_sparse"].tp, "fp": compose["guard_sparse"].fp, "fn": compose["guard_sparse"].fn},
|
||||
"dangerous_passes": len(dangerous),
|
||||
"perm_nonact_count": len(permNonact),
|
||||
"denied_action": len(deniedAction),
|
||||
},
|
||||
})
|
||||
fmt.Println("wrote", *outPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ── manual classification helpers ─────────────────────────────────────────
|
||||
|
||||
func writeManual(permNonact, deniedAction, dangerous []map[string]any) {
|
||||
writeJSON("/tmp/mvn-s21/manual_class.json", map[string]any{
|
||||
"dangerous_passes": dangerous,
|
||||
"permissive_non_action": permNonact,
|
||||
"denied_action": deniedAction,
|
||||
})
|
||||
}
|
||||
|
||||
func groupAndSample(title string, rows []map[string]any, sample int) {
|
||||
type g struct {
|
||||
key string
|
||||
n int
|
||||
texts []string
|
||||
}
|
||||
groups := map[string]*g{}
|
||||
var order []string
|
||||
for _, r := range rows {
|
||||
var family, reason, el string
|
||||
if v, ok := r["family"].(string); ok {
|
||||
family = v
|
||||
}
|
||||
if v, ok := r["eligibility"].(string); ok {
|
||||
el = v
|
||||
}
|
||||
if rs, ok := r["reasons"].([]Reason); ok {
|
||||
rs2 := make([]string, len(rs))
|
||||
for k, rr := range rs {
|
||||
rs2[k] = rr.String()
|
||||
}
|
||||
reason = strings.Join(rs2, ",")
|
||||
} else if rs, ok := r["reasons"].([]string); ok {
|
||||
reason = strings.Join(rs, ",")
|
||||
}
|
||||
key := fmt.Sprintf("family=%s elig=%s reason=%s", family, el, reason)
|
||||
if _, ok := groups[key]; !ok {
|
||||
groups[key] = &g{key: key}
|
||||
order = append(order, key)
|
||||
}
|
||||
groups[key].n++
|
||||
if len(groups[key].texts) < sample {
|
||||
groups[key].texts = append(groups[key].texts, firstN(fmt.Sprint(r["text"]), 60))
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s (%d rows):\n", title, len(rows))
|
||||
for _, key := range order {
|
||||
gr := groups[key]
|
||||
fmt.Printf(" %-58s n=%d %s\n", gr.key, gr.n, strings.Join(gr.texts, " | "))
|
||||
}
|
||||
}
|
||||
|
||||
func firstN(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// ── io helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func mustLoad[T any](path string) T {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var v T
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustSave(path string, v any) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) {
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
_ = os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
var _ = sort.Strings
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 21 emit: deterministic execution-frame guard — data files for the Go harness
|
||||
==================================================================================
|
||||
|
||||
Slice 18 showed the sparse lexical gate owns the aggregate boundary (PR-AUC
|
||||
0.838, strict operating point at threshold 0.715 with P>=0.95 | R=0.264) and
|
||||
slice 19/20 showed learning heads collapse on capability-question LOFO. Slice 21
|
||||
tests the deterministic alternative: a rule engine over existing parsers that
|
||||
decides execution eligibility as a three-way gate (permissive / blocked /
|
||||
ambiguous), never itself routing.
|
||||
|
||||
This script only repackages the frozen dev pool for the Go harness. It reuses
|
||||
slice 18's feature builders and grouped-CV and slice 19's pair builder verbatim,
|
||||
so the numbers the Go side reports are the same populations the accepts
|
||||
measured. It writes:
|
||||
|
||||
/tmp/mvn-s21/pool.json dev rows: idx, text, n_text, route, y, tags,
|
||||
cv_fold, split_group, source_id, family
|
||||
/tmp/mvn-s21/pairs.json capability-vs-action pairs (slice-19 builder)
|
||||
/tmp/mvn-s21/sparse_oof.json slice-18 "both" grouped-CV OOF proba per row
|
||||
(for the §17 guard+sparse composition)
|
||||
|
||||
No training happens here and no label is changed. The guard itself is Go.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import slice18_sparse # noqa: E402
|
||||
import slice19_main # noqa: E402
|
||||
|
||||
OUT_DIR = "/tmp/mvn-s21"
|
||||
SPARSE_THRESHOLD = 0.715 # slice-18 §4 strict-operating-point (P>=0.95 best recall)
|
||||
|
||||
|
||||
def main():
|
||||
# Population = the exact slice-20 dev pool (s19.load_dev): every dev_pool
|
||||
# row, fast-path included. The guard is evaluated on what slice 20 measured.
|
||||
meta, examples = slice18_sparse.load_data()
|
||||
dev = slice18_sparse.filter_dev_pool(examples)
|
||||
print(f"dev pool (all dev_pool rows): {len(dev)} rows")
|
||||
print(f"corpus meta: {meta.get('dev_count', '?')} dev rows declared, "
|
||||
f"{meta.get('route_counts', {}).get('action', '?')} action declared")
|
||||
|
||||
n_texts = [slice18_sparse.normalize_match_text(e["text"]) for e in dev]
|
||||
|
||||
rows = []
|
||||
by_route = {}
|
||||
by_family = {}
|
||||
for i, (e, nt) in enumerate(zip(dev, n_texts)):
|
||||
tags = sorted(set(e.get("tags", [])))
|
||||
route = e["route"]
|
||||
fam = slice19_main.family_of(set(tags))
|
||||
by_route[route] = by_route.get(route, 0) + 1
|
||||
by_family[fam] = by_family.get(fam, 0) + 1
|
||||
rows.append({
|
||||
"idx": i,
|
||||
"text": e["text"],
|
||||
"n_text": nt,
|
||||
"route": route,
|
||||
"y": 1 if route == "action" else 0,
|
||||
"tags": tags,
|
||||
"cv_fold": e["cv_fold"],
|
||||
"split_group": e["split_group"],
|
||||
"source_id": e["source_id"],
|
||||
})
|
||||
|
||||
print("routes:", by_route)
|
||||
print("families:", by_family)
|
||||
|
||||
# ── pairs (slice-19 builder, exact population) ─────────────────────────
|
||||
ldev = [{
|
||||
"text_orig": nt,
|
||||
"route": r["route"],
|
||||
"y": r["y"],
|
||||
"cv_fold": r["cv_fold"],
|
||||
"tags": set(r["tags"]),
|
||||
"source_id": r["source_id"],
|
||||
} for r, nt in zip(rows, n_texts)]
|
||||
pairs = slice19_main.build_pairs(ldev, n_texts)
|
||||
print(f"pairs: {len(pairs)}")
|
||||
|
||||
# ── slice-18 "both" grouped-CV OOF proba, aligned to row index ────────
|
||||
y = [1 if r["route"] == "action" else 0 for r in rows]
|
||||
folds = [r["cv_fold"] for r in rows]
|
||||
X, _vec = slice18_sparse.build_features(n_texts, "both")
|
||||
print(f"sparse 'both' X: {X.shape}")
|
||||
yb = np.array(y)
|
||||
folds_arr = np.array(folds)
|
||||
idx_proba = {}
|
||||
for te_fold in sorted(set(folds)):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yb[tr])
|
||||
p = clf.predict_proba(X[te])[:, 1]
|
||||
te_idx = np.where(te)[0]
|
||||
for k, i in enumerate(te_idx):
|
||||
idx_proba[int(i)] = float(p[k])
|
||||
assert len(idx_proba) == len(rows)
|
||||
sparse_oof = [{"idx": i, "proba": idx_proba[i]} for i in range(len(rows))]
|
||||
pred = [1 if idx_proba[i] >= 0.5 else 0 for i in range(len(rows))]
|
||||
tp = sum(1 for i in range(len(rows)) if y[i] == 1 and pred[i] == 1)
|
||||
fp = sum(1 for i in range(len(rows)) if y[i] == 0 and pred[i] == 1)
|
||||
fn = sum(1 for i in range(len(rows)) if y[i] == 1 and pred[i] == 0)
|
||||
print(f"sparse both OOF @0.5: P={tp/max(tp+fp,1):.3f} R={tp/max(tp+fn,1):.3f} "
|
||||
f"FA={fp} ({fp/len(rows):.4f})")
|
||||
pred21 = [1 if idx_proba[i] >= SPARSE_THRESHOLD else 0 for i in range(len(rows))]
|
||||
tp = sum(1 for i in range(len(rows)) if y[i] == 1 and pred21[i] == 1)
|
||||
fp = sum(1 for i in range(len(rows)) if y[i] == 0 and pred21[i] == 1)
|
||||
fn = sum(1 for i in range(len(rows)) if y[i] == 1 and pred21[i] == 0)
|
||||
print(f"sparse both OOF @{SPARSE_THRESHOLD}: P={tp/max(tp+fp,1):.3f} "
|
||||
f"R={tp/max(tp+fn,1):.3f} FA={fp} ({fp/len(rows):.4f})")
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
with open(os.path.join(OUT_DIR, "pool.json"), "w") as f:
|
||||
json.dump(rows, f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_DIR, "pairs.json"), "w") as f:
|
||||
json.dump([[c, a] for c, a in pairs], f)
|
||||
with open(os.path.join(OUT_DIR, "sparse_oof.json"), "w") as f:
|
||||
json.dump(sparse_oof, f)
|
||||
print(f"wrote {OUT_DIR}/{{pool,pairs,sparse_oof}}.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/router/semantic"
|
||||
)
|
||||
|
||||
// headsMain runs the deployed cascade minus the resident LLM: stage-0
|
||||
// grammars → routing heads (fine-tuned e5 copy + softmax, router_heads.onnx,
|
||||
// 0.6 decline threshold) → ONNX-embedder nearest-centroid classifier →
|
||||
// 0.55 confidence gate. This is what a production turn takes when the model
|
||||
// server is out (docs/routing.md: pickLLMRouter degrades to the classifier).
|
||||
//
|
||||
// The classifier is seeded from models/seeds like the daemon's seedClassifier,
|
||||
// embedded with the real multilingual-e5-small model rather than the block
|
||||
// hash, so this is the closest headless reproduction of the authoritative
|
||||
// router output the slice-22 report can run.
|
||||
//
|
||||
// Requires the ONNX model files and a libonnxruntime.so. Pass the library via
|
||||
// the MAVEN_ONNX_LIB environment variable, exactly as the daemon does.
|
||||
func headsMain(poolPath, outPath string) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
fmt.Fprintln(os.Stderr, "heads mode needs MAVEN_ONNX_LIB pointing at libonnxruntime.so")
|
||||
os.Exit(2)
|
||||
}
|
||||
const (
|
||||
embedModel = "models/embedder/multilingual-e5-small/model_quantized.onnx"
|
||||
tokPath = "models/embedder/multilingual-e5-small/tokenizer.json"
|
||||
headsModel = "models/embedder/router-heads/router_heads.onnx"
|
||||
)
|
||||
emb, err := router.NewONNXEmbedder(embedModel, tokPath, lib)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "heads: embedder: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cls := router.NewClassifier(emb)
|
||||
seedClassifier(cls)
|
||||
|
||||
heads, err := router.NewRouterHeads(headsModel, tokPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "heads: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer heads.Close()
|
||||
|
||||
acts := router.DefaultActMatcher{Fns: semantic.ExperimentActVerbs()}
|
||||
r := router.New(router.Config{
|
||||
Grammars: router.StageZeroGrammars(acts),
|
||||
Classifier: cls,
|
||||
Extractor: router.Extractor{
|
||||
Time: router.StubDateTimeParser{},
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
Threshold: 0.55,
|
||||
Heads: heads,
|
||||
})
|
||||
runOverPool(r, poolPath, outPath)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/router/semantic"
|
||||
)
|
||||
|
||||
// Seed loading replicated from cmd/mavend/voicewire.go (seedClassifier) and
|
||||
// internal/router/semantic/helpers_test.go, which build the same classifier
|
||||
// from models/seeds/<intent>.txt. The daemon and the eval fixture must agree
|
||||
// on the seeds; so must a measurement.
|
||||
const seedDir = "models/seeds"
|
||||
|
||||
var seedIntents = []router.Intent{
|
||||
router.IntentAct, router.IntentReminder, router.IntentFact,
|
||||
router.IntentNote, router.IntentQuery, router.IntentChat, router.IntentSystem,
|
||||
}
|
||||
|
||||
// buildMinimalRouter reproduces internal/router/semantic/buildMinimalRouter:
|
||||
// the daemon's grammar set, a hash-embedder classifier seeded from
|
||||
// models/seeds, and the deployed 0.55 threshold. Deterministic and
|
||||
// reproducible. The ONNX embedder and the routing heads score elsewhere;
|
||||
// this is the floor the eval fixture reports as the legacy baseline.
|
||||
func buildMinimalRouter() *router.Router {
|
||||
acts := router.DefaultActMatcher{Fns: semantic.ExperimentActVerbs()}
|
||||
cls := router.NewClassifier(router.NewHashEmbedder(1024))
|
||||
seedClassifier(cls)
|
||||
return router.New(router.Config{
|
||||
Grammars: router.StageZeroGrammars(acts),
|
||||
Classifier: cls,
|
||||
Extractor: router.Extractor{
|
||||
Time: router.StubDateTimeParser{},
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
}
|
||||
|
||||
func seedClassifier(c *router.Classifier) {
|
||||
// Walk up to find models/seeds like the daemon's seedPath, so the program
|
||||
// can run from any depth of the repo tree.
|
||||
dir := seedDir
|
||||
for i := 0; i < 5; i++ {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
break
|
||||
}
|
||||
dir = filepath.Join("..", dir)
|
||||
}
|
||||
ctx := context.Background()
|
||||
total := 0
|
||||
for _, intent := range seedIntents {
|
||||
path := filepath.Join(dir, string(intent)+".txt")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Printf("legacy: open seed %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
lines := []string{}
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
f.Close()
|
||||
sort.Strings(lines)
|
||||
for _, line := range lines {
|
||||
if err := c.AddExample(ctx, intent, line); err != nil {
|
||||
log.Printf("legacy: seed %s %q: %v", intent, line, err)
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
}
|
||||
log.Printf("legacy: loaded %d seed examples from %s", total, dir)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Legacy-baseline runner for slice 22: run the actual router cascade (stage 0
|
||||
// grammars → hash-embedder nearest-centroid classifier → 0.55 confidence gate)
|
||||
// over the frozen residual non-action dev pool and project each decision into
|
||||
// the five-way non-action semantic space.
|
||||
//
|
||||
// Projection rules (the daemon's behaviour, not just ScoreLegacy's):
|
||||
// - route error → uncertain
|
||||
// - Clarify=true (stage 3) → uncertain: the daemon asks, it does not commit
|
||||
// to a semantic bucket
|
||||
// - chat/query/fact+note/system → conversation/knowledge/memory_write/system
|
||||
// - act/reminder on a trusted non-action row → class "action" recorded
|
||||
// VERBATIM with illegal_action_prediction=true; never mapped to uncertain
|
||||
// - anything else → uncertain
|
||||
//
|
||||
// Reads /tmp/mvn-s22/pool.json (emit step) and writes /tmp/mvn-s22/legacy.json
|
||||
// with both the raw decision fields and the projected class, plus a summary
|
||||
// printout. No embedding is recomputed and no label is changed.
|
||||
|
||||
type poolRow struct {
|
||||
IDX int `json:"idx"`
|
||||
Text string `json:"text"`
|
||||
NText string `json:"n_text"`
|
||||
Route string `json:"route"`
|
||||
Tags []string `json:"tags"`
|
||||
CVFold int `json:"cv_fold"`
|
||||
SplitGroup string `json:"split_group"`
|
||||
FamilyID string `json:"family_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
type legacyRow struct {
|
||||
IDX int `json:"idx"`
|
||||
Text string `json:"text"`
|
||||
Route string `json:"route"`
|
||||
Intent string `json:"intent"`
|
||||
Class string `json:"class"`
|
||||
Illegal bool `json:"illegal_action_prediction"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Stage int `json:"stage"`
|
||||
Clarify bool `json:"clarify"`
|
||||
Producer string `json:"producer"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var mode, poolPath, outPath string
|
||||
flag.StringVar(&mode, "mode", "legacy", "baseline mode: legacy (hash classifier) or heads (ONNX cascade minus LLM)")
|
||||
flag.StringVar(&poolPath, "pool", "/tmp/mvn-s22/pool.json", "emit-step pool.json")
|
||||
flag.StringVar(&outPath, "out", "/tmp/mvn-s22/legacy.json", "output path")
|
||||
flag.Parse()
|
||||
switch mode {
|
||||
case "legacy":
|
||||
legacyMain(poolPath, outPath)
|
||||
case "heads":
|
||||
headsMain(poolPath, outPath)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown -mode %q\n", mode)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyMain(poolPath, outPath string) {
|
||||
runOverPool(buildMinimalRouter(), poolPath, outPath)
|
||||
}
|
||||
|
||||
func runOverPool(r *router.Router, poolPath, outPath string) {
|
||||
raw, err := os.ReadFile(poolPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var rows []poolRow
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
out := make([]legacyRow, 0, len(rows))
|
||||
classCount := map[string]int{}
|
||||
for _, pr := range rows {
|
||||
d, err := r.Route(ctx, router.NormalizedInput{Text: pr.Text}, now)
|
||||
lr := legacyRow{
|
||||
IDX: pr.IDX,
|
||||
Text: pr.Text,
|
||||
Route: pr.Route,
|
||||
SourceID: pr.SourceID,
|
||||
}
|
||||
if err != nil {
|
||||
lr.Class = "uncertain"
|
||||
lr.Error = err.Error()
|
||||
} else {
|
||||
lr.Intent = string(d.Intent)
|
||||
lr.Confidence = d.Confidence
|
||||
lr.Stage = d.Stage
|
||||
lr.Clarify = d.Clarify
|
||||
lr.Producer = string(d.Producer)
|
||||
}
|
||||
lr.Class, lr.Illegal = project(d, err)
|
||||
classCount[lr.Class]++
|
||||
out = append(out, lr)
|
||||
}
|
||||
|
||||
if err := writeJSON(outPath, out); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("legacy baseline over %d residual non-action rows:\n", len(out))
|
||||
for _, c := range []string{"conversation", "knowledge", "memory_write", "system", "uncertain", "action"} {
|
||||
fmt.Printf(" %-14s %d (%.1f%%)\n", c, classCount[c], 100*float64(classCount[c])/float64(len(out)))
|
||||
}
|
||||
fmt.Printf(" illegal_action_prediction: %d\n", classCount["action"])
|
||||
// Grammar hits inside a corpus-residual population would be a
|
||||
// corpus/harness disagreement worth telling the report about: the corpus
|
||||
// marked each row not-fast-path-resolved, so a current stage-0 rule
|
||||
// resolving it means the corpus's fast-path mirror is stale or a grammar
|
||||
// landed after the corpus froze.
|
||||
gh := 0
|
||||
ghByRoute := map[string]int{}
|
||||
ghByIntent := map[string]int{}
|
||||
for _, lr := range out {
|
||||
if lr.Producer == string(router.RouteProducerGrammar) {
|
||||
gh++
|
||||
ghByRoute[lr.Route]++
|
||||
ghByIntent[lr.Intent]++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" stage-0 grammar hits: %d\n", gh)
|
||||
if gh > 0 {
|
||||
fmt.Printf(" by ground-truth route: %v\n", ghByRoute)
|
||||
fmt.Printf(" by grammar intent: %v\n", ghByIntent)
|
||||
}
|
||||
}
|
||||
|
||||
// project maps the router's authoritative output into the five-way non-action
|
||||
// space, or to the "action" bucket verbatim when the router calls an act or a
|
||||
// reminder on a non-action row.
|
||||
func project(d router.Decision, err error) (string, bool) {
|
||||
if err != nil {
|
||||
return "uncertain", false
|
||||
}
|
||||
if d.Clarify {
|
||||
return "uncertain", false
|
||||
}
|
||||
switch d.Intent {
|
||||
case router.IntentChat:
|
||||
return "conversation", false
|
||||
case router.IntentQuery:
|
||||
return "knowledge", false
|
||||
case router.IntentFact, router.IntentNote:
|
||||
return "memory_write", false
|
||||
case router.IntentSystem:
|
||||
return "system", false
|
||||
case router.IntentAct, router.IntentReminder:
|
||||
return "action", true
|
||||
default:
|
||||
return "uncertain", false
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) error {
|
||||
fh, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
w := bufio.NewWriter(fh)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 22 emit: five-way residual non-action semantic router — data files
|
||||
========================================================================
|
||||
|
||||
Slice 21 accepted the deterministic execution-frame guard (docs/evals/
|
||||
2026-09-07-execution-frame-guard.md). Slice 22 returns to the coarse non-action
|
||||
router that the guard hands to: after TryFastPath misses and the guard passes,
|
||||
the remaining utterance is one of five non-action semantics — conversation,
|
||||
knowledge, memory_write, system, uncertain. Action rows never reach this
|
||||
router; they are usable only as out-of-domain probes, never in primary metrics.
|
||||
|
||||
This script only repackages the frozen dev pool for the Go legacy baseline and
|
||||
the Python experiment. It reuses slice 18's loader/filters and slice 19's
|
||||
normalizers verbatim, so the population here is the same one slices 18-21
|
||||
measured. It writes:
|
||||
|
||||
/tmp/mvn-s22/pool.json residual non-action dev rows: idx, text, n_text,
|
||||
route, tags, cv_fold, split_group, family_id,
|
||||
source_id (1652 rows)
|
||||
/tmp/mvn-s22/ood.json residual ACTION dev rows (766): same shape; OOD
|
||||
probes only, never primary metrics
|
||||
/tmp/mvn-s22/stats.json population summary (routes, families, folds)
|
||||
|
||||
idx is the row's position among dev_pool rows in dev-pool order, so the Python
|
||||
experiment can align the embedding vectors from /tmp/mvn-experiment/embeddings.json
|
||||
by index exactly as slice19.load_dev does.
|
||||
|
||||
No training happens here and no label is changed.
|
||||
|
||||
Population (verified 2026-09-08 from the frozen file):
|
||||
dev_pool 2490
|
||||
dev residual 2418 (= dev_pool minus fast_path_resolved)
|
||||
residual non-action 1652 knowledge 715 / memory_write 553 / system 184 /
|
||||
uncertain 107 / conversation 93
|
||||
residual action 766 (OOD probes only)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import slice18_sparse # noqa: E402 (normalize_match_text, load_data, filters)
|
||||
|
||||
OUT_DIR = "/tmp/mvn-s22"
|
||||
|
||||
ROUTES = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
|
||||
|
||||
def main():
|
||||
meta, examples = slice18_sparse.load_data()
|
||||
dev = slice18_sparse.filter_dev_pool(examples)
|
||||
print(f"dev pool: {len(dev)} rows "
|
||||
f"(meta declares dev_count={meta.get('dev_count')})")
|
||||
|
||||
rows = []
|
||||
for i, e in enumerate(dev):
|
||||
if not e["fast_path_resolved"]:
|
||||
rows.append({
|
||||
"idx": i,
|
||||
"text": e["text"],
|
||||
"n_text": slice18_sparse.normalize_match_text(e["text"]),
|
||||
"route": e["route"],
|
||||
"tags": sorted(set(e.get("tags", []))),
|
||||
"cv_fold": e["cv_fold"],
|
||||
"split_group": e["split_group"],
|
||||
"family_id": e["family_id"],
|
||||
"source_id": e["source_id"],
|
||||
})
|
||||
|
||||
na = [r for r in rows if r["route"] != "action"]
|
||||
ood = [r for r in rows if r["route"] == "action"]
|
||||
print(f"residual rows: {len(rows)} non-action: {len(na)} action(OOD): {len(ood)}")
|
||||
|
||||
by_route = {}
|
||||
for r in na:
|
||||
by_route[r["route"]] = by_route.get(r["route"], 0) + 1
|
||||
print("routes:", by_route)
|
||||
assert sum(by_route.values()) == len(na)
|
||||
assert set(ROUTES) == set(by_route), "route set must be the five-way"
|
||||
|
||||
by_family = {}
|
||||
for r in na:
|
||||
by_family[r["family_id"]] = by_family.get(r["family_id"], 0) + 1
|
||||
by_fold = {}
|
||||
for r in na:
|
||||
by_fold[r["cv_fold"]] = by_fold.get(r["cv_fold"], 0) + 1
|
||||
print(f"family_ids: {len(by_family)} split_groups: {len(set(r['split_group'] for r in na))}")
|
||||
print("folds:", by_fold)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
with open(os.path.join(OUT_DIR, "pool.json"), "w") as f:
|
||||
json.dump(na, f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_DIR, "ood.json"), "w") as f:
|
||||
json.dump(ood, f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_DIR, "stats.json"), "w") as f:
|
||||
json.dump({
|
||||
"dev_count": len(dev),
|
||||
"residual_count": len(rows),
|
||||
"non_action_count": len(na),
|
||||
"action_ood_count": len(ood),
|
||||
"routes": by_route,
|
||||
"family_ids": len(by_family),
|
||||
"folds": by_fold,
|
||||
"top_family": dict(sorted(by_family.items(), key=lambda kv: -kv[1])[:15]),
|
||||
}, f, ensure_ascii=False, indent=1)
|
||||
print(f"wrote {OUT_DIR}/{{pool,ood,stats}}.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 22: five-way residual non-action semantic router (experiment)
|
||||
==================================================================
|
||||
|
||||
After TryFastPath misses and the ExecutionFrameGuard passes, the residual
|
||||
utterance is one of five non-action semantics: conversation, knowledge,
|
||||
memory_write, system, uncertain. This measures whether the deployed e5-small
|
||||
embeddings (384-d, query-prefixed, mean-pooled, L2, frozen) fed to a linear
|
||||
softmax head suffice, and how they compare to the legacy router, to floors,
|
||||
and to the deployed routing heads.
|
||||
|
||||
Population: the frozen dev-pool residual non-action rows (1652; the pool
|
||||
written by slice22_emit.py). Action rows (766) are out-of-domain probes only.
|
||||
|
||||
Metrics written to /tmp/mvn-s22/results.json:
|
||||
§1 population
|
||||
§2 legacy baseline (legacy.json / legacy_heads.json): acc, macro-F1,
|
||||
per-class P/R/F1, confusion, illegal_action_prediction count
|
||||
§3 e5-linear primary head: C grid, grouped CV OOF, per-fold P/R/F1 +
|
||||
variance + composition
|
||||
§4 floors: majority, centroid (cosine nearest-mean), sparse word+char
|
||||
TF-IDF logistic (slice18 builder), all grouped CV
|
||||
§5 route-family (family_id) leave-family-out
|
||||
§6 knowledge vs memory_write: matched pairs (water/homelab/task) ordering
|
||||
§7 uncertain as an explicit class: P/R/F1 + top confusions
|
||||
§8 OOF confidence: max-softmax correct/wrong, ECE, log-loss, Brier,
|
||||
coverage/accuracy/macro-F1 abstention curves (no threshold chosen)
|
||||
§9 action OOD probes: fold models applied to the 766 action rows
|
||||
§10 artifact cost: head params, serialized bytes, incremental head latency
|
||||
|
||||
No corpus label is changed. No frozen-holdout rows are inspected.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import slice18_sparse # noqa: E402
|
||||
import slice19_main # noqa: E402
|
||||
|
||||
EMB_PATH = "/tmp/mvn-experiment/embeddings.json"
|
||||
OUT_DIR = "/tmp/mvn-s22"
|
||||
CLASSES = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
CLASS_PREFIX = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
C_GRID = [0.1, 1.0, 10.0]
|
||||
|
||||
# Route-family holdouts the report calls out by name (slice-22 brief): every
|
||||
# family that is not part of the shared subject inventory on either side.
|
||||
HOLDOUT_GROUPS = {
|
||||
"capability": ["knowledge:capability-ha", "knowledge:capability-tool"],
|
||||
"world": ["knowledge:world-def", "knowledge:world-explain"],
|
||||
"calendar": ["knowledge:calendar", "knowledge:calendar-time", "knowledge:calendar-next"],
|
||||
"recall": ["knowledge:recall-fact", "knowledge:recall-note", "knowledge:recall-possessive"],
|
||||
"fact": ["fact:meal", "fact:water", "fact:sleep", "fact:shower", "fact:break", "fact:pills", "fact:exercise"],
|
||||
"note": ["note:idea", "note:homelab", "note:task"],
|
||||
"remember": ["free:remember"],
|
||||
"system": None, # all system:*
|
||||
"conversation": None,
|
||||
"uncertain": None,
|
||||
}
|
||||
|
||||
|
||||
def load_pool_and_embeds():
|
||||
with open(os.path.join(OUT_DIR, "pool.json")) as f:
|
||||
pool = json.load(f)
|
||||
meta, examples = slice18_sparse.load_data()
|
||||
dev = slice18_sparse.filter_dev_pool(examples)
|
||||
by_idx = {e["dev_idx"]: e for e in dev} if "dev_idx" in dev[0] else None
|
||||
# pool rows carry idx = position among dev_pool rows in dev order
|
||||
emb_by_idx = {i: np.asarray(e["embedding"], dtype=np.float64)
|
||||
for i, e in enumerate(dev)}
|
||||
for r in pool:
|
||||
r["emb"] = emb_by_idx[r["idx"]]
|
||||
r["y"] = r["route"]
|
||||
return pool, meta
|
||||
|
||||
|
||||
def oof_proba_grouped(X, y, folds, C=1.0):
|
||||
"""Grouped OOF probability matrix (n×5, class order CLASSES)."""
|
||||
y_idx = np.array([CLASSES.index(c) for c in y])
|
||||
folds = np.asarray(folds)
|
||||
proba = np.zeros((len(y_idx), len(CLASSES)))
|
||||
for te_fold in sorted(set(folds.tolist())):
|
||||
tr = folds != te_fold
|
||||
te = folds == te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=C, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], y_idx[tr])
|
||||
proba[te] = clf.predict_proba(X[te])
|
||||
return proba
|
||||
|
||||
|
||||
def cls_metrics(yt, yp):
|
||||
import sklearn.metrics as m
|
||||
yt = np.asarray(yt)
|
||||
yp = np.asarray(yp)
|
||||
if yt.dtype != np.int64 and yt.dtype != np.int32:
|
||||
yt = np.array([CLASSES.index(c) for c in yt])
|
||||
if yp.dtype != np.int64 and yp.dtype != np.int32:
|
||||
yp = np.array([CLASSES.index(c) for c in yp])
|
||||
labels = list(range(len(CLASSES)))
|
||||
n = len(yt)
|
||||
acc = m.accuracy_score(yt, yp)
|
||||
macro = m.f1_score(yt, yp, average="macro", labels=labels, zero_division=0)
|
||||
pr, rc, f1, sup = m.precision_recall_fscore_support(
|
||||
yt, yp, labels=labels, zero_division=0)
|
||||
per = {c: {"p": float(pr[i]), "r": float(rc[i]), "f1": float(f1[i]), "n": int(sup[i])}
|
||||
for i, c in enumerate(CLASSES)}
|
||||
conf = m.confusion_matrix(yt, yp, labels=labels).tolist()
|
||||
return {"n": n, "acc": acc, "macro_f1": macro, "per_class": per, "confusion": conf}
|
||||
|
||||
|
||||
def fold_report(yt, proba, folds, true_y):
|
||||
out = {}
|
||||
folds_arr = np.asarray(folds)
|
||||
comp = {}
|
||||
for f in sorted(set(folds_arr.tolist())):
|
||||
mask = folds_arr == f
|
||||
yt_f = [CLASSES.index(y) for y in true_y[mask]]
|
||||
comp[f] = {c: int((np.array(true_y[mask]) == c).sum()) for c in CLASSES}
|
||||
per_fold = {}
|
||||
for f in sorted(set(folds_arr.tolist())):
|
||||
mask = folds_arr == f
|
||||
yp = proba[mask].argmax(1).tolist()
|
||||
m = cls_metrics([yt[i] for i in np.where(mask)[0].tolist()], yp)
|
||||
per_fold[f] = {"acc": m["acc"], "macro_f1": m["macro_f1"]}
|
||||
out["composition"] = comp
|
||||
out["per_fold"] = per_fold
|
||||
accs = [v["acc"] for v in per_fold.values()]
|
||||
macros = [v["macro_f1"] for v in per_fold.values()]
|
||||
out["acc_mean"] = float(np.mean(accs))
|
||||
out["acc_std"] = float(np.std(accs))
|
||||
out["macro_f1_mean"] = float(np.mean(macros))
|
||||
out["macro_f1_std"] = float(np.std(macros))
|
||||
return out
|
||||
|
||||
|
||||
def ece(yt, proba, n_bins=15):
|
||||
conf = proba.max(1)
|
||||
pred = proba.argmax(1)
|
||||
acc = (pred == yt).astype(float)
|
||||
bins = np.linspace(0, 1, n_bins + 1)
|
||||
tot = 0.0
|
||||
details = []
|
||||
counts = 0
|
||||
for i in range(n_bins):
|
||||
lo, hi = bins[i], bins[i + 1]
|
||||
m = (conf >= lo) & (conf < hi) if i < n_bins - 1 else conf >= lo
|
||||
if m.sum() == 0:
|
||||
continue
|
||||
acc_m = acc[m].mean()
|
||||
conf_m = conf[m].mean()
|
||||
w = m.sum() / len(conf)
|
||||
tot += w * abs(acc_m - conf_m)
|
||||
counts += int(m.sum())
|
||||
details.append({"bin": i, "lo": lo, "hi": hi, "conf": float(conf_m),
|
||||
"acc": float(acc_m), "n": int(m.sum())})
|
||||
return {"ece": float(tot), "n_bins": n_bins, "counted": counts, "bins": details}
|
||||
|
||||
|
||||
def main():
|
||||
pool, meta = load_pool_and_embeds()
|
||||
pool.sort(key=lambda r: r["idx"])
|
||||
print(f"pool: {len(pool)} rows")
|
||||
|
||||
from sklearn.metrics import brier_score_loss, log_loss
|
||||
|
||||
report = {"population": {}, "legacy": {}, "e5_linear": {}, "floors": {},
|
||||
"family_holdouts": {}, "kmw": {}, "uncertain": {}, "confidence": {},
|
||||
"ood": {}, "artifact": {}}
|
||||
|
||||
# ── §1 population ──────────────────────────────────────────────────────
|
||||
cnt = {}
|
||||
for r in pool:
|
||||
cnt[r["y"]] = cnt.get(r["y"], 0) + 1
|
||||
report["population"] = {
|
||||
"n": len(pool),
|
||||
"routes": cnt,
|
||||
"family_ids": len(set(r["family_id"] for r in pool)),
|
||||
"split_groups": len(set(r["split_group"] for r in pool)),
|
||||
"folds": {str(f): int(sum(1 for r in pool if r["cv_fold"] == f)) for f in sorted(set(r["cv_fold"] for r in pool))},
|
||||
"corpus": {k: v for k, v in meta.items() if k in
|
||||
("dev_count", "residual_count", "fast_path_count",
|
||||
"dimension", "embedder_id", "input_template", "pooling", "normalization")},
|
||||
}
|
||||
print("\n§1 population:", report["population"])
|
||||
|
||||
X = np.vstack([r["emb"] for r in pool])
|
||||
y = np.array([r["y"] for r in pool])
|
||||
folds = np.array([r["cv_fold"] for r in pool])
|
||||
yt = np.array([CLASSES.index(c) for c in y])
|
||||
|
||||
# ── §2 legacy baselines ────────────────────────────────────────────────
|
||||
import collections
|
||||
for tag, fname in [("hash", "legacy.json"), ("heads", "legacy_heads.json")]:
|
||||
path = os.path.join(OUT_DIR, fname)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
leg = json.load(open(path))
|
||||
leg_by_idx = {r["idx"]: r for r in leg}
|
||||
yp_leg = []
|
||||
illegal = []
|
||||
for r in pool:
|
||||
lr = leg_by_idx[r["idx"]]
|
||||
if lr["illegal_action_prediction"]:
|
||||
illegal.append(lr)
|
||||
yp_leg.append("action")
|
||||
else:
|
||||
yp_leg.append(lr["class"])
|
||||
yp_leg = np.array(yp_leg)
|
||||
# five-way: an 'action' prediction is an error (outside the label set)
|
||||
yp5 = np.array([("uncertain" if p == "action" else p) for p in yp_leg])
|
||||
m = cls_metrics(y, yp5)
|
||||
m["illegal_action_prediction"] = len(illegal)
|
||||
m["illegal_cases"] = [{"idx": i["idx"], "text": i["text"], "route": i["route"],
|
||||
"intent": i["intent"], "producer": i["producer"],
|
||||
"confidence": i["confidence"]} for i in illegal]
|
||||
# per-cell confusion also shows 'action' column
|
||||
conf_counts = collections.Counter(zip(y, yp_leg))
|
||||
m["confusion_with_action"] = {f"{a}->{b}": int(c) for (a, b), c in conf_counts.items()}
|
||||
report["legacy"][tag] = m
|
||||
print(f"\n§2 legacy ({tag}) acc={m['acc']:.4f} macroF1={m['macro_f1']:.4f} "
|
||||
f"illegal={len(illegal)}")
|
||||
for c in CLASSES:
|
||||
p = m["per_class"][c]
|
||||
print(f" {c:<14} P={p['p']:.3f} R={p['r']:.3f} F1={p['f1']:.3f} n={p['n']}")
|
||||
|
||||
# grammar-pure residual: rows not resolved by any current stage-0 grammar
|
||||
if os.path.exists(os.path.join(OUT_DIR, "legacy.json")):
|
||||
leg = json.load(open(os.path.join(OUT_DIR, "legacy.json")))
|
||||
gh = {r["idx"] for r in leg if r["producer"] == "grammar"}
|
||||
gp_mask = np.array([r["idx"] not in gh for r in pool])
|
||||
report["grammar_drift"] = {
|
||||
"grammar_hits_in_pool": len(gh),
|
||||
"grammar_pure_n": int(gp_mask.sum()),
|
||||
}
|
||||
|
||||
# ── §3 e5-linear primary head ─────────────────────────────────────────
|
||||
print("\n§3 e5-linear")
|
||||
bestC, bestMac = 1.0, -1.0
|
||||
grid = {}
|
||||
for C in C_GRID:
|
||||
p = oof_proba_grouped(X, y, folds, C=C)
|
||||
mp = cls_metrics(y, p.argmax(1).tolist())
|
||||
grid[float(C)] = {"acc": mp["acc"], "macro_f1": mp["macro_f1"]}
|
||||
print(f" C={C} acc={mp['acc']:.4f} macroF1={mp['macro_f1']:.4f}")
|
||||
if mp["macro_f1"] > bestMac:
|
||||
bestMac, bestC = mp["macro_f1"], C
|
||||
print(f" -> best C={bestC}")
|
||||
p_best = oof_proba_grouped(X, y, folds, C=bestC)
|
||||
m_best = cls_metrics(y, p_best.argmax(1).tolist())
|
||||
m_best["C"] = bestC
|
||||
m_best["C_grid"] = grid
|
||||
m_best["folds"] = fold_report(yt, p_best, folds, y)
|
||||
report["e5_linear"] = m_best
|
||||
for f, v in m_best["folds"]["per_fold"].items():
|
||||
print(f" fold {f}: acc={v['acc']:.4f} macroF1={v['macro_f1']:.4f}")
|
||||
print(f" fold acc mean={m_best['folds']['acc_mean']:.4f} "
|
||||
f"std={m_best['folds']['acc_std']:.4f}; "
|
||||
f"macroF1 mean={m_best['folds']['macro_f1_mean']:.4f} "
|
||||
f"std={m_best['folds']['macro_f1_std']:.4f}")
|
||||
for c in CLASSES:
|
||||
p_ = m_best["per_class"][c]
|
||||
print(f" {c:<14} P={p_['p']:.3f} R={p_['r']:.3f} F1={p_['f1']:.3f} n={p_['n']}")
|
||||
|
||||
# grammar-pure sensitivity for the primary head
|
||||
if "grammar_drift" in report:
|
||||
mp_gp = cls_metrics(y[gp_mask], p_best[gp_mask].argmax(1).tolist())
|
||||
report["e5_linear"]["grammar_pure"] = {
|
||||
"acc": mp_gp["acc"], "macro_f1": mp_gp["macro_f1"], "n": int(gp_mask.sum())}
|
||||
|
||||
# ── §4 floors ─────────────────────────────────────────────────────────
|
||||
print("\n§4 floors")
|
||||
# majority floor
|
||||
maj = CLASSES.index("knowledge")
|
||||
ym = np.full(len(y), maj)
|
||||
mm = cls_metrics(y, ym)
|
||||
report["floors"]["majority"] = {"acc": mm["acc"], "macro_f1": mm["macro_f1"],
|
||||
"per_class": mm["per_class"]}
|
||||
print(f" majority (predict {CLASSES[maj]}): acc={mm['acc']:.4f} macroF1={mm['macro_f1']:.4f}")
|
||||
|
||||
# centroid floor: cosine to per-class mean of the training folds' embeddings
|
||||
cf_proba = np.zeros((len(yt), len(CLASSES)))
|
||||
folds_arr = np.asarray(folds)
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
centroids = []
|
||||
for c in CLASSES:
|
||||
idxs = np.where(tr & (y == c))[0]
|
||||
ctr = X[idxs].mean(axis=0)
|
||||
ctr = ctr / np.linalg.norm(ctr)
|
||||
centroids.append(ctr)
|
||||
Cm = np.vstack(centroids)
|
||||
sims = X[te] @ Cm.T
|
||||
cf_proba[te] = sims
|
||||
yc = cf_proba.argmax(1)
|
||||
# accuracy + macroF1 with the same 5-way
|
||||
mc = cls_metrics(y, yc.tolist())
|
||||
report["floors"]["centroid"] = {"acc": mc["acc"], "macro_f1": mc["macro_f1"],
|
||||
"per_class": mc["per_class"]}
|
||||
print(f" centroid cosine: acc={mc['acc']:.4f} macroF1={mc['macro_f1']:.4f}")
|
||||
|
||||
# sparse word+char logistic (slice18 builder, grouped CV, five-way)
|
||||
texts = [r["n_text"] for r in pool]
|
||||
Xs, _vec = slice18_sparse.build_features(texts, "both")
|
||||
psp = np.zeros((len(yt), len(CLASSES)))
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(Xs[tr], yt[tr])
|
||||
psp[te] = clf.predict_proba(Xs[te])
|
||||
msp = cls_metrics(y, psp.argmax(1).tolist())
|
||||
report["floors"]["sparse_word_char"] = {
|
||||
"acc": msp["acc"], "macro_f1": msp["macro_f1"], "per_class": msp["per_class"],
|
||||
"vocab": slice18_sparse.vocab_size(_vec)}
|
||||
print(f" sparse both: acc={msp['acc']:.4f} macroF1={msp['macro_f1']:.4f} "
|
||||
f"vocab={report['floors']['sparse_word_char']['vocab']}")
|
||||
|
||||
# ── §5 route-family holdouts ─────────────────────────────────────────
|
||||
print("\n§5 route-family holdouts")
|
||||
fam = np.array([r["family_id"] for r in pool])
|
||||
holdouts = {}
|
||||
all_fams = sorted(set(fam.tolist()))
|
||||
for grp, fams in HOLDOUT_GROUPS.items():
|
||||
if fams is None:
|
||||
fams = [f for f in all_fams if f.startswith(grp + ":")]
|
||||
mask = np.isin(fam, fams)
|
||||
if mask.sum() == 0:
|
||||
continue
|
||||
tr = ~mask
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
ypgrp = clf.predict(X[mask])
|
||||
m = cls_metrics([CLASSES.index(c) for c in y[mask]], ypgrp.tolist())
|
||||
m["families"] = fams
|
||||
m["rows"] = int(mask.sum())
|
||||
holdouts[grp] = {"acc": m["acc"], "macro_f1": m["macro_f1"], "n": int(mask.sum()),
|
||||
"per_class": m["per_class"]}
|
||||
print(f" {grp:<14} n={m['rows']} acc={m['acc']:.4f} macroF1={m['macro_f1']:.4f}")
|
||||
# full leave-one-family-out summary
|
||||
lofo_accs = []
|
||||
lofo_f1s = []
|
||||
for f in all_fams:
|
||||
mask = fam == f
|
||||
tr = ~mask
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
ypf = clf.predict(X[mask])
|
||||
m = cls_metrics([CLASSES.index(c) for c in y[mask]], ypf.tolist())
|
||||
lofo_accs.append(m["acc"])
|
||||
lofo_f1s.append(m["macro_f1"])
|
||||
holdouts["_all_49_lo_"] = {"n_families": len(all_fams),
|
||||
"acc_mean": float(np.mean(lofo_accs)),
|
||||
"macro_f1_mean": float(np.mean(lofo_f1s))}
|
||||
report["family_holdouts"] = holdouts
|
||||
print(f" leave-one-family-out over {len(all_fams)} families: "
|
||||
f"acc mean={np.mean(lofo_accs):.4f} macroF1 mean={np.mean(lofo_f1s):.4f}")
|
||||
|
||||
# ── §6 knowledge vs memory_write ─────────────────────────────────────
|
||||
print("\n§6 knowledge vs memory_write")
|
||||
# reuse e5-linear OOF: does the model put the higher probability on the
|
||||
# right side (memory_write for a write, knowledge for a recall)?
|
||||
conf_km = np.zeros((2, 2))
|
||||
pk = p_best[:, CLASSES.index("knowledge")]
|
||||
pmw = p_best[:, CLASSES.index("memory_write")]
|
||||
for i in range(len(yt)):
|
||||
t = y[i]
|
||||
if t == "knowledge":
|
||||
conf_km[0, 1 if pmw[i] > pk[i] else 0] += 1
|
||||
elif t == "memory_write":
|
||||
conf_km[1, 1 if pmw[i] >= pk[i] else 0] += 1
|
||||
report["kmw"] = {"confusion_p_ordered": conf_km.tolist()}
|
||||
|
||||
# matched pairs with shared subject lexemes, corpus-justified
|
||||
def build_pairs(subject, fam_k, fam_mw):
|
||||
kr = [r for r in pool if r["family_id"] in fam_k]
|
||||
mr = [r for r in pool if r["family_id"] in fam_mw]
|
||||
pairs = []
|
||||
for mw in mr:
|
||||
for k in kr:
|
||||
if subject in mw["n_text"] and subject in k["n_text"]:
|
||||
pairs.append((mw["idx"], k["idx"], mw["n_text"], k["n_text"]))
|
||||
return pairs
|
||||
|
||||
sets = {
|
||||
"water": build_pairs("вод", ["knowledge:recall-fact"], ["fact:water"]),
|
||||
"homelab": build_pairs("dns", ["knowledge:homelab-status"], ["note:homelab"])
|
||||
+ build_pairs("сервер", ["knowledge:homelab-status"], ["note:homelab"])
|
||||
+ build_pairs("vlan", ["knowledge:homelab-status"], ["note:homelab"]),
|
||||
"task": build_pairs("задач", ["knowledge:task-check", "knowledge:deadline"],
|
||||
["note:task"]),
|
||||
}
|
||||
idx_of = {r["idx"]: i for i, r in enumerate(pool)}
|
||||
pair_rep = {}
|
||||
for name, pairs in sets.items():
|
||||
if not pairs:
|
||||
continue
|
||||
ok = 0
|
||||
margins = []
|
||||
bad = []
|
||||
for mi, ki, mx, kx in pairs:
|
||||
mi_i, ki_i = idx_of[mi], idx_of[ki]
|
||||
# MW row should get a higher memory_write probability than the K row
|
||||
mk = (pmw[mi_i] + 0.0)
|
||||
if pmw[mi_i] > pmw[ki_i]:
|
||||
ok += 1
|
||||
else:
|
||||
bad.append((mx[:46], round(float(pmw[mi_i]), 3), kx[:46], round(float(pmw[ki_i]), 3)))
|
||||
margins.append(pmw[mi_i] - pmw[ki_i])
|
||||
pair_rep[name] = {
|
||||
"pairs": len(pairs),
|
||||
"mw_over_k_order_acc": ok / len(pairs),
|
||||
"mean_margin": float(np.mean(margins)),
|
||||
"reversed_examples": bad[:6],
|
||||
}
|
||||
print(f" {name}: pairs={len(pairs)} order_acc={ok/len(pairs):.3f} "
|
||||
f"mean_margin={np.mean(margins):+.3f}")
|
||||
report["kmw"]["matched_pairs"] = pair_rep
|
||||
|
||||
# ── §7 uncertain as explicit class ────────────────────────────────────
|
||||
print("\n§7 uncertain")
|
||||
up = m_best["per_class"]["uncertain"]
|
||||
uc = m_best["confusion"][CLASSES.index("uncertain")]
|
||||
report["uncertain"] = {
|
||||
"per_class": up,
|
||||
"row_from_uncertain": {CLASSES[j]: int(uc[j]) for j in range(5)},
|
||||
"row_to_uncertain": {CLASSES[j]: int(m_best["confusion"][j][CLASSES.index("uncertain")])
|
||||
for j in range(5)},
|
||||
}
|
||||
print(f" uncertain n={up['n']} P={up['p']:.3f} R={up['r']:.3f} F1={up['f1']:.3f}")
|
||||
print(" wrong-→label pulled from uncertain:", report["uncertain"]["row_from_uncertain"])
|
||||
print(" →uncertain pulled from:", report["uncertain"]["row_to_uncertain"])
|
||||
|
||||
# ── §8 OOF confidence / calibration / abstention ─────────────────────
|
||||
print("\n§8 confidence / calibration")
|
||||
conf = p_best.max(1)
|
||||
right = (p_best.argmax(1) == yt)
|
||||
cer = {
|
||||
"correct_conf_mean": float(conf[right].mean()),
|
||||
"correct_conf_median": float(np.median(conf[right])),
|
||||
"wrong_conf_mean": float(conf[~right].mean()),
|
||||
"wrong_conf_median": float(np.median(conf[~right])),
|
||||
"ece": ece(yt, p_best)["ece"],
|
||||
"ece_bins": ece(yt, p_best)["bins"],
|
||||
"log_loss": float(log_loss(yt, p_best, labels=[0, 1, 2, 3, 4])),
|
||||
}
|
||||
# Brier is label-set specific: one-vs-rest mean
|
||||
briers = []
|
||||
for i in range(5):
|
||||
briers.append(brier_score_loss((yt == i).astype(int), p_best[:, i]))
|
||||
cer["brier_macro"] = float(np.mean(briers))
|
||||
report["confidence"] = cer
|
||||
print(f" right conf mean={cer['correct_conf_mean']:.3f} "
|
||||
f"wrong conf mean={cer['wrong_conf_mean']:.3f} ECE={cer['ece']:.4f}")
|
||||
print(f" log_loss={cer['log_loss']:.4f} brier_macro={cer['brier_macro']:.4f}")
|
||||
|
||||
thr_grid = np.linspace(0.10, 0.98, 45)
|
||||
abst = []
|
||||
for t in thr_grid:
|
||||
cov = (conf >= t).mean()
|
||||
if cov == 0:
|
||||
continue
|
||||
keep = conf >= t
|
||||
yt_k = yt[keep]
|
||||
yp_k = p_best[keep].argmax(1)
|
||||
mk_ = cls_metrics(yt_k.tolist(), yp_k.tolist())
|
||||
abst.append({"threshold": round(float(t), 3), "coverage": float(cov),
|
||||
"accuracy": mk_["acc"], "macro_f1": mk_["macro_f1"]})
|
||||
report["confidence"]["abstention_curve"] = abst
|
||||
print(" threshold | coverage | accuracy | macroF1 (first 6/45 + knee)")
|
||||
for row in abst[::9]:
|
||||
print(f" {row['threshold']:.2f} | {row['coverage']:.3f} | "
|
||||
f"{row['accuracy']:.3f} | {row['macro_f1']:.3f}")
|
||||
|
||||
# ── §9 action OOD probes ──────────────────────────────────────────────
|
||||
print("\n§9 action OOD")
|
||||
ood_rows = [r for r in json.load(open(os.path.join(OUT_DIR, "ood.json")))]
|
||||
emb_by_idx = {i: np.asarray(e["embedding"], dtype=np.float64)
|
||||
for i, e in enumerate(slice18_sparse.filter_dev_pool(
|
||||
slice18_sparse.load_data()[1]))}
|
||||
Xo = np.vstack([emb_by_idx[r["idx"]] for r in ood_rows])
|
||||
fold_models = []
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
fold_models.append(clf)
|
||||
# OOD rows are not in folds; use the full-train model to keep it simple and
|
||||
# comparable to the non-action in-fold behaviour
|
||||
po = np.zeros((len(Xo), 5))
|
||||
for clf in fold_models:
|
||||
po += clf.predict_proba(Xo)
|
||||
po /= len(fold_models)
|
||||
ood_top = int(np.argmax(po.mean(0)))
|
||||
ood_conf = po.max(1)
|
||||
ood_pred = po.argmax(1)
|
||||
top_dist = {CLASSES[i]: int((ood_pred == i).sum()) for i in range(5)}
|
||||
confident_na = int((ood_conf > 0.9).sum())
|
||||
report["ood"] = {
|
||||
"n": len(ood_rows),
|
||||
"top_class": CLASSES[int(ood_top)],
|
||||
"top_class_dist": top_dist,
|
||||
"conf_gt_0.9": confident_na,
|
||||
"conf_gt_0.9_frac": float(confident_na / len(ood_rows)),
|
||||
"conf_mean": float(ood_conf.mean()),
|
||||
"conf_median": float(np.median(ood_conf)),
|
||||
}
|
||||
print(f" action OOD n={len(ood_rows)}: most-confident class={report['ood']['top_class']} "
|
||||
f"dist={top_dist}")
|
||||
print(f" conf>0.9: {confident_na} ({confident_na/len(ood_rows):.3f}) "
|
||||
f"conf mean={report['ood']['conf_mean']:.3f}")
|
||||
|
||||
# ── §10 artifact cost ────────────────────────────────────────────────
|
||||
print("\n§10 artifact")
|
||||
n_params = len(CLASSES) * X.shape[1] + len(CLASSES)
|
||||
fp32 = n_params * 4
|
||||
report["artifact"] = {
|
||||
"e5_dim": X.shape[1],
|
||||
"head_params": n_params,
|
||||
"head_fp32_bytes": fp32,
|
||||
"head_fp32_kib": fp32 / 1024,
|
||||
"head_int8_bytes": n_params,
|
||||
}
|
||||
# incremental latency of the linear head over a batch of 1 (µs)
|
||||
clf = slice18_sparse.LogisticRegression(C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X, yt)
|
||||
x1 = X[:1]
|
||||
for _ in range(50):
|
||||
clf.predict_proba(x1)
|
||||
lat = []
|
||||
for _ in range(2000):
|
||||
t0 = time.perf_counter_ns()
|
||||
clf.predict_proba(x1)
|
||||
lat.append((time.perf_counter_ns() - t0) / 1e3)
|
||||
lat = np.array(lat)
|
||||
report["artifact"]["head_latency_us_mean"] = float(lat.mean())
|
||||
report["artifact"]["head_latency_us_p50"] = float(np.median(lat))
|
||||
print(f" head params={n_params} fp32={fp32/1024:.2f}KiB "
|
||||
f"lat mean={lat.mean():.2f}us p50={np.median(lat):.2f}us")
|
||||
|
||||
with open(os.path.join(OUT_DIR, "results.json"), "w") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=1, default=float)
|
||||
print(f"\nwrote {OUT_DIR}/results.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
// slice23 — fast-path metadata reconciliation diagnostic.
|
||||
//
|
||||
// Classifies every disagreement between the corpus's stored fast_path_resolved
|
||||
// flag and what the production fast path derives today (TryFastPath over the
|
||||
// stage-0 grammars with the experiment's act allowlist). Outputs a JSON
|
||||
// decomposition and a console summary for docs/evals reports.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/semantic-router-experiment/slice23/ -out /tmp/mvn-s23/drift.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/router/semantic"
|
||||
)
|
||||
|
||||
type conflict struct {
|
||||
Text string `json:"text"`
|
||||
SourceID string `json:"source_id"`
|
||||
Route string `json:"route"`
|
||||
Source string `json:"source"`
|
||||
Group string `json:"split_group"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Dev bool `json:"dev"`
|
||||
// Direction: claimed_fast_now_miss = stored fast, runtime residual;
|
||||
// mirror_missed = stored residual, runtime fast.
|
||||
Direction string `json:"direction"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
// ShapeDeclined is true when at least one stage-0 grammar matched the
|
||||
// utterance's shape but refused the content (falls through like the router).
|
||||
ShapeDeclined bool `json:"shape_declined"`
|
||||
// DeclinedGrammars names every stage-0 grammar that matched the shape but
|
||||
// declined the content, for claimed_fast_now_miss rows.
|
||||
DeclinedGrammars []string `json:"declined_grammars,omitempty"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Meta metaSummary `json:"meta"`
|
||||
Pop popSummary `json:"population"`
|
||||
Conflicts []conflict `json:"conflicts"`
|
||||
ByRoute map[string]map[string]int `json:"by_route"`
|
||||
ByGrammar map[string]int `json:"by_grammar"`
|
||||
BySource map[string]map[string]int `json:"by_source"`
|
||||
ByFamily map[string]map[string]int `json:"by_family"`
|
||||
Direction map[string]int `json:"by_direction"`
|
||||
Declined int `json:"claimed_fast_with_declined_shape"`
|
||||
NoShape int `json:"claimed_fast_with_no_shape"`
|
||||
}
|
||||
|
||||
type metaSummary struct {
|
||||
Total int `json:"total"`
|
||||
DevCount int `json:"dev_count"`
|
||||
Frozen int `json:"frozen_count"`
|
||||
}
|
||||
|
||||
type popSummary struct {
|
||||
StoredFast int `json:"stored_fast"`
|
||||
StoredResid int `json:"stored_residual"`
|
||||
DerivedFast int `json:"derived_fast"`
|
||||
DerivedResid int `json:"derived_residual"`
|
||||
// Dev-pool residual route counts derived as the router sees them today.
|
||||
DevResidualByRoute map[string]int `json:"dev_residual_by_route"`
|
||||
// Dev-pool residual non-action + action OOD as derived.
|
||||
DevResidualNonAction int `json:"dev_residual_non_action"`
|
||||
DevResidualAction int `json:"dev_residual_action"`
|
||||
// Fast rows in the dev pool, derived.
|
||||
DevFast int `json:"dev_fast"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
outPath := flag.String("out", "/tmp/mvn-s23/drift.json", "output JSON path")
|
||||
flag.Parse()
|
||||
|
||||
exs, err := semantic.LoadCorpus()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load corpus: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, dev, _ := semantic.FrozenHoldoutSplit(exs)
|
||||
devSet := make(map[string]bool, len(dev))
|
||||
for _, e := range dev {
|
||||
devSet[e.SourceID] = true
|
||||
}
|
||||
|
||||
// Stage-zero grammar list for shape/declined attribution (same list the
|
||||
// derivation walks).
|
||||
acts := router.DefaultActMatcher{Fns: semantic.ExperimentActVerbs()}
|
||||
gs := router.StageZeroGrammars(acts)
|
||||
|
||||
var (
|
||||
conflicts []conflict
|
||||
byRoute = map[string]map[string]int{}
|
||||
byGrammar = map[string]int{}
|
||||
bySource = map[string]map[string]int{}
|
||||
byFamily = map[string]map[string]int{}
|
||||
byDirection = map[string]int{}
|
||||
storedFast, derivedFast, declined, noShape int
|
||||
devResidByRoute = map[string]int{}
|
||||
devResidNonAct, devResidAct, devFast int
|
||||
)
|
||||
|
||||
for _, e := range exs {
|
||||
o := semantic.DeriveFastPath(e.Text)
|
||||
inDev := devSet[e.SourceID]
|
||||
|
||||
st := e.FastPathResolved
|
||||
if st {
|
||||
storedFast++
|
||||
}
|
||||
if o.Matched {
|
||||
derivedFast++
|
||||
}
|
||||
|
||||
var c *conflict
|
||||
switch {
|
||||
case st && o.Matched:
|
||||
case st && !o.Matched:
|
||||
// Stored fast but the runtime misses. Attribute why.
|
||||
shapeDeclined := false
|
||||
var declinedNames []string
|
||||
for _, g := range gs {
|
||||
_, matched, ok := g.Evaluate(e.Text)
|
||||
if matched && !ok {
|
||||
shapeDeclined = true
|
||||
declinedNames = append(declinedNames, g.Name)
|
||||
}
|
||||
}
|
||||
if shapeDeclined {
|
||||
declined++
|
||||
} else {
|
||||
noShape++
|
||||
}
|
||||
c = &conflict{Direction: "claimed_fast_now_miss", ShapeDeclined: shapeDeclined, DeclinedGrammars: declinedNames}
|
||||
case !st && o.Matched:
|
||||
c = &conflict{Direction: "mirror_missed", Grammar: o.Grammar}
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
c.Text = e.Text
|
||||
c.SourceID = e.SourceID
|
||||
c.Route = string(e.Route)
|
||||
c.Source = e.Source
|
||||
c.Group = e.SplitGroup
|
||||
c.Tags = e.Tags
|
||||
c.Dev = inDev
|
||||
conflicts = append(conflicts, *c)
|
||||
byDirection[c.Direction]++
|
||||
byGrammar[c.Grammar]++
|
||||
if byRoute[c.Direction] == nil {
|
||||
byRoute[c.Direction] = map[string]int{}
|
||||
}
|
||||
byRoute[c.Direction][c.Route]++
|
||||
if bySource[c.Direction] == nil {
|
||||
bySource[c.Direction] = map[string]int{}
|
||||
}
|
||||
bySource[c.Direction][c.Source]++
|
||||
if byFamily[c.Direction] == nil {
|
||||
byFamily[c.Direction] = map[string]int{}
|
||||
}
|
||||
byFamily[c.Direction][c.Group]++
|
||||
}
|
||||
|
||||
if inDev {
|
||||
if o.Matched {
|
||||
devFast++
|
||||
} else {
|
||||
devResidByRoute[string(e.Route)]++
|
||||
if e.Route == semantic.RouteAction {
|
||||
devResidAct++
|
||||
} else {
|
||||
devResidNonAct++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(conflicts, func(i, j int) bool { return conflicts[i].SourceID < conflicts[j].SourceID })
|
||||
|
||||
rep := report{
|
||||
Meta: metaSummary{Total: len(exs), DevCount: len(dev), Frozen: len(exs) - len(dev)},
|
||||
Pop: popSummary{
|
||||
StoredFast: storedFast, StoredResid: len(exs) - storedFast,
|
||||
DerivedFast: derivedFast, DerivedResid: len(exs) - derivedFast,
|
||||
DevResidualByRoute: devResidByRoute,
|
||||
DevResidualNonAction: devResidNonAct, DevResidualAction: devResidAct,
|
||||
DevFast: devFast,
|
||||
},
|
||||
Conflicts: conflicts,
|
||||
ByRoute: byRoute, ByGrammar: byGrammar, BySource: bySource, ByFamily: byFamily,
|
||||
Direction: byDirection, Declined: declined, NoShape: noShape,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(rep, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "marshal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, data, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write %s: %v\n", *outPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("total %d (dev %d, frozen %d)\n", rep.Meta.Total, rep.Meta.DevCount, rep.Meta.Frozen)
|
||||
fmt.Printf("stored fast=%d residual=%d\n", rep.Pop.StoredFast, rep.Pop.StoredResid)
|
||||
fmt.Printf("derived fast=%d residual=%d\n", rep.Pop.DerivedFast, rep.Pop.DerivedResid)
|
||||
fmt.Printf("disagreements total %d\n", len(conflicts))
|
||||
for _, d := range []string{"claimed_fast_now_miss", "mirror_missed"} {
|
||||
fmt.Printf(" %-22s %d\n", d, byDirection[d])
|
||||
if d == "claimed_fast_now_miss" {
|
||||
fmt.Printf(" with declined shape: %d no shape: %d\n", declined, noShape)
|
||||
}
|
||||
}
|
||||
fmt.Println(" mirror-missed by grammar:")
|
||||
for _, k := range sortedKeys(byGrammar) {
|
||||
fmt.Printf(" %-28s %d\n", k, byGrammar[k])
|
||||
}
|
||||
fmt.Println(" by route:")
|
||||
for _, d := range sortedKeys(byRoute) {
|
||||
fmt.Printf(" %-22s %v\n", d, byRoute[d])
|
||||
}
|
||||
fmt.Printf("dev pool derived: fast=%d residual=%d (non-action=%d action=%d)\n",
|
||||
rep.Pop.DevFast, rep.Pop.DevResidualNonAction+rep.Pop.DevResidualAction,
|
||||
rep.Pop.DevResidualNonAction, rep.Pop.DevResidualAction)
|
||||
fmt.Printf("dev residual by route: %v\n", rep.Pop.DevResidualByRoute)
|
||||
fmt.Printf("wrote %s\n", *outPath)
|
||||
}
|
||||
|
||||
func sortedKeys[T any](m map[string]T) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 23 emit: five-way residual non-action semantic router — data files
|
||||
========================================================================
|
||||
|
||||
Slice 23 reconciles corpus fast-path metadata with the production router
|
||||
(TryFastPath over stage-0 grammars). The corpus builder no longer mirrors the
|
||||
grammars by hand; fast_path_resolved is derived from the router, so this emit
|
||||
flags exactly the rows the router genuinely leaves for the general cascade.
|
||||
|
||||
This script only repackages the frozen dev pool for the Go legacy baseline and
|
||||
the Python experiment, writing into /tmp/mvn-s23 so the slice-22 artifacts
|
||||
stay untouched. Logic is slice22_emit.py verbatim; only OUT_DIR differs.
|
||||
|
||||
/tmp/mvn-s23/pool.json residual non-action dev rows: idx, text, n_text,
|
||||
route, tags, cv_fold, split_group, family_id,
|
||||
source_id (1509 rows)
|
||||
/tmp/mvn-s23/ood.json residual ACTION dev rows (720): same shape; OOD
|
||||
probes only, never primary metrics
|
||||
/tmp/mvn-s23/stats.json population summary (routes, families, folds)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import slice18_sparse # noqa: E402 (normalize_match_text, load_data, filters)
|
||||
|
||||
OUT_DIR = "/tmp/mvn-s23"
|
||||
|
||||
ROUTES = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
|
||||
|
||||
def main():
|
||||
meta, examples = slice18_sparse.load_data()
|
||||
dev = slice18_sparse.filter_dev_pool(examples)
|
||||
print(f"dev pool: {len(dev)} rows "
|
||||
f"(meta declares dev_count={meta.get('dev_count')})")
|
||||
|
||||
rows = []
|
||||
for i, e in enumerate(dev):
|
||||
if not e["fast_path_resolved"]:
|
||||
rows.append({
|
||||
"idx": i,
|
||||
"text": e["text"],
|
||||
"n_text": slice18_sparse.normalize_match_text(e["text"]),
|
||||
"route": e["route"],
|
||||
"tags": sorted(set(e.get("tags", []))),
|
||||
"cv_fold": e["cv_fold"],
|
||||
"split_group": e["split_group"],
|
||||
"family_id": e["family_id"],
|
||||
"source_id": e["source_id"],
|
||||
})
|
||||
|
||||
na = [r for r in rows if r["route"] != "action"]
|
||||
ood = [r for r in rows if r["route"] == "action"]
|
||||
print(f"residual rows: {len(rows)} non-action: {len(na)} action(OOD): {len(ood)}")
|
||||
|
||||
by_route = {}
|
||||
for r in na:
|
||||
by_route[r["route"]] = by_route.get(r["route"], 0) + 1
|
||||
print("routes:", by_route)
|
||||
assert sum(by_route.values()) == len(na)
|
||||
assert set(ROUTES) == set(by_route), "route set must be the five-way"
|
||||
|
||||
by_family = {}
|
||||
for r in na:
|
||||
by_family[r["family_id"]] = by_family.get(r["family_id"], 0) + 1
|
||||
by_fold = {}
|
||||
for r in na:
|
||||
by_fold[r["cv_fold"]] = by_fold.get(r["cv_fold"], 0) + 1
|
||||
print(f"family_ids: {len(by_family)} split_groups: {len(set(r['split_group'] for r in na))}")
|
||||
print("folds:", by_fold)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
with open(os.path.join(OUT_DIR, "pool.json"), "w") as f:
|
||||
json.dump(na, f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_DIR, "ood.json"), "w") as f:
|
||||
json.dump(ood, f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_DIR, "stats.json"), "w") as f:
|
||||
json.dump({
|
||||
"dev_count": len(dev),
|
||||
"residual_count": len(rows),
|
||||
"non_action_count": len(na),
|
||||
"action_ood_count": len(ood),
|
||||
"routes": by_route,
|
||||
"family_ids": len(by_family),
|
||||
"folds": by_fold,
|
||||
"top_family": dict(sorted(by_family.items(), key=lambda kv: -kv[1])[:15]),
|
||||
}, f, ensure_ascii=False, indent=1)
|
||||
print(f"wrote {OUT_DIR}/{{pool,ood,stats}}.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,559 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slice 23: five-way residual non-action semantic router — corrected population
|
||||
============================================================================
|
||||
|
||||
Slice 22 reported the corpus's fast-path mirror was stale next to the
|
||||
production stage-0 grammars (185 residual rows resolved at runtime). Slice 23
|
||||
derives fast_path_resolved from the real router, rebuilds the corpus, and
|
||||
re-measures the primary slice-22 results on the corrected residual pool.
|
||||
Logic and configs are slice22_main.py verbatim; only OUT_DIR differs.
|
||||
|
||||
Population: the corrected dev-pool residual non-action rows (1509; the pool
|
||||
written by slice23_emit.py). Corrected action rows (720) are OOD probes only.
|
||||
|
||||
Metrics written to /tmp/mvn-s23/results.json:
|
||||
§1 population
|
||||
§2 legacy baseline (legacy.json / legacy_heads.json): acc, macro-F1,
|
||||
per-class P/R/F1, confusion, illegal_action_prediction count
|
||||
§3 e5-linear primary head: C grid, grouped CV OOF, per-fold P/R/F1 +
|
||||
variance + composition
|
||||
§4 floors: majority, centroid (cosine nearest-mean), sparse word+char
|
||||
TF-IDF logistic (slice18 builder), all grouped CV
|
||||
§5 route-family (family_id) leave-family-out
|
||||
§6 knowledge vs memory_write: matched pairs (water/homelab/task) ordering
|
||||
§7 uncertain as an explicit class: P/R/F1 + top confusions
|
||||
§8 OOF confidence: max-softmax correct/wrong, ECE, log-loss, Brier,
|
||||
coverage/accuracy/macro-F1 abstention curves (no threshold chosen)
|
||||
§9 action OOD probes: fold models applied to the corrected action rows
|
||||
§10 artifact cost: head params, serialized bytes, incremental head latency
|
||||
|
||||
No corpus label is changed. No frozen-holdout rows are inspected.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import slice18_sparse # noqa: E402
|
||||
import slice19_main # noqa: E402
|
||||
|
||||
EMB_PATH = "/tmp/mvn-experiment/embeddings.json"
|
||||
OUT_DIR = "/tmp/mvn-s23"
|
||||
CLASSES = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
CLASS_PREFIX = ["conversation", "knowledge", "memory_write", "system", "uncertain"]
|
||||
C_GRID = [0.1, 1.0, 10.0]
|
||||
|
||||
# Route-family holdouts the report calls out by name (slice-22 brief): every
|
||||
# family that is not part of the shared subject inventory on either side.
|
||||
HOLDOUT_GROUPS = {
|
||||
"capability": ["knowledge:capability-ha", "knowledge:capability-tool"],
|
||||
"world": ["knowledge:world-def", "knowledge:world-explain"],
|
||||
"calendar": ["knowledge:calendar", "knowledge:calendar-time", "knowledge:calendar-next"],
|
||||
"recall": ["knowledge:recall-fact", "knowledge:recall-note", "knowledge:recall-possessive"],
|
||||
"fact": ["fact:meal", "fact:water", "fact:sleep", "fact:shower", "fact:break", "fact:pills", "fact:exercise"],
|
||||
"note": ["note:idea", "note:homelab", "note:task"],
|
||||
"remember": ["free:remember"],
|
||||
"system": None, # all system:*
|
||||
"conversation": None,
|
||||
"uncertain": None,
|
||||
}
|
||||
|
||||
|
||||
def load_pool_and_embeds():
|
||||
with open(os.path.join(OUT_DIR, "pool.json")) as f:
|
||||
pool = json.load(f)
|
||||
meta, examples = slice18_sparse.load_data()
|
||||
dev = slice18_sparse.filter_dev_pool(examples)
|
||||
by_idx = {e["dev_idx"]: e for e in dev} if "dev_idx" in dev[0] else None
|
||||
# pool rows carry idx = position among dev_pool rows in dev order
|
||||
emb_by_idx = {i: np.asarray(e["embedding"], dtype=np.float64)
|
||||
for i, e in enumerate(dev)}
|
||||
for r in pool:
|
||||
r["emb"] = emb_by_idx[r["idx"]]
|
||||
r["y"] = r["route"]
|
||||
return pool, meta
|
||||
|
||||
|
||||
def oof_proba_grouped(X, y, folds, C=1.0):
|
||||
"""Grouped OOF probability matrix (n×5, class order CLASSES)."""
|
||||
y_idx = np.array([CLASSES.index(c) for c in y])
|
||||
folds = np.asarray(folds)
|
||||
proba = np.zeros((len(y_idx), len(CLASSES)))
|
||||
for te_fold in sorted(set(folds.tolist())):
|
||||
tr = folds != te_fold
|
||||
te = folds == te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=C, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], y_idx[tr])
|
||||
proba[te] = clf.predict_proba(X[te])
|
||||
return proba
|
||||
|
||||
|
||||
def cls_metrics(yt, yp):
|
||||
import sklearn.metrics as m
|
||||
yt = np.asarray(yt)
|
||||
yp = np.asarray(yp)
|
||||
if yt.dtype != np.int64 and yt.dtype != np.int32:
|
||||
yt = np.array([CLASSES.index(c) for c in yt])
|
||||
if yp.dtype != np.int64 and yp.dtype != np.int32:
|
||||
yp = np.array([CLASSES.index(c) for c in yp])
|
||||
labels = list(range(len(CLASSES)))
|
||||
n = len(yt)
|
||||
acc = m.accuracy_score(yt, yp)
|
||||
macro = m.f1_score(yt, yp, average="macro", labels=labels, zero_division=0)
|
||||
pr, rc, f1, sup = m.precision_recall_fscore_support(
|
||||
yt, yp, labels=labels, zero_division=0)
|
||||
per = {c: {"p": float(pr[i]), "r": float(rc[i]), "f1": float(f1[i]), "n": int(sup[i])}
|
||||
for i, c in enumerate(CLASSES)}
|
||||
conf = m.confusion_matrix(yt, yp, labels=labels).tolist()
|
||||
return {"n": n, "acc": acc, "macro_f1": macro, "per_class": per, "confusion": conf}
|
||||
|
||||
|
||||
def fold_report(yt, proba, folds, true_y):
|
||||
out = {}
|
||||
folds_arr = np.asarray(folds)
|
||||
comp = {}
|
||||
for f in sorted(set(folds_arr.tolist())):
|
||||
mask = folds_arr == f
|
||||
yt_f = [CLASSES.index(y) for y in true_y[mask]]
|
||||
comp[f] = {c: int((np.array(true_y[mask]) == c).sum()) for c in CLASSES}
|
||||
per_fold = {}
|
||||
for f in sorted(set(folds_arr.tolist())):
|
||||
mask = folds_arr == f
|
||||
yp = proba[mask].argmax(1).tolist()
|
||||
m = cls_metrics([yt[i] for i in np.where(mask)[0].tolist()], yp)
|
||||
per_fold[f] = {"acc": m["acc"], "macro_f1": m["macro_f1"]}
|
||||
out["composition"] = comp
|
||||
out["per_fold"] = per_fold
|
||||
accs = [v["acc"] for v in per_fold.values()]
|
||||
macros = [v["macro_f1"] for v in per_fold.values()]
|
||||
out["acc_mean"] = float(np.mean(accs))
|
||||
out["acc_std"] = float(np.std(accs))
|
||||
out["macro_f1_mean"] = float(np.mean(macros))
|
||||
out["macro_f1_std"] = float(np.std(macros))
|
||||
return out
|
||||
|
||||
|
||||
def ece(yt, proba, n_bins=15):
|
||||
conf = proba.max(1)
|
||||
pred = proba.argmax(1)
|
||||
acc = (pred == yt).astype(float)
|
||||
bins = np.linspace(0, 1, n_bins + 1)
|
||||
tot = 0.0
|
||||
details = []
|
||||
counts = 0
|
||||
for i in range(n_bins):
|
||||
lo, hi = bins[i], bins[i + 1]
|
||||
m = (conf >= lo) & (conf < hi) if i < n_bins - 1 else conf >= lo
|
||||
if m.sum() == 0:
|
||||
continue
|
||||
acc_m = acc[m].mean()
|
||||
conf_m = conf[m].mean()
|
||||
w = m.sum() / len(conf)
|
||||
tot += w * abs(acc_m - conf_m)
|
||||
counts += int(m.sum())
|
||||
details.append({"bin": i, "lo": lo, "hi": hi, "conf": float(conf_m),
|
||||
"acc": float(acc_m), "n": int(m.sum())})
|
||||
return {"ece": float(tot), "n_bins": n_bins, "counted": counts, "bins": details}
|
||||
|
||||
|
||||
def main():
|
||||
pool, meta = load_pool_and_embeds()
|
||||
pool.sort(key=lambda r: r["idx"])
|
||||
print(f"pool: {len(pool)} rows")
|
||||
|
||||
from sklearn.metrics import brier_score_loss, log_loss
|
||||
|
||||
report = {"population": {}, "legacy": {}, "e5_linear": {}, "floors": {},
|
||||
"family_holdouts": {}, "kmw": {}, "uncertain": {}, "confidence": {},
|
||||
"ood": {}, "artifact": {}}
|
||||
|
||||
# ── §1 population ──────────────────────────────────────────────────────
|
||||
cnt = {}
|
||||
for r in pool:
|
||||
cnt[r["y"]] = cnt.get(r["y"], 0) + 1
|
||||
report["population"] = {
|
||||
"n": len(pool),
|
||||
"routes": cnt,
|
||||
"family_ids": len(set(r["family_id"] for r in pool)),
|
||||
"split_groups": len(set(r["split_group"] for r in pool)),
|
||||
"folds": {str(f): int(sum(1 for r in pool if r["cv_fold"] == f)) for f in sorted(set(r["cv_fold"] for r in pool))},
|
||||
"corpus": {k: v for k, v in meta.items() if k in
|
||||
("dev_count", "residual_count", "fast_path_count",
|
||||
"dimension", "embedder_id", "input_template", "pooling", "normalization")},
|
||||
}
|
||||
print("\n§1 population:", report["population"])
|
||||
|
||||
X = np.vstack([r["emb"] for r in pool])
|
||||
y = np.array([r["y"] for r in pool])
|
||||
folds = np.array([r["cv_fold"] for r in pool])
|
||||
yt = np.array([CLASSES.index(c) for c in y])
|
||||
|
||||
# ── §2 legacy baselines ────────────────────────────────────────────────
|
||||
import collections
|
||||
for tag, fname in [("hash", "legacy.json"), ("heads", "legacy_heads.json")]:
|
||||
path = os.path.join(OUT_DIR, fname)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
leg = json.load(open(path))
|
||||
leg_by_idx = {r["idx"]: r for r in leg}
|
||||
yp_leg = []
|
||||
illegal = []
|
||||
for r in pool:
|
||||
lr = leg_by_idx[r["idx"]]
|
||||
if lr["illegal_action_prediction"]:
|
||||
illegal.append(lr)
|
||||
yp_leg.append("action")
|
||||
else:
|
||||
yp_leg.append(lr["class"])
|
||||
yp_leg = np.array(yp_leg)
|
||||
# five-way: an 'action' prediction is an error (outside the label set)
|
||||
yp5 = np.array([("uncertain" if p == "action" else p) for p in yp_leg])
|
||||
m = cls_metrics(y, yp5)
|
||||
m["illegal_action_prediction"] = len(illegal)
|
||||
m["illegal_cases"] = [{"idx": i["idx"], "text": i["text"], "route": i["route"],
|
||||
"intent": i["intent"], "producer": i["producer"],
|
||||
"confidence": i["confidence"]} for i in illegal]
|
||||
# per-cell confusion also shows 'action' column
|
||||
conf_counts = collections.Counter(zip(y, yp_leg))
|
||||
m["confusion_with_action"] = {f"{a}->{b}": int(c) for (a, b), c in conf_counts.items()}
|
||||
report["legacy"][tag] = m
|
||||
print(f"\n§2 legacy ({tag}) acc={m['acc']:.4f} macroF1={m['macro_f1']:.4f} "
|
||||
f"illegal={len(illegal)}")
|
||||
for c in CLASSES:
|
||||
p = m["per_class"][c]
|
||||
print(f" {c:<14} P={p['p']:.3f} R={p['r']:.3f} F1={p['f1']:.3f} n={p['n']}")
|
||||
|
||||
# grammar-pure residual: rows not resolved by any current stage-0 grammar
|
||||
if os.path.exists(os.path.join(OUT_DIR, "legacy.json")):
|
||||
leg = json.load(open(os.path.join(OUT_DIR, "legacy.json")))
|
||||
gh = {r["idx"] for r in leg if r["producer"] == "grammar"}
|
||||
gp_mask = np.array([r["idx"] not in gh for r in pool])
|
||||
report["grammar_drift"] = {
|
||||
"grammar_hits_in_pool": len(gh),
|
||||
"grammar_pure_n": int(gp_mask.sum()),
|
||||
}
|
||||
|
||||
# ── §3 e5-linear primary head ─────────────────────────────────────────
|
||||
print("\n§3 e5-linear")
|
||||
bestC, bestMac = 1.0, -1.0
|
||||
grid = {}
|
||||
for C in C_GRID:
|
||||
p = oof_proba_grouped(X, y, folds, C=C)
|
||||
mp = cls_metrics(y, p.argmax(1).tolist())
|
||||
grid[float(C)] = {"acc": mp["acc"], "macro_f1": mp["macro_f1"]}
|
||||
print(f" C={C} acc={mp['acc']:.4f} macroF1={mp['macro_f1']:.4f}")
|
||||
if mp["macro_f1"] > bestMac:
|
||||
bestMac, bestC = mp["macro_f1"], C
|
||||
print(f" -> best C={bestC}")
|
||||
p_best = oof_proba_grouped(X, y, folds, C=bestC)
|
||||
m_best = cls_metrics(y, p_best.argmax(1).tolist())
|
||||
m_best["C"] = bestC
|
||||
m_best["C_grid"] = grid
|
||||
m_best["folds"] = fold_report(yt, p_best, folds, y)
|
||||
report["e5_linear"] = m_best
|
||||
for f, v in m_best["folds"]["per_fold"].items():
|
||||
print(f" fold {f}: acc={v['acc']:.4f} macroF1={v['macro_f1']:.4f}")
|
||||
print(f" fold acc mean={m_best['folds']['acc_mean']:.4f} "
|
||||
f"std={m_best['folds']['acc_std']:.4f}; "
|
||||
f"macroF1 mean={m_best['folds']['macro_f1_mean']:.4f} "
|
||||
f"std={m_best['folds']['macro_f1_std']:.4f}")
|
||||
for c in CLASSES:
|
||||
p_ = m_best["per_class"][c]
|
||||
print(f" {c:<14} P={p_['p']:.3f} R={p_['r']:.3f} F1={p_['f1']:.3f} n={p_['n']}")
|
||||
|
||||
# grammar-pure sensitivity for the primary head
|
||||
if "grammar_drift" in report:
|
||||
mp_gp = cls_metrics(y[gp_mask], p_best[gp_mask].argmax(1).tolist())
|
||||
report["e5_linear"]["grammar_pure"] = {
|
||||
"acc": mp_gp["acc"], "macro_f1": mp_gp["macro_f1"], "n": int(gp_mask.sum())}
|
||||
|
||||
# ── §4 floors ─────────────────────────────────────────────────────────
|
||||
print("\n§4 floors")
|
||||
# majority floor
|
||||
maj = CLASSES.index("knowledge")
|
||||
ym = np.full(len(y), maj)
|
||||
mm = cls_metrics(y, ym)
|
||||
report["floors"]["majority"] = {"acc": mm["acc"], "macro_f1": mm["macro_f1"],
|
||||
"per_class": mm["per_class"]}
|
||||
print(f" majority (predict {CLASSES[maj]}): acc={mm['acc']:.4f} macroF1={mm['macro_f1']:.4f}")
|
||||
|
||||
# centroid floor: cosine to per-class mean of the training folds' embeddings
|
||||
cf_proba = np.zeros((len(yt), len(CLASSES)))
|
||||
folds_arr = np.asarray(folds)
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
centroids = []
|
||||
for c in CLASSES:
|
||||
idxs = np.where(tr & (y == c))[0]
|
||||
ctr = X[idxs].mean(axis=0)
|
||||
ctr = ctr / np.linalg.norm(ctr)
|
||||
centroids.append(ctr)
|
||||
Cm = np.vstack(centroids)
|
||||
sims = X[te] @ Cm.T
|
||||
cf_proba[te] = sims
|
||||
yc = cf_proba.argmax(1)
|
||||
# accuracy + macroF1 with the same 5-way
|
||||
mc = cls_metrics(y, yc.tolist())
|
||||
report["floors"]["centroid"] = {"acc": mc["acc"], "macro_f1": mc["macro_f1"],
|
||||
"per_class": mc["per_class"]}
|
||||
print(f" centroid cosine: acc={mc['acc']:.4f} macroF1={mc['macro_f1']:.4f}")
|
||||
|
||||
# sparse word+char logistic (slice18 builder, grouped CV, five-way)
|
||||
texts = [r["n_text"] for r in pool]
|
||||
Xs, _vec = slice18_sparse.build_features(texts, "both")
|
||||
psp = np.zeros((len(yt), len(CLASSES)))
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
te = folds_arr == te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=1.0, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(Xs[tr], yt[tr])
|
||||
psp[te] = clf.predict_proba(Xs[te])
|
||||
msp = cls_metrics(y, psp.argmax(1).tolist())
|
||||
report["floors"]["sparse_word_char"] = {
|
||||
"acc": msp["acc"], "macro_f1": msp["macro_f1"], "per_class": msp["per_class"],
|
||||
"vocab": slice18_sparse.vocab_size(_vec)}
|
||||
print(f" sparse both: acc={msp['acc']:.4f} macroF1={msp['macro_f1']:.4f} "
|
||||
f"vocab={report['floors']['sparse_word_char']['vocab']}")
|
||||
|
||||
# ── §5 route-family holdouts ─────────────────────────────────────────
|
||||
print("\n§5 route-family holdouts")
|
||||
fam = np.array([r["family_id"] for r in pool])
|
||||
holdouts = {}
|
||||
all_fams = sorted(set(fam.tolist()))
|
||||
for grp, fams in HOLDOUT_GROUPS.items():
|
||||
if fams is None:
|
||||
fams = [f for f in all_fams if f.startswith(grp + ":")]
|
||||
mask = np.isin(fam, fams)
|
||||
if mask.sum() == 0:
|
||||
continue
|
||||
tr = ~mask
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
ypgrp = clf.predict(X[mask])
|
||||
m = cls_metrics([CLASSES.index(c) for c in y[mask]], ypgrp.tolist())
|
||||
m["families"] = fams
|
||||
m["rows"] = int(mask.sum())
|
||||
holdouts[grp] = {"acc": m["acc"], "macro_f1": m["macro_f1"], "n": int(mask.sum()),
|
||||
"per_class": m["per_class"]}
|
||||
print(f" {grp:<14} n={m['rows']} acc={m['acc']:.4f} macroF1={m['macro_f1']:.4f}")
|
||||
# full leave-one-family-out summary
|
||||
lofo_accs = []
|
||||
lofo_f1s = []
|
||||
for f in all_fams:
|
||||
mask = fam == f
|
||||
tr = ~mask
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
ypf = clf.predict(X[mask])
|
||||
m = cls_metrics([CLASSES.index(c) for c in y[mask]], ypf.tolist())
|
||||
lofo_accs.append(m["acc"])
|
||||
lofo_f1s.append(m["macro_f1"])
|
||||
holdouts["_all_49_lo_"] = {"n_families": len(all_fams),
|
||||
"acc_mean": float(np.mean(lofo_accs)),
|
||||
"macro_f1_mean": float(np.mean(lofo_f1s))}
|
||||
report["family_holdouts"] = holdouts
|
||||
print(f" leave-one-family-out over {len(all_fams)} families: "
|
||||
f"acc mean={np.mean(lofo_accs):.4f} macroF1 mean={np.mean(lofo_f1s):.4f}")
|
||||
|
||||
# ── §6 knowledge vs memory_write ─────────────────────────────────────
|
||||
print("\n§6 knowledge vs memory_write")
|
||||
# reuse e5-linear OOF: does the model put the higher probability on the
|
||||
# right side (memory_write for a write, knowledge for a recall)?
|
||||
conf_km = np.zeros((2, 2))
|
||||
pk = p_best[:, CLASSES.index("knowledge")]
|
||||
pmw = p_best[:, CLASSES.index("memory_write")]
|
||||
for i in range(len(yt)):
|
||||
t = y[i]
|
||||
if t == "knowledge":
|
||||
conf_km[0, 1 if pmw[i] > pk[i] else 0] += 1
|
||||
elif t == "memory_write":
|
||||
conf_km[1, 1 if pmw[i] >= pk[i] else 0] += 1
|
||||
report["kmw"] = {"confusion_p_ordered": conf_km.tolist()}
|
||||
|
||||
# matched pairs with shared subject lexemes, corpus-justified
|
||||
def build_pairs(subject, fam_k, fam_mw):
|
||||
kr = [r for r in pool if r["family_id"] in fam_k]
|
||||
mr = [r for r in pool if r["family_id"] in fam_mw]
|
||||
pairs = []
|
||||
for mw in mr:
|
||||
for k in kr:
|
||||
if subject in mw["n_text"] and subject in k["n_text"]:
|
||||
pairs.append((mw["idx"], k["idx"], mw["n_text"], k["n_text"]))
|
||||
return pairs
|
||||
|
||||
sets = {
|
||||
"water": build_pairs("вод", ["knowledge:recall-fact"], ["fact:water"]),
|
||||
"homelab": build_pairs("dns", ["knowledge:homelab-status"], ["note:homelab"])
|
||||
+ build_pairs("сервер", ["knowledge:homelab-status"], ["note:homelab"])
|
||||
+ build_pairs("vlan", ["knowledge:homelab-status"], ["note:homelab"]),
|
||||
"task": build_pairs("задач", ["knowledge:task-check", "knowledge:deadline"],
|
||||
["note:task"]),
|
||||
}
|
||||
idx_of = {r["idx"]: i for i, r in enumerate(pool)}
|
||||
pair_rep = {}
|
||||
for name, pairs in sets.items():
|
||||
if not pairs:
|
||||
continue
|
||||
ok = 0
|
||||
margins = []
|
||||
bad = []
|
||||
for mi, ki, mx, kx in pairs:
|
||||
mi_i, ki_i = idx_of[mi], idx_of[ki]
|
||||
# MW row should get a higher memory_write probability than the K row
|
||||
mk = (pmw[mi_i] + 0.0)
|
||||
if pmw[mi_i] > pmw[ki_i]:
|
||||
ok += 1
|
||||
else:
|
||||
bad.append((mx[:46], round(float(pmw[mi_i]), 3), kx[:46], round(float(pmw[ki_i]), 3)))
|
||||
margins.append(pmw[mi_i] - pmw[ki_i])
|
||||
pair_rep[name] = {
|
||||
"pairs": len(pairs),
|
||||
"mw_over_k_order_acc": ok / len(pairs),
|
||||
"mean_margin": float(np.mean(margins)),
|
||||
"reversed_examples": bad[:6],
|
||||
}
|
||||
print(f" {name}: pairs={len(pairs)} order_acc={ok/len(pairs):.3f} "
|
||||
f"mean_margin={np.mean(margins):+.3f}")
|
||||
report["kmw"]["matched_pairs"] = pair_rep
|
||||
|
||||
# ── §7 uncertain as explicit class ────────────────────────────────────
|
||||
print("\n§7 uncertain")
|
||||
up = m_best["per_class"]["uncertain"]
|
||||
uc = m_best["confusion"][CLASSES.index("uncertain")]
|
||||
report["uncertain"] = {
|
||||
"per_class": up,
|
||||
"row_from_uncertain": {CLASSES[j]: int(uc[j]) for j in range(5)},
|
||||
"row_to_uncertain": {CLASSES[j]: int(m_best["confusion"][j][CLASSES.index("uncertain")])
|
||||
for j in range(5)},
|
||||
}
|
||||
print(f" uncertain n={up['n']} P={up['p']:.3f} R={up['r']:.3f} F1={up['f1']:.3f}")
|
||||
print(" wrong-→label pulled from uncertain:", report["uncertain"]["row_from_uncertain"])
|
||||
print(" →uncertain pulled from:", report["uncertain"]["row_to_uncertain"])
|
||||
|
||||
# ── §8 OOF confidence / calibration / abstention ─────────────────────
|
||||
print("\n§8 confidence / calibration")
|
||||
conf = p_best.max(1)
|
||||
right = (p_best.argmax(1) == yt)
|
||||
cer = {
|
||||
"correct_conf_mean": float(conf[right].mean()),
|
||||
"correct_conf_median": float(np.median(conf[right])),
|
||||
"wrong_conf_mean": float(conf[~right].mean()),
|
||||
"wrong_conf_median": float(np.median(conf[~right])),
|
||||
"ece": ece(yt, p_best)["ece"],
|
||||
"ece_bins": ece(yt, p_best)["bins"],
|
||||
"log_loss": float(log_loss(yt, p_best, labels=[0, 1, 2, 3, 4])),
|
||||
}
|
||||
# Brier is label-set specific: one-vs-rest mean
|
||||
briers = []
|
||||
for i in range(5):
|
||||
briers.append(brier_score_loss((yt == i).astype(int), p_best[:, i]))
|
||||
cer["brier_macro"] = float(np.mean(briers))
|
||||
report["confidence"] = cer
|
||||
print(f" right conf mean={cer['correct_conf_mean']:.3f} "
|
||||
f"wrong conf mean={cer['wrong_conf_mean']:.3f} ECE={cer['ece']:.4f}")
|
||||
print(f" log_loss={cer['log_loss']:.4f} brier_macro={cer['brier_macro']:.4f}")
|
||||
|
||||
thr_grid = np.linspace(0.10, 0.98, 45)
|
||||
abst = []
|
||||
for t in thr_grid:
|
||||
cov = (conf >= t).mean()
|
||||
if cov == 0:
|
||||
continue
|
||||
keep = conf >= t
|
||||
yt_k = yt[keep]
|
||||
yp_k = p_best[keep].argmax(1)
|
||||
mk_ = cls_metrics(yt_k.tolist(), yp_k.tolist())
|
||||
abst.append({"threshold": round(float(t), 3), "coverage": float(cov),
|
||||
"accuracy": mk_["acc"], "macro_f1": mk_["macro_f1"]})
|
||||
report["confidence"]["abstention_curve"] = abst
|
||||
print(" threshold | coverage | accuracy | macroF1 (first 6/45 + knee)")
|
||||
for row in abst[::9]:
|
||||
print(f" {row['threshold']:.2f} | {row['coverage']:.3f} | "
|
||||
f"{row['accuracy']:.3f} | {row['macro_f1']:.3f}")
|
||||
|
||||
# ── §9 action OOD probes ──────────────────────────────────────────────
|
||||
print("\n§9 action OOD")
|
||||
ood_rows = [r for r in json.load(open(os.path.join(OUT_DIR, "ood.json")))]
|
||||
emb_by_idx = {i: np.asarray(e["embedding"], dtype=np.float64)
|
||||
for i, e in enumerate(slice18_sparse.filter_dev_pool(
|
||||
slice18_sparse.load_data()[1]))}
|
||||
Xo = np.vstack([emb_by_idx[r["idx"]] for r in ood_rows])
|
||||
fold_models = []
|
||||
for te_fold in sorted(set(folds_arr.tolist())):
|
||||
tr = folds_arr != te_fold
|
||||
clf = slice18_sparse.LogisticRegression(
|
||||
C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X[tr], yt[tr])
|
||||
fold_models.append(clf)
|
||||
# XX
|
||||
# comparable to the non-action in-fold behaviour
|
||||
po = np.zeros((len(Xo), 5))
|
||||
for clf in fold_models:
|
||||
po += clf.predict_proba(Xo)
|
||||
po /= len(fold_models)
|
||||
ood_top = int(np.argmax(po.mean(0)))
|
||||
ood_conf = po.max(1)
|
||||
ood_pred = po.argmax(1)
|
||||
top_dist = {CLASSES[i]: int((ood_pred == i).sum()) for i in range(5)}
|
||||
confident_na = int((ood_conf > 0.9).sum())
|
||||
report["ood"] = {
|
||||
"n": len(ood_rows),
|
||||
"top_class": CLASSES[int(ood_top)],
|
||||
"top_class_dist": top_dist,
|
||||
"conf_gt_0.9": confident_na,
|
||||
"conf_gt_0.9_frac": float(confident_na / len(ood_rows)),
|
||||
"conf_mean": float(ood_conf.mean()),
|
||||
"conf_median": float(np.median(ood_conf)),
|
||||
}
|
||||
print(f" action OOD n={len(ood_rows)}: most-confident class={report['ood']['top_class']} "
|
||||
f"dist={top_dist}")
|
||||
print(f" conf>0.9: {confident_na} ({confident_na/len(ood_rows):.3f}) "
|
||||
f"conf mean={report['ood']['conf_mean']:.3f}")
|
||||
|
||||
# ── §10 artifact cost ────────────────────────────────────────────────
|
||||
print("\n§10 artifact")
|
||||
n_params = len(CLASSES) * X.shape[1] + len(CLASSES)
|
||||
fp32 = n_params * 4
|
||||
report["artifact"] = {
|
||||
"e5_dim": X.shape[1],
|
||||
"head_params": n_params,
|
||||
"head_fp32_bytes": fp32,
|
||||
"head_fp32_kib": fp32 / 1024,
|
||||
"head_int8_bytes": n_params,
|
||||
}
|
||||
# incremental latency of the linear head over a batch of 1 (µs)
|
||||
clf = slice18_sparse.LogisticRegression(C=bestC, max_iter=2000, solver="lbfgs", random_state=42)
|
||||
clf.fit(X, yt)
|
||||
x1 = X[:1]
|
||||
for _ in range(50):
|
||||
clf.predict_proba(x1)
|
||||
lat = []
|
||||
for _ in range(2000):
|
||||
t0 = time.perf_counter_ns()
|
||||
clf.predict_proba(x1)
|
||||
lat.append((time.perf_counter_ns() - t0) / 1e3)
|
||||
lat = np.array(lat)
|
||||
report["artifact"]["head_latency_us_mean"] = float(lat.mean())
|
||||
report["artifact"]["head_latency_us_p50"] = float(np.median(lat))
|
||||
print(f" head params={n_params} fp32={fp32/1024:.2f}KiB "
|
||||
f"lat mean={lat.mean():.2f}us p50={np.median(lat):.2f}us")
|
||||
|
||||
with open(os.path.join(OUT_DIR, "results.json"), "w") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=1, default=float)
|
||||
print(f"\nwrote {OUT_DIR}/results.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,465 @@
|
||||
# A deterministic structural execution-frame gate blocks every one of the 126 capability-question rows on all three stress views (dangerous pass 0/126), never blocks a true action (0/796), declines 196 malformed action rows as ambiguous, and removes the sparse gate's last residual capability-question pass and three of its nine false actions (guard→sparse P 96.7 %, R 22.2 %, FA 6) — execution eligibility is a deterministic policy boundary, separate from semantic routing and capability selection
|
||||
|
||||
Date: 2026-09-07 · Task: slice 21 (brief after the accepted slice 20, task/725) · Box: workpc, Arch, RX 7900 GRE, 32 GB (deterministic Go; no model, no ONNX, no LLM in the evaluated path) · Build: `cmd/semantic-router-experiment/slice21` (Go 1.25.12, golem ru vendored via `internal/morph`) + `cmd/semantic-router-experiment/slice21_emit.py` in the frozen venv `/tmp/mvn-exp-venv`.
|
||||
|
||||
## 0. Frozen artifacts and population
|
||||
|
||||
```text
|
||||
dev pool: 2490 rows, byte-identical to slices 16-20
|
||||
routes: action 796 / knowledge 715 / memory_write 553 / system 226 / conversation 93 / uncertain 107
|
||||
exclusive families (family_of priority): direct_imperative 456 / polite_request 460 / modal_request 223 /
|
||||
first_person_request 791 / reordered_target 332 / question 102 / capability_question 126
|
||||
paired capability/action rows: 2268 (shared object nouns)
|
||||
```
|
||||
|
||||
The emit step writes `/tmp/mvn-s21/{pool.json, pairs.json, sparse_oof.json}`.
|
||||
`pool.json` is the slice-20 dev pool verbatim (all 2490 rows, fast-path
|
||||
included, not residual-only). `sparse_oof.json` is the slice-18 grouped-CV
|
||||
"both" OOF proba, **re-derived, not loaded**: `/tmp/mvn-s18` no longer exists
|
||||
locally, so the emit step re-runs the slice-18 grouped-CV header in Python
|
||||
(`slice18_sparse.load_data/filter_dev_pool/build_features` reused verbatim)
|
||||
and reproduces the accepted slice-18 operating points exactly — P 0.959 /
|
||||
R 0.264 / FA 9 at threshold 0.715, and P 0.875 / R 0.485 / FA 55 at 0.5. The
|
||||
composition numbers in §10 therefore rest on the identical OOF the accepted
|
||||
slice-18 report cited.
|
||||
|
||||
## 1. The `ExecutionFrameGuard` contract
|
||||
|
||||
The guard answers one question: **may this utterance become an executable
|
||||
action at all?** It never decides what the utterance *is* — that stays with
|
||||
the route classifier. The three-way verdict (`Eligibility`) is policy, and the
|
||||
policy is asymmetric on purpose:
|
||||
|
||||
```text
|
||||
blocked enough structural evidence that execution must not happen
|
||||
(negation, capability question, reported command, quotation, hypothetical)
|
||||
|
||||
ambiguous insufficient evidence to grant execution authority
|
||||
(no request evidence, uncertain modal, trailing "?")
|
||||
|
||||
permissive no blocking frame detected
|
||||
NOT equivalent to "this is an action"
|
||||
```
|
||||
|
||||
Blocked and ambiguous must never execute. Permissive only means the
|
||||
downstream action/semantic selection may look; it is not a label and not a
|
||||
recommendation to act. `Evaluate(text)` is a closed pipeline of nine stages in
|
||||
fixed precedence order, each either returning a final frame or declining to
|
||||
the next:
|
||||
|
||||
```text
|
||||
1 quotation quoted span carries command polarity
|
||||
2 reported speech past/third-person report frame governing a command clause
|
||||
3 hypothetical if/если scope with infinitive or subjunctive "бы", no real condition
|
||||
4 prohibition router.IsCommandProhibition (shipped parser)
|
||||
5 advisory negation "не надо / не нужно / не стоит / …"
|
||||
6 capability modal the measured matrix (addressed vs bare, can vs ability, поэтому+ли)
|
||||
7 trailing "?" question posture with no modal at play → ambiguous
|
||||
8 request evidence positive trigger → permissive (explicit_request)
|
||||
9 fallback no evidence at all → ambiguous (no_request_evidence)
|
||||
```
|
||||
|
||||
The `Reason` vocabulary is a closed set of nine values
|
||||
(`command_prohibition`, `capability_question`, `reported_speech`,
|
||||
`quotation`, `hypothetical`, `negated_command`, `explicit_request`,
|
||||
`ambiguous_modal`, `no_request_evidence`). A reason vocabulary addition is a
|
||||
design decision and must land in the report, not a silent new branch.
|
||||
|
||||
Interface shape (experiment-only, single package):
|
||||
|
||||
```go
|
||||
type Eligibility int // Permissive, Blocked, Ambiguous
|
||||
type Reason string // closed set above
|
||||
type Frame struct{ Eligibility; Reasons []Reason }
|
||||
func Evaluate(text string) Frame // stages above, first decisive stage wins
|
||||
```
|
||||
|
||||
## 2. Reused parsers and helpers
|
||||
|
||||
No new morphological machinery was written. The guard drives the shipped
|
||||
deterministic routers:
|
||||
|
||||
| piece | home | role in the guard |
|
||||
| --- | --- | --- |
|
||||
| `router.IsCommandProhibition` | `internal/router/commandframe.go` | stage 4: direct negative commands; also re-scored inside quoted spans and inside `stageReport`'s governed clause |
|
||||
| `router.NormalizeMatchText` | `internal/router/matchtext.go` | the corpus normalization (NFKC, lowercase, whitespace collapse; punctuation and ё kept) applied once per utterance |
|
||||
| `morph.IsVerbForm` / `morph.Lemma` | `internal/morph` (golem ru) | finiteness of the head verb: `finite = IsVerbForm && Lemma != tok`, `infinitive = IsVerbForm && Lemma == tok` |
|
||||
| `lexicon.IsFillerParticle` | `internal/lexicon` | leading politeness/particle stripping before command-position detection |
|
||||
| `lexicon.FirstPerson()` / `lexicon.ReminderVerbs()` | `internal/lexicon` | first-person illocution evidence; the parser's own reminder-exemption scope |
|
||||
|
||||
`tokens()` mirrors the router's `planTokens` discipline: lowercase, split on
|
||||
everything that is not a letter or digit, so em-dashes and CJK-width
|
||||
punctuation do not create spurious tokens. Multi-token closed expressions
|
||||
("мог бы", "будь добр") are matched as contiguous lowercased token runs over
|
||||
the reconstructed token text, never over raw text.
|
||||
|
||||
## 3. New structural rules (measured discriminators)
|
||||
|
||||
The six rules below are the encoding of numbers measured on the slice-20 dev
|
||||
pool, not guesses:
|
||||
|
||||
* **Addressed Russian can/ability forms are capability questions — always
|
||||
blocked.** "ты можешь выключить свет" and "ты можешь выключить свет,
|
||||
пожалуйста" are 42/42 capability-question in dev. Any `ты/вы/тебе/…` +
|
||||
can-form (present or ability) → `blocked/сapability_question`.
|
||||
* **Bare ability forms are capability even polite.** "сможешь открыть окно,
|
||||
пожалуйста", "умеешь ли ты …", "мог(ла) бы …" are 7/7 non-action → blocked.
|
||||
Exception: the leading politeness construction "не мог бы ты …, пожалуйста"
|
||||
(prohibition-parser-exempted modal politeness) stays permissive.
|
||||
* **Bare Russian present can-form needs politeness.** "можешь выключить свет,
|
||||
пожалуйста" is 127/127 action → permissive. Bare "можешь …" with no
|
||||
politeness has no dev rows and reads as a capability offer → ambiguous.
|
||||
* **English can/could needs politeness to be a request.** "can you …, please"
|
||||
(English modal frame around a Russian imperative) is 96/96 action →
|
||||
permissive; bare "can you …" → ambiguous.
|
||||
* **Polar "… ли" after a can-form is a capability question → blocked.**
|
||||
"могу ли я …", "можешь ли ты …", "умеешь ли ты …", "можно ли …".
|
||||
* **Trailing "?" with no modal at play → ambiguous.** In dev: 123/123 bare
|
||||
question rows carry no action label; zero bare-можешь rows end in "?"; only
|
||||
21 of the 126 capability-question rows end in "?" (the rest are declarative
|
||||
templates like "ты можешь выключить свет"). The two rules never collide.
|
||||
|
||||
Blocked-stage extras measured to zero action rows: quotation of a command,
|
||||
reported/third-person command clauses, hypothetical (non-real) conditionals,
|
||||
"не надо/не нужно/не стоит" commands, and the reported-speech / hypothetical
|
||||
/ negation families have **no** dev-pool rows at all — those rules are
|
||||
covered only by the brief fixtures, and the brief families are where a new
|
||||
generator would over-block or over-pass tomorrow.
|
||||
|
||||
## 4. Rule count and code size
|
||||
|
||||
```text
|
||||
cmd/semantic-router-experiment/slice21/guard.go 659 lines
|
||||
cmd/semantic-router-experiment/slice21/main.go 566 lines
|
||||
cmd/semantic-router-experiment/slice21/guard_test.go 139 lines
|
||||
cmd/semantic-router-experiment/slice21/fixtures.go 95 lines
|
||||
cmd/semantic-router-experiment/slice21_emit.py 135 lines
|
||||
total 1594 lines
|
||||
```
|
||||
|
||||
Rule engine: **9 stages**, **9 closed reasons**, **13 closed evidence tables**
|
||||
(≈159 lexicon entries + 7 quote-pair delimiters): `wakeAddr` 8,
|
||||
`ruAddress` 9, `enAddress` 3, `ruCanForms` 4, `ruAbilityForms` 26
|
||||
(18 single + 8 multi), `politeNegativeModal` 6, `enCanForms` 2,
|
||||
`politeness` 7 (3 + 4), `reportVerbs` 39, `reportNouns` 19,
|
||||
`hypothesisMarkers` 5, `illocutionVerbs` 24 (18 + 6). One `maybeWord` matcher
|
||||
handles single/multi closed expressions. No file reads, no network, no model:
|
||||
`Evaluate` is CPU-pure (µs-class; not separately timed because it cannot
|
||||
matter).
|
||||
|
||||
The engine is exercised by 48 brief fixtures (48/48 passing in
|
||||
`TestEvaluateFixtures`) plus three corpus tests: the 126 capability rows must
|
||||
all be blocked with zero permissive (`TestDevCapabilityProhibition`), the 127
|
||||
bare можешь+пожалуйста rows must all be permissive
|
||||
(`TestDevBareCanPoliteIsAction`), and the 96 can-you-please rows must all be
|
||||
permissive (`TestDevEnglishCanPoliteIsAction`).
|
||||
|
||||
## 5. Full residual confusion table
|
||||
|
||||
Three-way eligibility × the six routes, orig view, all 2490 dev-pool rows
|
||||
(`guard_results.json → rows.frames.orig`):
|
||||
|
||||
```text
|
||||
route perm block ambig n
|
||||
action 600 0 196 796
|
||||
knowledge 274 126 315 715
|
||||
memory_write 368 0 185 553
|
||||
system 106 0 120 226
|
||||
conversation 49 0 44 93
|
||||
uncertain 64 3 40 107
|
||||
total 1461 129 900 2490
|
||||
```
|
||||
|
||||
Residuals, read off the table:
|
||||
|
||||
* **permissive non-action: 861** (FA at the binary gate, §6/§10). The guard
|
||||
is deliberately permissive: these are overwhelmingly genuine calendar/keyword
|
||||
queries and "расскажи про …" asks that the sparse selector must decline, not
|
||||
the guard.
|
||||
* **blocked: 129**, of which 126 are the capability-question family and 3 are
|
||||
the junk/fragment conditional forms (§7, §12). **Blocked action = 0.**
|
||||
* **ambiguous: 900**, of which 196 are true-action rows (§12). Ambiguous never
|
||||
executes; these are the guard's recall cost.
|
||||
|
||||
## 6. Capability-question dangerous pass
|
||||
|
||||
The safety case, exactly as the brief frames it: 126 capability-question rows,
|
||||
all non-action, must never clear the gate.
|
||||
|
||||
```text
|
||||
capability-question dangerous pass = 0/126 (rate 0.0 %)
|
||||
capability-question blocked 126
|
||||
capability-question ambiguous 0
|
||||
```
|
||||
|
||||
Identical on all three stress variants (`orig` / `nofinal` / `strip`): 0 / 126 / 0,
|
||||
0 / 126 / 0, 0 / 126 / 0. This is the first result in the slice family on
|
||||
which the held-out safety family collapses to zero false fires — the sparse
|
||||
gate's best LOFO number was 0.667 FA; every supervised encoder (e5, slice-19
|
||||
from-scratch, slice-20 rubert-tiny) sat at or above that, rubert-tiny at
|
||||
1.000 across all 18 config/seeds.
|
||||
|
||||
## 7. Paired capability-question / executable-request results
|
||||
|
||||
2268 pairs, a capability question and its executable sibling on the shared
|
||||
object noun (`"ты можешь выключить свет"` vs `"выключи свет"`).
|
||||
|
||||
```text
|
||||
pairs 2268: cap cleared 0 (rate 0.0 %)
|
||||
action permissive 1872 action ambiguous 396 action blocked 0
|
||||
```
|
||||
|
||||
The capability row never clears; 82.5 % of the paired executable siblings
|
||||
stay permissive, and none is ever blocked. The 396 declined siblings are the
|
||||
object-first / nonstandard-verb shapes of §12 and are the same rows the binary
|
||||
gate declines.
|
||||
|
||||
### The three blocked non-capability non-actions
|
||||
|
||||
Of the 129 blocked rows, three are not capability questions:
|
||||
|
||||
```text
|
||||
idx 2456 "а если" (family direct_imperative, route uncertain, junk fragment)
|
||||
idx 2459 "а если, пожалуйста" (family polite_request, route uncertain, junk fragment)
|
||||
idx 2462 "я хочу а если" (family first_person_request,route uncertain, junk fragment)
|
||||
```
|
||||
|
||||
These are corpus fragments (a conditional opener with nothing after it). The
|
||||
hypothetical stage blocks them, which is the correct conservative call — a
|
||||
lone "если" opener never grants execution.
|
||||
|
||||
## 8. Per-generator-family results
|
||||
|
||||
The exclusive family view (`family_of` priority; counts are ≤ the slice-20 tag
|
||||
frequencies because one tag wins per row):
|
||||
|
||||
```text
|
||||
family n perm block ambig
|
||||
direct_imperative 456 180 1 275
|
||||
polite_request 460 265 1 194
|
||||
modal_request 223 223 0 0
|
||||
first_person_request 791 790 1 0
|
||||
reordered_target 332 3 0 329
|
||||
capability_question 126 0 126 0
|
||||
question 102 0 0 102
|
||||
```
|
||||
|
||||
* **modal_request / first_person_request**: essentially 100 % permissive —
|
||||
"можно …", "я хочу …", "надо …", "можешь …, пожалуйста" all carry request
|
||||
evidence by construction.
|
||||
* **question**: 100 % ambiguous — trailing "?" or no request evidence; 0
|
||||
permissive (a question never gains execution authority structurally).
|
||||
* **capability_question**: 100 % blocked.
|
||||
* **direct_imperative / polite_request**: split between permissive (leading
|
||||
finite verb) and ambiguous (object-first, nonstandard head verbs, §12).
|
||||
* **reordered_target**: 1 % permissive. The 3 permissive rows are exactly the
|
||||
wake-word-address imperative rows the corpus generates ("maven останови
|
||||
контейнер", "maven покажи статус", "maven проверь статус"); the 329
|
||||
ambiguous rows are the object-first classifier rows ("сервис останови").
|
||||
|
||||
## 9. Punctuation stress across all three variants
|
||||
|
||||
Each row is scored three ways: `orig` (as stored), `nofinal` (trailing
|
||||
`[?.!,;:]+` stripped), and `strip` (all punctuation stripped). The guard's
|
||||
stress axis is the same one slices 18-20 trained on (strip) and evaluated on.
|
||||
|
||||
```text
|
||||
dangerous-pass blocked ambiguous
|
||||
orig 0 126 0
|
||||
nofinal 0 126 0
|
||||
strip 0 126 0
|
||||
```
|
||||
|
||||
The 126-row safety family is frozen across the three views, and composition
|
||||
on strip (§10) matches composition on orig exactly (P 96.7 % / R 22.2 % /
|
||||
FA 6). Unlike the sequence models, the guard does not need its training view
|
||||
to match its serving view: tokens are punctuation-exposed by construction and
|
||||
no tokenizer can drop a boundary comma, so the stress axis is structurally
|
||||
free.
|
||||
|
||||
## 10. Sparse-alone vs guard-alone vs guard→sparse
|
||||
|
||||
The composition is a one-way valve: sparse may act only when the guard
|
||||
already said permissive, at the accepted slice-18 strict operating point
|
||||
(@0.715).
|
||||
|
||||
```text
|
||||
policy P R FA FA rate capQ capQ rate
|
||||
guard alone 41.1% 75.4% 861 34.6% 0 0.0%
|
||||
sparse alone 95.9% 26.4% 9 0.4% 1 0.8%
|
||||
guard → sparse 96.7% 22.2% 6 0.2% 0 0.0%
|
||||
```
|
||||
|
||||
Stress: guard→sparse on `strip` is identical to `orig` (P 96.7 %, R 22.2 %,
|
||||
FA 6, capQ 0).
|
||||
|
||||
### Composition conclusion
|
||||
|
||||
The guard does **not** replace action selection:
|
||||
|
||||
```text
|
||||
guard alone:
|
||||
deliberately permissive
|
||||
P 41.1 %
|
||||
R 75.4 %
|
||||
FA 861
|
||||
|
||||
sparse alone:
|
||||
P 95.9 %
|
||||
R 26.4 %
|
||||
FA 9
|
||||
|
||||
guard → sparse:
|
||||
P 96.7 %
|
||||
R 22.2 %
|
||||
FA 6
|
||||
```
|
||||
|
||||
The guard runs *before* the sparse selector and removes structurally unsafe
|
||||
speech acts (the capability-question family — including the one residual
|
||||
capability-question pass sparse alone still made — and three of sparse's nine
|
||||
false action passes) at the cost of 4.2 recall points. Precision
|
||||
96.7 % is the best operating point measured anywhere on this pool. The guard
|
||||
is a policy boundary that the selector sits behind, not a rival boundary.
|
||||
|
||||
## 11. Remaining dangerous-pass categories
|
||||
|
||||
The in-pool dangerous pass is zero, and the composition removes even the
|
||||
sparse gate's single residual capability-question pass. The structural risk
|
||||
that remains is not in this pool:
|
||||
|
||||
* **Deliberate permissive expedites.** Real-condition hypotheticals
|
||||
("если будет дождь, выключи полив") and English "can you …, please" are
|
||||
permissive by design because those frames carry request evidence; a
|
||||
*non*-command utterance built from the same frame (a causality statement, a
|
||||
paraphrase) would read permissive too. No such rows exist in dev.
|
||||
* **Closed-list blind spots.** `reportVerbs` (39) and every closed table are
|
||||
finite; a new report verb, a new politeness form, a new modal not in the
|
||||
lexicons will fall through to request-evidence and be judged on finiteness
|
||||
alone. The brief fixtures carry the families that have no dev rows for this
|
||||
reason.
|
||||
* **New generators.** All rules were measured on the frozen corpus. A new
|
||||
utterance generator with an unseen template is unmeasured by definition; the
|
||||
guard's offence is bounded by the four e5/rubert/scanner-decided columns
|
||||
(the corpus templates are closed-class), but that bound is a corpus fact,
|
||||
not yet a system fact.
|
||||
* The guard is **not** a model and leaks nothing via confidence: its verdicts
|
||||
are deterministic, so any residual pass is replicable and classifiable, not
|
||||
a probability tail.
|
||||
|
||||
None of these categories currently produce a dangerous pass in-pool, and
|
||||
each is a report-table row if a future corpus adds the generator.
|
||||
|
||||
## 12. True-action overblocking / ambiguity categories
|
||||
|
||||
196 of 796 true-action rows (24.6 %) are declined as ambiguous (0 are
|
||||
blocked). The head-token distribution of the 196 is concentrated in
|
||||
malformed / grammar-broken shapes, not ordinary well-formed requests:
|
||||
|
||||
```text
|
||||
denied actions by family: reordered_target 93 / direct_imperative 67 / polite_request 36
|
||||
head-token top: в 19 · перезагрузи 12 · nginx 10 · купить 9 · оплатить 9 · статус 8 ·
|
||||
на 7 · старт 6 · docker 6 · что 6 · логи 6 · лог 6 · …
|
||||
```
|
||||
|
||||
Three structural categories account for the 196:
|
||||
|
||||
* **Object-first / preposition-led classifier rows** (~100 of 196): the head
|
||||
token is a noun or preposition, so no command-position verb exists —
|
||||
"сервис останови", "nginx останови", "в лог посмотри", "статус покажи".
|
||||
These are the classifier's reordered_target training shapes. No request
|
||||
evidence → ambiguous.
|
||||
* **Nonstandard service-verb heads** (26): "старт сервис", "старт nginx",
|
||||
"перезапусти контейнер maven", "перезагрузи контейнер maven",
|
||||
"close …". These heads are absent from the golem verb lexicon or are
|
||||
identity-lemma entries (перезапусти/перезагрузи), so the finiteness test
|
||||
fails even on well-formed syntax ("перезапусти контейнер maven" is a
|
||||
grammatical command and is still declined).
|
||||
* **Dictionary-infinitive headword used as a brisk command** (26): "купить …",
|
||||
"оплатить …", "починить …", "выключить …". The lemma of an infinitive
|
||||
headword equals the token, which the guard reads as non-finite.
|
||||
|
||||
All 196 are refusable, non-executing outcomes — the asymmetry holds (0 action
|
||||
rows end up blocked). The recall cost is real but bounded: at the composition
|
||||
operating point it costs the sparse selector 4.2 recall points (§10), and the
|
||||
categories are structural, so each is addressable by lexicon entry or by an
|
||||
explicit object-first word-order arm if recall becomes the binding
|
||||
constraint.
|
||||
|
||||
## 13. Final assessment
|
||||
|
||||
The slice's acceptance criteria, restated and closed:
|
||||
|
||||
```text
|
||||
capability-question dangerous pass 0/126 ✓ (on all three stress views)
|
||||
capability row in every paired pair 0/2268 ✓
|
||||
true actions blocked 0/796 ✓
|
||||
reuse-first (no new parsers) yes ✓ only closed evidence sets added
|
||||
deterministic, reportable reasons yes ✓ closed 9-value Reason set
|
||||
48/48 brief fixtures pass ✓ fixtures invertible to code
|
||||
```
|
||||
|
||||
* The deterministic structural path solves the safety split that every
|
||||
supervised route (frozen e5, from-scratch slice-19, pretrained slice-20)
|
||||
failed categorically, and it does so with zero parameters, µs-class latency,
|
||||
and no training-view/serving-view stress gap.
|
||||
* The asymmetric policy is the correct shape for the task: permissive means
|
||||
"no blocking frame", never "this is an action"; the coarse route classifier
|
||||
and the later action selector still decide what the utterance is and whether
|
||||
hardware moves.
|
||||
* The guard's own precision is deliberately low (P 41.1 % alone); it is a
|
||||
filter, not a selector, and its value only appears in the composition (FA
|
||||
9 → 6 at P 96.7 %).
|
||||
* Nothing here records the sparse gate as production-ready. Its remaining
|
||||
recall (R 22.2 % under the guard, 26.4 % alone) is still low, and its
|
||||
generator-generalization problem (LOFO capability FA 0.667, aggregate FA
|
||||
pattern) remains a known, unresolved fact of the pool.
|
||||
|
||||
## 14. Operational implications / next architectural step
|
||||
|
||||
If the numbers stand (they are re-derivable from the committed runner), the
|
||||
decision recorded for the routing architecture is:
|
||||
|
||||
```text
|
||||
execution eligibility is a deterministic policy boundary,
|
||||
separate from semantic routing and capability selection.
|
||||
|
||||
TryFastPath
|
||||
↓ miss
|
||||
ExecutionFrameGuard
|
||||
├─ blocked/ambiguous → action unavailable
|
||||
└─ permissive
|
||||
↓
|
||||
action/semantic selection
|
||||
```
|
||||
|
||||
Operational notes for whoever wires this later:
|
||||
|
||||
* Blocked and ambiguous are both non-executing; the phraser may answer both,
|
||||
but no action engine may fire on either. The trigger wiring
|
||||
(`cmd/mavend/ecosystem_acts.go`) is out of scope for this slice.
|
||||
* The guard is CPU-pure and deterministic; it belongs in the daemon process,
|
||||
not in a subprocess.
|
||||
* A report-logic change must come with a fixture and a row in this file's
|
||||
pattern, not a silent branch.
|
||||
|
||||
**Next slice: stop the action-pragmatics model branch.** Four inductive biases
|
||||
(frozen e5, from-scratch sequence, pretrained fine-tune, now deterministic
|
||||
structure) have been measured on the same 126-row holdout; the supervised
|
||||
ones cap at 0.48-1.000 FA and the deterministic one closes it to 0. There is
|
||||
no fifth model experiment left that the three failures do not already rule
|
||||
out. Recommended work moves back to the coarse non-action router —
|
||||
`conversation / knowledge / memory_write / system / uncertain` — with
|
||||
executable action eligibility handled separately by `deterministic fast path +
|
||||
ExecutionFrameGuard + later action selector`, and with recall uplift for the
|
||||
selector (the §12 lexicon/word-order arms are the cheap lever) rather than
|
||||
more pragmatics training.
|
||||
|
||||
## 15. Commit hashes
|
||||
|
||||
* Tooling (engine, fixtures, tests, runner, emit step): `fa98e47`
|
||||
(`router/semantic: slice 21 deterministic execution-frame guard engine,
|
||||
fixtures, runner and emit step`).
|
||||
* Report + eval index: this file paired with its `docs/evals/CLAUDE.md` row.
|
||||
* Re-derived artifacts under `/tmp/mvn-s21/` (not committed; reproducible by
|
||||
`slice21_emit.py` then `go run ./cmd/semantic-router-experiment/slice21`).
|
||||
@@ -0,0 +1,635 @@
|
||||
# Semantic Router Linear Head Experiment — Report
|
||||
|
||||
## 1. Exact e5 representation used
|
||||
|
||||
- **Model**: model_quantized@384/tok2
|
||||
- **Checkpoint**: models/embedder/multilingual-e5-small/model_quantized.onnx
|
||||
- **Tokenizer**: models/embedder/multilingual-e5-small/tokenizer.json
|
||||
- **Dimension**: 384
|
||||
- **Pooling**: mean-pool + L2-normalize
|
||||
- **Normalization**: L2
|
||||
- **Input template**: query: <text>
|
||||
|
||||
## 2. Development/residual row counts
|
||||
|
||||
- Total corpus: 3025
|
||||
- Frozen holdout: 535
|
||||
- Development pool: 2490
|
||||
- Fast-path resolved: 82
|
||||
- Router-residual: 2943
|
||||
|
||||
Route distribution (full corpus):
|
||||
- action: 990
|
||||
- conversation: 103
|
||||
- knowledge: 880
|
||||
- memory_write: 705
|
||||
- system: 226
|
||||
- uncertain: 121
|
||||
|
||||
## 3. Grouped fold composition
|
||||
|
||||
Folds: 5
|
||||
- Fold 0: eval=476 train=2014 routes={'action': 144, 'conversation': 30, 'knowledge': 150, 'memory_write': 74, 'system': 58, 'uncertain': 20}
|
||||
- Fold 1: eval=341 train=2149 routes={'action': 201, 'conversation': 30, 'knowledge': 44, 'memory_write': 39, 'system': 9, 'uncertain': 18}
|
||||
- Fold 2: eval=707 train=1783 routes={'action': 297, 'conversation': 12, 'knowledge': 210, 'memory_write': 125, 'system': 45, 'uncertain': 18}
|
||||
- Fold 3: eval=506 train=1984 routes={'action': 86, 'conversation': 12, 'knowledge': 160, 'memory_write': 125, 'system': 90, 'uncertain': 33}
|
||||
- Fold 4: eval=460 train=2030 routes={'action': 68, 'conversation': 9, 'knowledge': 151, 'memory_write': 190, 'system': 24, 'uncertain': 18}
|
||||
|
||||
## 4. Selected regularization
|
||||
|
||||
### Experiment A: All development examples
|
||||
- Best C: 10.0
|
||||
- Mean accuracy: 69.2% ± 10.2%
|
||||
- Mean macro F1: 0.616 ± 0.093
|
||||
- Total false actions (CV): 390
|
||||
|
||||
### Experiment B: Router-residual only
|
||||
- Best C: 10.0
|
||||
- Mean accuracy: 68.5% ± 10.2%
|
||||
- Mean macro F1: 0.608 ± 0.089
|
||||
- Total false actions (CV): 388
|
||||
|
||||
### Stability across folds
|
||||
|
||||
C=0.01 acc=30.1%±18.1% f1=0.107±0.074 folds_acc=['33.6%', '21.4%', '63.8%', '17.0%', '14.8%']
|
||||
C=0.1 acc=55.0%±16.8% f1=0.276±0.077 folds_acc=['63.4%', '70.7%', '71.6%', '35.2%', '34.3%']
|
||||
C=1.0 acc=65.2%±10.7% f1=0.522±0.075 folds_acc=['75.4%', '76.0%', '68.9%', '56.9%', '48.7%']
|
||||
C=10.0 acc=69.2%±10.2% f1=0.616±0.093 folds_acc=['79.8%', '79.5%', '66.8%', '67.8%', '52.0%']
|
||||
C=100.0 acc=67.4%±9.0% f1=0.595±0.079 folds_acc=['74.4%', '77.4%', '66.5%', '67.2%', '51.3%']
|
||||
|
||||
## 5. All-example CV metrics
|
||||
|
||||
- Accuracy: 68.5%
|
||||
- Macro F1: 0.675
|
||||
- False-action count: 390
|
||||
- False-action rate: 15.7%
|
||||
- Action precision: 0.629
|
||||
- Action recall: 0.832
|
||||
- Uncertain precision: 0.618
|
||||
- Uncertain recall: 0.589
|
||||
|
||||
Per-class metrics:
|
||||
action P=0.629 R=0.832 F1=0.716 (n=796)
|
||||
conversation P=0.825 R=0.710 F1=0.763 (n=93)
|
||||
knowledge P=0.721 R=0.610 F1=0.661 (n=715)
|
||||
memory_write P=0.748 R=0.635 F1=0.687 (n=553)
|
||||
system P=0.698 R=0.562 F1=0.623 (n=226)
|
||||
uncertain P=0.618 R=0.589 F1=0.603 (n=107)
|
||||
|
||||
Confusion matrix (rows=expected, cols=predicted):
|
||||
action conversation knowledge memory_write system uncertain
|
||||
action 662 4 65 45 4 16
|
||||
conversation 2 66 8 4 4 9
|
||||
knowledge 195 0 436 46 34 4
|
||||
memory_write 135 0 49 351 13 5
|
||||
system 33 4 42 15 127 5
|
||||
uncertain 25 6 5 8 0 63
|
||||
|
||||
## 6. Residual-only CV metrics
|
||||
|
||||
- Accuracy: 67.9%
|
||||
- Macro F1: 0.663
|
||||
- False-action count: 388
|
||||
- False-action rate: 16.0%
|
||||
- Action precision: 0.620
|
||||
- Action recall: 0.826
|
||||
- Uncertain precision: 0.604
|
||||
- Uncertain recall: 0.570
|
||||
|
||||
Per-class metrics:
|
||||
action P=0.620 R=0.826 F1=0.708 (n=766)
|
||||
conversation P=0.807 R=0.720 F1=0.761 (n=93)
|
||||
knowledge P=0.713 R=0.606 F1=0.655 (n=715)
|
||||
memory_write P=0.754 R=0.649 F1=0.698 (n=553)
|
||||
system P=0.685 R=0.484 F1=0.567 (n=184)
|
||||
uncertain P=0.604 R=0.570 F1=0.587 (n=107)
|
||||
|
||||
Confusion matrix (rows=expected, cols=predicted):
|
||||
action conversation knowledge memory_write system uncertain
|
||||
action 633 4 69 43 0 17
|
||||
conversation 2 67 8 6 1 9
|
||||
knowledge 198 1 433 44 35 4
|
||||
memory_write 133 0 50 359 5 6
|
||||
system 29 5 42 15 89 4
|
||||
uncertain 26 6 5 9 0 61
|
||||
|
||||
## 7. Legacy-vs-linear comparison
|
||||
|
||||
### All examples
|
||||
metric legacy linear e5 delta
|
||||
-------------------------------------------------------
|
||||
accuracy 52.2% 68.5% 16.3%
|
||||
macro F1 — 0.675 —
|
||||
action precision — 0.629 —
|
||||
false-action rate 19.9% 15.7% -4.2%
|
||||
uncertain F1 0.000 0.603 0.603
|
||||
|
||||
### Router-residual only
|
||||
metric legacy linear e5 delta
|
||||
-------------------------------------------------------
|
||||
accuracy 40.8% 67.9% 27.1%
|
||||
macro F1 — 0.663 —
|
||||
false-action rate — 16.0% —
|
||||
|
||||
## 8. Fold variance
|
||||
|
||||
All-example CV:
|
||||
Fold 0: acc=79.8% f1=0.758 false_action=27
|
||||
Fold 1: acc=79.5% f1=0.594 false_action=40
|
||||
Fold 2: acc=66.8% f1=0.582 false_action=92
|
||||
Fold 3: acc=67.8% f1=0.667 false_action=102
|
||||
Fold 4: acc=52.0% f1=0.478 false_action=129
|
||||
|
||||
Residual-only CV:
|
||||
Fold 0: acc=78.7% f1=0.747 false_action=28
|
||||
Fold 1: acc=78.7% f1=0.590 false_action=38
|
||||
Fold 2: acc=67.6% f1=0.582 false_action=92
|
||||
Fold 3: acc=66.2% f1=0.648 false_action=104
|
||||
Fold 4: acc=51.0% f1=0.474 false_action=126
|
||||
|
||||
## 9. False-action repair/new-error analysis
|
||||
|
||||
Note: Legacy per-example predictions were not available for this experiment.
|
||||
The legacy baseline was measured in aggregate in the Go test suite.
|
||||
|
||||
Learned router false-action cases (out-of-fold):
|
||||
kq-deadline-1717: 'когда дедлайн по задаче, пожалуйста' (true=knowledge, proba(action)=0.559)
|
||||
kq-deadline-1720: 'я хочу я успеваю до дедлайна по задаче' (true=knowledge, proba(action)=0.491)
|
||||
kq-deadline-1724: 'я хочу когда дедлайн по задаче' (true=knowledge, proba(action)=0.566)
|
||||
kq-deadline-1725: 'надо бы когда дедлайн по задаче' (true=knowledge, proba(action)=0.526)
|
||||
kq-deadline-1729: 'по задаче когда дедлайн' (true=knowledge, proba(action)=0.361)
|
||||
mw-fact-pills-2224: 'принял таблетки сегодня' (true=memory_write, proba(action)=0.342)
|
||||
mw-fact-pills-2229: 'принял таблетки вечером, пожалуйста' (true=memory_write, proba(action)=0.416)
|
||||
mw-fact-pills-2230: 'принял таблетки сегодня, пожалуйста' (true=memory_write, proba(action)=0.282)
|
||||
mw-note-idea-2363: 'запиши идею: гидропоника на балконе, пожалуйста' (true=memory_write, proba(action)=0.395)
|
||||
mw-note-idea-2366: 'идея: гидропоника на балконе, пожалуйста' (true=memory_write, proba(action)=0.399)
|
||||
mw-note-idea-2367: 'идея: сервер в шкаф, пожалуйста' (true=memory_write, proba(action)=0.517)
|
||||
mw-note-idea-2369: '记住: гидропоника на балконе, пожалуйста' (true=memory_write, proba(action)=0.410)
|
||||
mw-note-idea-2370: '记住: сервер в шкаф, пожалуйста' (true=memory_write, proba(action)=0.539)
|
||||
mw-note-idea-2380: 'я хочу идея: сервер в шкаф' (true=memory_write, proba(action)=0.455)
|
||||
mw-note-idea-2386: 'я хочу 记住: сервер в шкаф' (true=memory_write, proba(action)=0.460)
|
||||
mw-note-idea-2393: 'гидропоника на балконе идея:' (true=memory_write, proba(action)=0.483)
|
||||
mw-note-idea-2396: 'гидропоника на балконе 记住:' (true=memory_write, proba(action)=0.464)
|
||||
sys-quiet-on-2577: 'я хочу режим тишина' (true=system, proba(action)=0.448)
|
||||
conv-goodbye-2869: 'до свидания, пожалуйста' (true=conversation, proba(action)=0.253)
|
||||
conv-goodbye-2875: 'я хочу до свидания' (true=conversation, proba(action)=0.213)
|
||||
unc-single-word-verb-2947: 'выключи' (true=uncertain, proba(action)=0.873)
|
||||
unc-single-word-verb-2948: 'включи' (true=uncertain, proba(action)=0.698)
|
||||
unc-single-word-verb-2949: 'перезапусти, пожалуйста' (true=uncertain, proba(action)=0.428)
|
||||
unc-single-word-verb-2950: 'выключи, пожалуйста' (true=uncertain, proba(action)=0.835)
|
||||
unc-single-word-verb-2951: 'включи, пожалуйста' (true=uncertain, proba(action)=0.691)
|
||||
unc-single-word-verb-2953: 'я хочу выключи' (true=uncertain, proba(action)=0.733)
|
||||
unc-single-word-verb-2954: 'я хочу включи' (true=uncertain, proba(action)=0.720)
|
||||
kq-homelab-disk-1494: 'хватает ли места на диске' (true=knowledge, proba(action)=0.635)
|
||||
kq-homelab-disk-1498: 'хватает ли места на диске, пожалуйста' (true=knowledge, proba(action)=0.664)
|
||||
kq-homelab-disk-1503: 'я хочу хватает ли места на диске' (true=knowledge, proba(action)=0.500)
|
||||
kq-homelab-disk-1504: 'надо бы хватает ли места на диске' (true=knowledge, proba(action)=0.630)
|
||||
kq-homelab-disk-1510: 'на диске хватает ли места' (true=knowledge, proba(action)=0.541)
|
||||
kq-task-check-1700: 'покажи задачи' (true=knowledge, proba(action)=0.859)
|
||||
kq-task-check-1702: 'какие задачи есть, пожалуйста' (true=knowledge, proba(action)=0.574)
|
||||
kq-task-check-1703: 'покажи задачи, пожалуйста' (true=knowledge, proba(action)=0.808)
|
||||
kq-task-check-1706: 'я хочу покажи задачи' (true=knowledge, proba(action)=0.715)
|
||||
kq-task-check-1709: 'покажи задачи?' (true=knowledge, proba(action)=0.514)
|
||||
mw-note-task-2429: 'добавь в задачи купить молоко' (true=memory_write, proba(action)=0.935)
|
||||
mw-note-task-2430: 'добавь в задачи починить кран' (true=memory_write, proba(action)=0.721)
|
||||
mw-note-task-2431: 'добавь в задачи обновить сервер' (true=memory_write, proba(action)=0.900)
|
||||
mw-note-task-2432: 'запиши задачу купить молоко' (true=memory_write, proba(action)=0.740)
|
||||
mw-note-task-2433: 'запиши задачу починить кран' (true=memory_write, proba(action)=0.548)
|
||||
mw-note-task-2434: 'запиши задачу обновить сервер' (true=memory_write, proba(action)=0.773)
|
||||
mw-note-task-2435: 'добавь в задачи купить молоко, пожалуйста' (true=memory_write, proba(action)=0.851)
|
||||
mw-note-task-2436: 'добавь в задачи починить кран, пожалуйста' (true=memory_write, proba(action)=0.743)
|
||||
mw-note-task-2437: 'добавь в задачи обновить сервер, пожалуйста' (true=memory_write, proba(action)=0.925)
|
||||
mw-note-task-2438: 'запиши задачу купить молоко, пожалуйста' (true=memory_write, proba(action)=0.617)
|
||||
mw-note-task-2439: 'запиши задачу починить кран, пожалуйста' (true=memory_write, proba(action)=0.549)
|
||||
mw-note-task-2440: 'запиши задачу обновить сервер, пожалуйста' (true=memory_write, proba(action)=0.739)
|
||||
mw-note-task-2441: 'я хочу добавь в задачи купить молоко' (true=memory_write, proba(action)=0.820)
|
||||
mw-note-task-2442: 'надо бы добавь в задачи купить молоко' (true=memory_write, proba(action)=0.791)
|
||||
mw-note-task-2443: 'я хочу добавь в задачи починить кран' (true=memory_write, proba(action)=0.568)
|
||||
mw-note-task-2444: 'надо бы добавь в задачи починить кран' (true=memory_write, proba(action)=0.503)
|
||||
mw-note-task-2445: 'я хочу добавь в задачи обновить сервер' (true=memory_write, proba(action)=0.870)
|
||||
mw-note-task-2446: 'надо бы добавь в задачи обновить сервер' (true=memory_write, proba(action)=0.724)
|
||||
mw-note-task-2447: 'я хочу запиши задачу купить молоко' (true=memory_write, proba(action)=0.658)
|
||||
mw-note-task-2448: 'надо бы запиши задачу купить молоко' (true=memory_write, proba(action)=0.676)
|
||||
mw-note-task-2449: 'я хочу запиши задачу починить кран' (true=memory_write, proba(action)=0.557)
|
||||
mw-note-task-2450: 'надо бы запиши задачу починить кран' (true=memory_write, proba(action)=0.458)
|
||||
mw-note-task-2451: 'я хочу запиши задачу обновить сервер' (true=memory_write, proba(action)=0.676)
|
||||
mw-note-task-2452: 'надо бы запиши задачу обновить сервер' (true=memory_write, proba(action)=0.495)
|
||||
mw-note-task-2453: 'купить молоко добавь в задачи' (true=memory_write, proba(action)=0.926)
|
||||
mw-note-task-2454: 'починить кран добавь в задачи' (true=memory_write, proba(action)=0.895)
|
||||
mw-note-task-2455: 'обновить сервер добавь в задачи' (true=memory_write, proba(action)=0.933)
|
||||
mw-note-task-2456: 'купить молоко запиши задачу' (true=memory_write, proba(action)=0.884)
|
||||
mw-note-task-2457: 'починить кран запиши задачу' (true=memory_write, proba(action)=0.701)
|
||||
mw-note-task-2458: 'обновить сервер запиши задачу' (true=memory_write, proba(action)=0.783)
|
||||
kq-cal-time-1063: 'когда планёрка, пожалуйста' (true=knowledge, proba(action)=0.499)
|
||||
kq-cal-time-1066: 'я хочу когда планёрка' (true=knowledge, proba(action)=0.457)
|
||||
kq-world-def-1205: 'что такое TCP' (true=knowledge, proba(action)=0.592)
|
||||
kq-world-def-1207: 'что такое Kubernetes' (true=knowledge, proba(action)=0.581)
|
||||
kq-world-def-1209: 'что такое React' (true=knowledge, proba(action)=0.396)
|
||||
kq-world-def-1210: 'кто такой TCP' (true=knowledge, proba(action)=0.428)
|
||||
kq-world-def-1215: 'что значит TCP' (true=knowledge, proba(action)=0.581)
|
||||
kq-world-def-1217: 'что значит Kubernetes' (true=knowledge, proba(action)=0.564)
|
||||
kq-world-def-1219: 'что значит React' (true=knowledge, proba(action)=0.441)
|
||||
kq-world-def-1220: 'что такое TCP, пожалуйста' (true=knowledge, proba(action)=0.785)
|
||||
kq-world-def-1221: 'что такое Docker, пожалуйста' (true=knowledge, proba(action)=0.456)
|
||||
kq-world-def-1222: 'что такое Kubernetes, пожалуйста' (true=knowledge, proba(action)=0.700)
|
||||
kq-world-def-1224: 'что такое React, пожалуйста' (true=knowledge, proba(action)=0.587)
|
||||
kq-world-def-1225: 'кто такой TCP, пожалуйста' (true=knowledge, proba(action)=0.615)
|
||||
kq-world-def-1227: 'кто такой Kubernetes, пожалуйста' (true=knowledge, proba(action)=0.687)
|
||||
kq-world-def-1229: 'кто такой React, пожалуйста' (true=knowledge, proba(action)=0.411)
|
||||
kq-world-def-1230: 'что значит TCP, пожалуйста' (true=knowledge, proba(action)=0.771)
|
||||
kq-world-def-1231: 'что значит Docker, пожалуйста' (true=knowledge, proba(action)=0.444)
|
||||
kq-world-def-1232: 'что значит Kubernetes, пожалуйста' (true=knowledge, proba(action)=0.639)
|
||||
kq-world-def-1234: 'что значит React, пожалуйста' (true=knowledge, proba(action)=0.561)
|
||||
kq-world-def-1235: 'я хочу что такое TCP' (true=knowledge, proba(action)=0.588)
|
||||
kq-world-def-1236: 'надо бы что такое TCP' (true=knowledge, proba(action)=0.415)
|
||||
kq-world-def-1239: 'я хочу что такое Kubernetes' (true=knowledge, proba(action)=0.442)
|
||||
kq-world-def-1240: 'надо бы что такое Kubernetes' (true=knowledge, proba(action)=0.460)
|
||||
kq-world-def-1243: 'я хочу что такое React' (true=knowledge, proba(action)=0.411)
|
||||
kq-world-def-1244: 'надо бы что такое React' (true=knowledge, proba(action)=0.331)
|
||||
kq-world-def-1245: 'я хочу кто такой TCP' (true=knowledge, proba(action)=0.350)
|
||||
kq-world-def-1249: 'я хочу кто такой Kubernetes' (true=knowledge, proba(action)=0.418)
|
||||
kq-world-def-1250: 'надо бы кто такой Kubernetes' (true=knowledge, proba(action)=0.358)
|
||||
kq-world-def-1255: 'я хочу что значит TCP' (true=knowledge, proba(action)=0.630)
|
||||
kq-world-def-1256: 'надо бы что значит TCP' (true=knowledge, proba(action)=0.442)
|
||||
kq-world-def-1259: 'я хочу что значит Kubernetes' (true=knowledge, proba(action)=0.576)
|
||||
kq-world-def-1263: 'я хочу что значит React' (true=knowledge, proba(action)=0.480)
|
||||
kq-world-def-1265: 'TCP что такое' (true=knowledge, proba(action)=0.510)
|
||||
kq-world-def-1267: 'Kubernetes что такое' (true=knowledge, proba(action)=0.483)
|
||||
kq-world-def-1269: 'React что такое' (true=knowledge, proba(action)=0.365)
|
||||
kq-world-def-1270: 'TCP кто такой' (true=knowledge, proba(action)=0.331)
|
||||
kq-world-def-1272: 'Kubernetes кто такой' (true=knowledge, proba(action)=0.406)
|
||||
kq-world-def-1275: 'TCP что значит' (true=knowledge, proba(action)=0.493)
|
||||
kq-world-def-1277: 'Kubernetes что значит' (true=knowledge, proba(action)=0.436)
|
||||
kq-world-def-1279: 'React что значит' (true=knowledge, proba(action)=0.358)
|
||||
kq-world-def-1280: 'что такое TCP?' (true=knowledge, proba(action)=0.624)
|
||||
kq-world-def-1282: 'что такое Kubernetes?' (true=knowledge, proba(action)=0.544)
|
||||
kq-world-def-1284: 'что такое React?' (true=knowledge, proba(action)=0.350)
|
||||
kq-world-def-1285: 'кто такой TCP?' (true=knowledge, proba(action)=0.407)
|
||||
kq-world-def-1289: 'кто такой React?' (true=knowledge, proba(action)=0.266)
|
||||
kq-world-def-1290: 'что значит TCP?' (true=knowledge, proba(action)=0.514)
|
||||
kq-world-def-1292: 'что значит Kubernetes?' (true=knowledge, proba(action)=0.509)
|
||||
kq-world-def-1294: 'что значит React?' (true=knowledge, proba(action)=0.356)
|
||||
kq-homelab-status-1429: 'как дела с бэкапами' (true=knowledge, proba(action)=0.442)
|
||||
kq-homelab-status-1431: 'как дела с доменом' (true=knowledge, proba(action)=0.518)
|
||||
kq-homelab-status-1433: 'есть новости по бэкапами, пожалуйста' (true=knowledge, proba(action)=0.389)
|
||||
kq-homelab-status-1435: 'есть новости по доменом, пожалуйста' (true=knowledge, proba(action)=0.497)
|
||||
kq-homelab-status-1439: 'что там с доменом, пожалуйста' (true=knowledge, proba(action)=0.418)
|
||||
kq-homelab-status-1441: 'как дела с бэкапами, пожалуйста' (true=knowledge, proba(action)=0.509)
|
||||
kq-homelab-status-1442: 'как дела с сервером, пожалуйста' (true=knowledge, proba(action)=0.569)
|
||||
kq-homelab-status-1443: 'как дела с доменом, пожалуйста' (true=knowledge, proba(action)=0.697)
|
||||
kq-homelab-status-1444: 'как дела с DNS, пожалуйста' (true=knowledge, proba(action)=0.452)
|
||||
kq-homelab-status-1461: 'я хочу как дела с бэкапами' (true=knowledge, proba(action)=0.434)
|
||||
kq-homelab-status-1462: 'надо бы как дела с бэкапами' (true=knowledge, proba(action)=0.437)
|
||||
kq-homelab-status-1465: 'я хочу как дела с доменом' (true=knowledge, proba(action)=0.427)
|
||||
kq-homelab-status-1471: 'доменом есть новости по' (true=knowledge, proba(action)=0.350)
|
||||
kq-homelab-status-1477: 'бэкапами как дела с' (true=knowledge, proba(action)=0.386)
|
||||
mw-fact-break-2172: 'час took a break' (true=memory_write, proba(action)=0.508)
|
||||
mw-fact-break-2173: 'пять минут took a break' (true=memory_write, proba(action)=0.416)
|
||||
sys-self-version-2739: 'текущая версия maven' (true=system, proba(action)=0.668)
|
||||
sys-self-version-2741: 'текущая версия праксиса' (true=system, proba(action)=0.363)
|
||||
sys-self-version-2742: 'what version maven' (true=system, proba(action)=0.750)
|
||||
sys-self-version-2743: 'what version нексуса' (true=system, proba(action)=0.395)
|
||||
sys-self-version-2744: 'what version праксиса' (true=system, proba(action)=0.570)
|
||||
sys-self-version-2745: 'какая версия maven, пожалуйста' (true=system, proba(action)=0.589)
|
||||
sys-self-version-2747: 'какая версия праксиса, пожалуйста' (true=system, proba(action)=0.396)
|
||||
sys-self-version-2748: 'текущая версия maven, пожалуйста' (true=system, proba(action)=0.737)
|
||||
sys-self-version-2749: 'текущая версия нексуса, пожалуйста' (true=system, proba(action)=0.384)
|
||||
sys-self-version-2750: 'текущая версия праксиса, пожалуйста' (true=system, proba(action)=0.486)
|
||||
sys-self-version-2751: 'what version maven, пожалуйста' (true=system, proba(action)=0.711)
|
||||
sys-self-version-2752: 'what version нексуса, пожалуйста' (true=system, proba(action)=0.394)
|
||||
sys-self-version-2753: 'what version праксиса, пожалуйста' (true=system, proba(action)=0.576)
|
||||
sys-self-version-2760: 'я хочу текущая версия maven' (true=system, proba(action)=0.601)
|
||||
sys-self-version-2761: 'надо бы текущая версия maven' (true=system, proba(action)=0.633)
|
||||
sys-self-version-2763: 'надо бы текущая версия нексуса' (true=system, proba(action)=0.409)
|
||||
sys-self-version-2764: 'я хочу текущая версия праксиса' (true=system, proba(action)=0.367)
|
||||
sys-self-version-2766: 'я хочу what version maven' (true=system, proba(action)=0.714)
|
||||
sys-self-version-2767: 'надо бы what version maven' (true=system, proba(action)=0.501)
|
||||
sys-self-version-2768: 'я хочу what version нексуса' (true=system, proba(action)=0.408)
|
||||
sys-self-version-2770: 'я хочу what version праксиса' (true=system, proba(action)=0.568)
|
||||
sys-self-version-2775: 'maven текущая версия' (true=system, proba(action)=0.734)
|
||||
sys-self-version-2776: 'нексуса текущая версия' (true=system, proba(action)=0.386)
|
||||
sys-self-version-2777: 'праксиса текущая версия' (true=system, proba(action)=0.557)
|
||||
sys-self-version-2778: 'maven what version' (true=system, proba(action)=0.712)
|
||||
sys-self-version-2779: 'нексуса what version' (true=system, proba(action)=0.441)
|
||||
sys-self-version-2780: 'праксиса what version' (true=system, proba(action)=0.582)
|
||||
kq-world-explain-1303: 'опиши машинное обучение' (true=knowledge, proba(action)=0.545)
|
||||
kq-world-explain-1309: 'объясни машинное обучение, пожалуйста' (true=knowledge, proba(action)=0.536)
|
||||
kq-world-explain-1312: 'опиши машинное обучение, пожалуйста' (true=knowledge, proba(action)=0.607)
|
||||
kq-world-explain-1330: 'надо бы опиши машинное обучение' (true=knowledge, proba(action)=0.376)
|
||||
kq-world-explain-1336: 'машинное обучение объясни' (true=knowledge, proba(action)=0.471)
|
||||
kq-world-explain-1339: 'машинное обучение опиши' (true=knowledge, proba(action)=0.587)
|
||||
kq-cap-ha-1734: 'ты можешь выключить свет' (true=knowledge, proba(action)=0.920)
|
||||
kq-cap-ha-1735: 'ты можешь выключить жалюзи' (true=knowledge, proba(action)=0.988)
|
||||
kq-cap-ha-1736: 'ты можешь выключить вытяжку' (true=knowledge, proba(action)=0.983)
|
||||
kq-cap-ha-1737: 'ты можешь выключить вентилятор' (true=knowledge, proba(action)=0.909)
|
||||
kq-cap-ha-1738: 'умеешь ли включить свет' (true=knowledge, proba(action)=0.779)
|
||||
kq-cap-ha-1739: 'умеешь ли включить жалюзи' (true=knowledge, proba(action)=0.942)
|
||||
kq-cap-ha-1740: 'умеешь ли включить вытяжку' (true=knowledge, proba(action)=0.898)
|
||||
kq-cap-ha-1741: 'умеешь ли включить вентилятор' (true=knowledge, proba(action)=0.508)
|
||||
kq-cap-ha-1742: 'сможешь открыть свет' (true=knowledge, proba(action)=0.919)
|
||||
kq-cap-ha-1743: 'сможешь открыть жалюзи' (true=knowledge, proba(action)=0.986)
|
||||
kq-cap-ha-1744: 'сможешь открыть вытяжку' (true=knowledge, proba(action)=0.981)
|
||||
kq-cap-ha-1745: 'сможешь открыть вентилятор' (true=knowledge, proba(action)=0.900)
|
||||
kq-cap-ha-1746: 'ты можешь выключить свет, пожалуйста' (true=knowledge, proba(action)=0.928)
|
||||
kq-cap-ha-1747: 'ты можешь выключить жалюзи, пожалуйста' (true=knowledge, proba(action)=0.988)
|
||||
kq-cap-ha-1748: 'ты можешь выключить вытяжку, пожалуйста' (true=knowledge, proba(action)=0.976)
|
||||
kq-cap-ha-1749: 'ты можешь выключить вентилятор, пожалуйста' (true=knowledge, proba(action)=0.938)
|
||||
kq-cap-ha-1750: 'умеешь ли включить свет, пожалуйста' (true=knowledge, proba(action)=0.846)
|
||||
kq-cap-ha-1751: 'умеешь ли включить жалюзи, пожалуйста' (true=knowledge, proba(action)=0.938)
|
||||
kq-cap-ha-1752: 'умеешь ли включить вытяжку, пожалуйста' (true=knowledge, proba(action)=0.902)
|
||||
kq-cap-ha-1753: 'умеешь ли включить вентилятор, пожалуйста' (true=knowledge, proba(action)=0.559)
|
||||
kq-cap-ha-1754: 'сможешь открыть свет, пожалуйста' (true=knowledge, proba(action)=0.892)
|
||||
kq-cap-ha-1755: 'сможешь открыть жалюзи, пожалуйста' (true=knowledge, proba(action)=0.978)
|
||||
kq-cap-ha-1756: 'сможешь открыть вытяжку, пожалуйста' (true=knowledge, proba(action)=0.975)
|
||||
kq-cap-ha-1757: 'сможешь открыть вентилятор, пожалуйста' (true=knowledge, proba(action)=0.929)
|
||||
kq-cap-ha-1758: 'я хочу ты можешь выключить свет' (true=knowledge, proba(action)=0.835)
|
||||
kq-cap-ha-1759: 'надо бы ты можешь выключить свет' (true=knowledge, proba(action)=0.746)
|
||||
kq-cap-ha-1760: 'я хочу ты можешь выключить жалюзи' (true=knowledge, proba(action)=0.951)
|
||||
kq-cap-ha-1761: 'надо бы ты можешь выключить жалюзи' (true=knowledge, proba(action)=0.961)
|
||||
kq-cap-ha-1762: 'я хочу ты можешь выключить вытяжку' (true=knowledge, proba(action)=0.932)
|
||||
kq-cap-ha-1763: 'надо бы ты можешь выключить вытяжку' (true=knowledge, proba(action)=0.952)
|
||||
kq-cap-ha-1764: 'я хочу ты можешь выключить вентилятор' (true=knowledge, proba(action)=0.837)
|
||||
kq-cap-ha-1765: 'надо бы ты можешь выключить вентилятор' (true=knowledge, proba(action)=0.754)
|
||||
kq-cap-ha-1766: 'я хочу умеешь ли включить свет' (true=knowledge, proba(action)=0.793)
|
||||
kq-cap-ha-1767: 'надо бы умеешь ли включить свет' (true=knowledge, proba(action)=0.640)
|
||||
kq-cap-ha-1768: 'я хочу умеешь ли включить жалюзи' (true=knowledge, proba(action)=0.892)
|
||||
kq-cap-ha-1769: 'надо бы умеешь ли включить жалюзи' (true=knowledge, proba(action)=0.784)
|
||||
kq-cap-ha-1770: 'я хочу умеешь ли включить вытяжку' (true=knowledge, proba(action)=0.822)
|
||||
kq-cap-ha-1771: 'надо бы умеешь ли включить вытяжку' (true=knowledge, proba(action)=0.798)
|
||||
kq-cap-ha-1774: 'я хочу сможешь открыть свет' (true=knowledge, proba(action)=0.840)
|
||||
kq-cap-ha-1775: 'надо бы сможешь открыть свет' (true=knowledge, proba(action)=0.838)
|
||||
kq-cap-ha-1776: 'я хочу сможешь открыть жалюзи' (true=knowledge, proba(action)=0.946)
|
||||
kq-cap-ha-1777: 'надо бы сможешь открыть жалюзи' (true=knowledge, proba(action)=0.940)
|
||||
kq-cap-ha-1778: 'я хочу сможешь открыть вытяжку' (true=knowledge, proba(action)=0.936)
|
||||
kq-cap-ha-1779: 'надо бы сможешь открыть вытяжку' (true=knowledge, proba(action)=0.933)
|
||||
kq-cap-ha-1780: 'я хочу сможешь открыть вентилятор' (true=knowledge, proba(action)=0.857)
|
||||
kq-cap-ha-1781: 'надо бы сможешь открыть вентилятор' (true=knowledge, proba(action)=0.700)
|
||||
kq-cap-ha-1782: 'свет ты можешь выключить' (true=knowledge, proba(action)=0.893)
|
||||
kq-cap-ha-1783: 'жалюзи ты можешь выключить' (true=knowledge, proba(action)=0.982)
|
||||
kq-cap-ha-1784: 'вытяжку ты можешь выключить' (true=knowledge, proba(action)=0.971)
|
||||
kq-cap-ha-1785: 'вентилятор ты можешь выключить' (true=knowledge, proba(action)=0.897)
|
||||
kq-cap-ha-1786: 'свет умеешь ли включить' (true=knowledge, proba(action)=0.786)
|
||||
kq-cap-ha-1787: 'жалюзи умеешь ли включить' (true=knowledge, proba(action)=0.946)
|
||||
kq-cap-ha-1788: 'вытяжку умеешь ли включить' (true=knowledge, proba(action)=0.924)
|
||||
kq-cap-ha-1789: 'вентилятор умеешь ли включить' (true=knowledge, proba(action)=0.674)
|
||||
kq-cap-ha-1790: 'свет сможешь открыть' (true=knowledge, proba(action)=0.931)
|
||||
kq-cap-ha-1791: 'жалюзи сможешь открыть' (true=knowledge, proba(action)=0.983)
|
||||
kq-cap-ha-1792: 'вытяжку сможешь открыть' (true=knowledge, proba(action)=0.967)
|
||||
kq-cap-ha-1793: 'вентилятор сможешь открыть' (true=knowledge, proba(action)=0.886)
|
||||
kq-cap-ha-1794: 'ты можешь выключить свет?' (true=knowledge, proba(action)=0.913)
|
||||
kq-cap-ha-1795: 'ты можешь выключить жалюзи?' (true=knowledge, proba(action)=0.980)
|
||||
kq-cap-ha-1796: 'ты можешь выключить вытяжку?' (true=knowledge, proba(action)=0.970)
|
||||
kq-cap-ha-1797: 'ты можешь выключить вентилятор?' (true=knowledge, proba(action)=0.862)
|
||||
kq-cap-ha-1798: 'умеешь ли включить свет?' (true=knowledge, proba(action)=0.709)
|
||||
kq-cap-ha-1799: 'умеешь ли включить жалюзи?' (true=knowledge, proba(action)=0.866)
|
||||
kq-cap-ha-1800: 'умеешь ли включить вытяжку?' (true=knowledge, proba(action)=0.862)
|
||||
kq-cap-ha-1802: 'сможешь открыть свет?' (true=knowledge, proba(action)=0.905)
|
||||
kq-cap-ha-1803: 'сможешь открыть жалюзи?' (true=knowledge, proba(action)=0.975)
|
||||
kq-cap-ha-1804: 'сможешь открыть вытяжку?' (true=knowledge, proba(action)=0.967)
|
||||
kq-cap-ha-1805: 'сможешь открыть вентилятор?' (true=knowledge, proba(action)=0.859)
|
||||
mw-fact-water-1881: 'воды попил стакан, пожалуйста' (true=memory_write, proba(action)=0.543)
|
||||
mw-fact-water-1882: 'воды попил литр, пожалуйста' (true=memory_write, proba(action)=0.444)
|
||||
mw-fact-water-1886: 'пил воду литр, пожалуйста' (true=memory_write, proba(action)=0.480)
|
||||
mw-fact-water-1894: 'я хочу выпил воды стакан' (true=memory_write, proba(action)=0.527)
|
||||
mw-fact-water-1896: 'я хочу выпил воды литр' (true=memory_write, proba(action)=0.465)
|
||||
mw-fact-water-1902: 'я хочу воды попил стакан' (true=memory_write, proba(action)=0.503)
|
||||
mw-fact-water-1904: 'я хочу воды попил литр' (true=memory_write, proba(action)=0.403)
|
||||
mw-fact-water-1910: 'я хочу пил воду стакан' (true=memory_write, proba(action)=0.468)
|
||||
mw-fact-water-1925: 'стакан выпил воды' (true=memory_write, proba(action)=0.575)
|
||||
mw-fact-water-1929: 'стакан воды попил' (true=memory_write, proba(action)=0.613)
|
||||
unc-ambiguous-noun-2892: 'вода' (true=uncertain, proba(action)=0.371)
|
||||
unc-ambiguous-noun-2893: 'бэкап' (true=uncertain, proba(action)=0.641)
|
||||
unc-ambiguous-noun-2894: 'сервер' (true=uncertain, proba(action)=0.505)
|
||||
unc-ambiguous-noun-2895: 'контейнер' (true=uncertain, proba(action)=0.978)
|
||||
unc-ambiguous-noun-2896: 'задача' (true=uncertain, proba(action)=0.746)
|
||||
unc-ambiguous-noun-2897: 'вода, пожалуйста' (true=uncertain, proba(action)=0.381)
|
||||
unc-ambiguous-noun-2898: 'бэкап, пожалуйста' (true=uncertain, proba(action)=0.636)
|
||||
unc-ambiguous-noun-2899: 'сервер, пожалуйста' (true=uncertain, proba(action)=0.720)
|
||||
unc-ambiguous-noun-2900: 'контейнер, пожалуйста' (true=uncertain, proba(action)=0.938)
|
||||
unc-ambiguous-noun-2901: 'задача, пожалуйста' (true=uncertain, proba(action)=0.742)
|
||||
unc-ambiguous-noun-2902: 'я хочу вода' (true=uncertain, proba(action)=0.476)
|
||||
unc-ambiguous-noun-2903: 'я хочу бэкап' (true=uncertain, proba(action)=0.731)
|
||||
unc-ambiguous-noun-2904: 'я хочу сервер' (true=uncertain, proba(action)=0.661)
|
||||
unc-ambiguous-noun-2905: 'я хочу контейнер' (true=uncertain, proba(action)=0.946)
|
||||
unc-ambiguous-noun-2906: 'я хочу задача' (true=uncertain, proba(action)=0.747)
|
||||
unc-multi-ambiguous-3001: 'всё ок' (true=uncertain, proba(action)=0.391)
|
||||
unc-multi-ambiguous-3007: 'я хочу всё ок' (true=uncertain, proba(action)=0.447)
|
||||
kq-cap-tool-1806: 'ты можешь перезапустить nginx' (true=knowledge, proba(action)=0.975)
|
||||
kq-cap-tool-1807: 'ты можешь перезапустить docker' (true=knowledge, proba(action)=0.936)
|
||||
kq-cap-tool-1808: 'ты можешь перезапустить сервер' (true=knowledge, proba(action)=0.736)
|
||||
kq-cap-tool-1809: 'сможешь остановить nginx' (true=knowledge, proba(action)=0.923)
|
||||
kq-cap-tool-1810: 'сможешь остановить docker' (true=knowledge, proba(action)=0.849)
|
||||
kq-cap-tool-1811: 'сможешь остановить сервер' (true=knowledge, proba(action)=0.802)
|
||||
kq-cap-tool-1812: 'умеешь ли проверить nginx' (true=knowledge, proba(action)=0.691)
|
||||
kq-cap-tool-1815: 'ты можешь перезапустить nginx, пожалуйста' (true=knowledge, proba(action)=0.975)
|
||||
kq-cap-tool-1816: 'ты можешь перезапустить docker, пожалуйста' (true=knowledge, proba(action)=0.942)
|
||||
kq-cap-tool-1817: 'ты можешь перезапустить сервер, пожалуйста' (true=knowledge, proba(action)=0.834)
|
||||
kq-cap-tool-1818: 'сможешь остановить nginx, пожалуйста' (true=knowledge, proba(action)=0.958)
|
||||
kq-cap-tool-1819: 'сможешь остановить docker, пожалуйста' (true=knowledge, proba(action)=0.890)
|
||||
kq-cap-tool-1820: 'сможешь остановить сервер, пожалуйста' (true=knowledge, proba(action)=0.877)
|
||||
kq-cap-tool-1821: 'умеешь ли проверить nginx, пожалуйста' (true=knowledge, proba(action)=0.761)
|
||||
kq-cap-tool-1824: 'я хочу ты можешь перезапустить nginx' (true=knowledge, proba(action)=0.937)
|
||||
kq-cap-tool-1825: 'надо бы ты можешь перезапустить nginx' (true=knowledge, proba(action)=0.934)
|
||||
kq-cap-tool-1826: 'я хочу ты можешь перезапустить docker' (true=knowledge, proba(action)=0.813)
|
||||
kq-cap-tool-1827: 'надо бы ты можешь перезапустить docker' (true=knowledge, proba(action)=0.787)
|
||||
kq-cap-tool-1828: 'я хочу ты можешь перезапустить сервер' (true=knowledge, proba(action)=0.676)
|
||||
kq-cap-tool-1829: 'надо бы ты можешь перезапустить сервер' (true=knowledge, proba(action)=0.570)
|
||||
kq-cap-tool-1830: 'я хочу сможешь остановить nginx' (true=knowledge, proba(action)=0.908)
|
||||
kq-cap-tool-1831: 'надо бы сможешь остановить nginx' (true=knowledge, proba(action)=0.918)
|
||||
kq-cap-tool-1832: 'я хочу сможешь остановить docker' (true=knowledge, proba(action)=0.806)
|
||||
kq-cap-tool-1833: 'надо бы сможешь остановить docker' (true=knowledge, proba(action)=0.789)
|
||||
kq-cap-tool-1834: 'я хочу сможешь остановить сервер' (true=knowledge, proba(action)=0.795)
|
||||
kq-cap-tool-1835: 'надо бы сможешь остановить сервер' (true=knowledge, proba(action)=0.721)
|
||||
kq-cap-tool-1836: 'я хочу умеешь ли проверить nginx' (true=knowledge, proba(action)=0.587)
|
||||
kq-cap-tool-1837: 'надо бы умеешь ли проверить nginx' (true=knowledge, proba(action)=0.505)
|
||||
kq-cap-tool-1842: 'nginx ты можешь перезапустить' (true=knowledge, proba(action)=0.943)
|
||||
kq-cap-tool-1843: 'docker ты можешь перезапустить' (true=knowledge, proba(action)=0.908)
|
||||
kq-cap-tool-1844: 'сервер ты можешь перезапустить' (true=knowledge, proba(action)=0.776)
|
||||
kq-cap-tool-1845: 'nginx сможешь остановить' (true=knowledge, proba(action)=0.880)
|
||||
kq-cap-tool-1846: 'docker сможешь остановить' (true=knowledge, proba(action)=0.790)
|
||||
kq-cap-tool-1847: 'сервер сможешь остановить' (true=knowledge, proba(action)=0.660)
|
||||
kq-cap-tool-1848: 'nginx умеешь ли проверить' (true=knowledge, proba(action)=0.633)
|
||||
kq-cap-tool-1851: 'ты можешь перезапустить nginx?' (true=knowledge, proba(action)=0.947)
|
||||
kq-cap-tool-1852: 'ты можешь перезапустить docker?' (true=knowledge, proba(action)=0.886)
|
||||
kq-cap-tool-1853: 'ты можешь перезапустить сервер?' (true=knowledge, proba(action)=0.661)
|
||||
kq-cap-tool-1854: 'сможешь остановить nginx?' (true=knowledge, proba(action)=0.916)
|
||||
kq-cap-tool-1855: 'сможешь остановить docker?' (true=knowledge, proba(action)=0.628)
|
||||
kq-cap-tool-1856: 'сможешь остановить сервер?' (true=knowledge, proba(action)=0.625)
|
||||
kq-cap-tool-1857: 'умеешь ли проверить nginx?' (true=knowledge, proba(action)=0.604)
|
||||
mw-fact-meal-1946: 'пообедал салатом' (true=memory_write, proba(action)=0.634)
|
||||
mw-fact-meal-1950: 'поужинал салатом' (true=memory_write, proba(action)=0.685)
|
||||
mw-fact-meal-1951: 'поужинал пиццей' (true=memory_write, proba(action)=0.493)
|
||||
mw-fact-meal-1955: 'ел пиццей' (true=memory_write, proba(action)=0.238)
|
||||
mw-fact-meal-1962: 'позавтракал салатом, пожалуйста' (true=memory_write, proba(action)=0.459)
|
||||
mw-fact-meal-1966: 'пообедал салатом, пожалуйста' (true=memory_write, proba(action)=0.656)
|
||||
mw-fact-meal-1968: 'поужинал овсянкой, пожалуйста' (true=memory_write, proba(action)=0.414)
|
||||
mw-fact-meal-1970: 'поужинал салатом, пожалуйста' (true=memory_write, proba(action)=0.662)
|
||||
mw-fact-meal-1974: 'ел салатом, пожалуйста' (true=memory_write, proba(action)=0.592)
|
||||
mw-fact-meal-1975: 'ел пиццей, пожалуйста' (true=memory_write, proba(action)=0.290)
|
||||
mw-fact-meal-1976: 'just ate овсянкой, пожалуйста' (true=memory_write, proba(action)=0.328)
|
||||
mw-fact-meal-1978: 'just ate салатом, пожалуйста' (true=memory_write, proba(action)=0.429)
|
||||
mw-fact-meal-1984: 'я хочу позавтракал салатом' (true=memory_write, proba(action)=0.483)
|
||||
mw-fact-meal-1992: 'я хочу пообедал салатом' (true=memory_write, proba(action)=0.626)
|
||||
mw-fact-meal-1993: 'надо бы пообедал салатом' (true=memory_write, proba(action)=0.528)
|
||||
mw-fact-meal-2000: 'я хочу поужинал салатом' (true=memory_write, proba(action)=0.675)
|
||||
mw-fact-meal-2001: 'надо бы поужинал салатом' (true=memory_write, proba(action)=0.483)
|
||||
mw-fact-meal-2010: 'я хочу ел пиццей' (true=memory_write, proba(action)=0.265)
|
||||
mw-fact-meal-2016: 'я хочу just ate салатом' (true=memory_write, proba(action)=0.561)
|
||||
mw-fact-meal-2024: 'овсянкой пообедал' (true=memory_write, proba(action)=0.385)
|
||||
mw-fact-meal-2026: 'салатом пообедал' (true=memory_write, proba(action)=0.612)
|
||||
mw-fact-meal-2027: 'пиццей пообедал' (true=memory_write, proba(action)=0.444)
|
||||
mw-fact-meal-2028: 'овсянкой поужинал' (true=memory_write, proba(action)=0.438)
|
||||
mw-fact-meal-2030: 'салатом поужинал' (true=memory_write, proba(action)=0.591)
|
||||
mw-fact-meal-2031: 'пиццей поужинал' (true=memory_write, proba(action)=0.571)
|
||||
mw-note-homelab-2401: 'заметка про 备份策略' (true=memory_write, proba(action)=0.633)
|
||||
mw-note-homelab-2404: 'запиши про настройку 备份策略' (true=memory_write, proba(action)=0.657)
|
||||
mw-note-homelab-2406: 'заметка про DNS записи, пожалуйста' (true=memory_write, proba(action)=0.442)
|
||||
mw-note-homelab-2407: 'заметка про 备份策略, пожалуйста' (true=memory_write, proba(action)=0.730)
|
||||
mw-note-homelab-2410: 'запиши про настройку 备份策略, пожалуйста' (true=memory_write, proba(action)=0.719)
|
||||
mw-note-homelab-2415: 'я хочу заметка про 备份策略' (true=memory_write, proba(action)=0.671)
|
||||
mw-note-homelab-2416: 'надо бы заметка про 备份策略' (true=memory_write, proba(action)=0.645)
|
||||
mw-note-homelab-2421: 'я хочу запиши про настройку 备份策略' (true=memory_write, proba(action)=0.561)
|
||||
mw-note-homelab-2422: 'надо бы запиши про настройку 备份策略' (true=memory_write, proba(action)=0.666)
|
||||
mw-note-homelab-2425: '备份策略 заметка про' (true=memory_write, proba(action)=0.742)
|
||||
mw-note-homelab-2428: '备份策略 запиши про настройку' (true=memory_write, proba(action)=0.633)
|
||||
mw-free-remember-2460: 'запомни пароль от wifi' (true=memory_write, proba(action)=0.836)
|
||||
mw-free-remember-2461: 'запомни адрес электрика' (true=memory_write, proba(action)=0.753)
|
||||
mw-free-remember-2462: 'сохрани что встреча в 3' (true=memory_write, proba(action)=0.457)
|
||||
mw-free-remember-2463: 'сохрани пароль от wifi' (true=memory_write, proba(action)=0.939)
|
||||
mw-free-remember-2464: 'сохрани адрес электрика' (true=memory_write, proba(action)=0.980)
|
||||
mw-free-remember-2466: 'занеси пароль от wifi' (true=memory_write, proba(action)=0.733)
|
||||
mw-free-remember-2467: 'занеси адрес электрика' (true=memory_write, proba(action)=0.734)
|
||||
mw-free-remember-2469: 'внеси пароль от wifi' (true=memory_write, proba(action)=0.847)
|
||||
mw-free-remember-2470: 'внеси адрес электрика' (true=memory_write, proba(action)=0.898)
|
||||
mw-free-remember-2472: 'запомни пароль от wifi, пожалуйста' (true=memory_write, proba(action)=0.866)
|
||||
mw-free-remember-2473: 'запомни адрес электрика, пожалуйста' (true=memory_write, proba(action)=0.734)
|
||||
mw-free-remember-2474: 'сохрани что встреча в 3, пожалуйста' (true=memory_write, proba(action)=0.595)
|
||||
mw-free-remember-2475: 'сохрани пароль от wifi, пожалуйста' (true=memory_write, proba(action)=0.942)
|
||||
mw-free-remember-2476: 'сохрани адрес электрика, пожалуйста' (true=memory_write, proba(action)=0.935)
|
||||
mw-free-remember-2478: 'занеси пароль от wifi, пожалуйста' (true=memory_write, proba(action)=0.830)
|
||||
mw-free-remember-2479: 'занеси адрес электрика, пожалуйста' (true=memory_write, proba(action)=0.807)
|
||||
mw-free-remember-2481: 'внеси пароль от wifi, пожалуйста' (true=memory_write, proba(action)=0.942)
|
||||
mw-free-remember-2482: 'внеси адрес электрика, пожалуйста' (true=memory_write, proba(action)=0.946)
|
||||
mw-free-remember-2485: 'я хочу запомни пароль от wifi' (true=memory_write, proba(action)=0.692)
|
||||
mw-free-remember-2486: 'надо бы запомни пароль от wifi' (true=memory_write, proba(action)=0.698)
|
||||
mw-free-remember-2487: 'я хочу запомни адрес электрика' (true=memory_write, proba(action)=0.768)
|
||||
mw-free-remember-2488: 'надо бы запомни адрес электрика' (true=memory_write, proba(action)=0.706)
|
||||
mw-free-remember-2489: 'я хочу сохрани что встреча в 3' (true=memory_write, proba(action)=0.585)
|
||||
mw-free-remember-2490: 'надо бы сохрани что встреча в 3' (true=memory_write, proba(action)=0.465)
|
||||
mw-free-remember-2491: 'я хочу сохрани пароль от wifi' (true=memory_write, proba(action)=0.826)
|
||||
mw-free-remember-2492: 'надо бы сохрани пароль от wifi' (true=memory_write, proba(action)=0.868)
|
||||
mw-free-remember-2493: 'я хочу сохрани адрес электрика' (true=memory_write, proba(action)=0.914)
|
||||
mw-free-remember-2494: 'надо бы сохрани адрес электрика' (true=memory_write, proba(action)=0.932)
|
||||
mw-free-remember-2497: 'я хочу занеси пароль от wifi' (true=memory_write, proba(action)=0.606)
|
||||
mw-free-remember-2498: 'надо бы занеси пароль от wifi' (true=memory_write, proba(action)=0.775)
|
||||
mw-free-remember-2499: 'я хочу занеси адрес электрика' (true=memory_write, proba(action)=0.723)
|
||||
mw-free-remember-2500: 'надо бы занеси адрес электрика' (true=memory_write, proba(action)=0.776)
|
||||
mw-free-remember-2503: 'я хочу внеси пароль от wifi' (true=memory_write, proba(action)=0.738)
|
||||
mw-free-remember-2504: 'надо бы внеси пароль от wifi' (true=memory_write, proba(action)=0.895)
|
||||
mw-free-remember-2505: 'я хочу внеси адрес электрика' (true=memory_write, proba(action)=0.946)
|
||||
mw-free-remember-2506: 'надо бы внеси адрес электрика' (true=memory_write, proba(action)=0.955)
|
||||
mw-free-remember-2508: 'пароль от wifi запомни' (true=memory_write, proba(action)=0.843)
|
||||
mw-free-remember-2509: 'адрес электрика запомни' (true=memory_write, proba(action)=0.817)
|
||||
mw-free-remember-2510: 'что встреча в 3 сохрани' (true=memory_write, proba(action)=0.582)
|
||||
mw-free-remember-2511: 'пароль от wifi сохрани' (true=memory_write, proba(action)=0.955)
|
||||
mw-free-remember-2512: 'адрес электрика сохрани' (true=memory_write, proba(action)=0.974)
|
||||
mw-free-remember-2514: 'пароль от wifi занеси' (true=memory_write, proba(action)=0.672)
|
||||
mw-free-remember-2515: 'адрес электрика занеси' (true=memory_write, proba(action)=0.541)
|
||||
mw-free-remember-2517: 'пароль от wifi внеси' (true=memory_write, proba(action)=0.838)
|
||||
mw-free-remember-2518: 'адрес электрика внеси' (true=memory_write, proba(action)=0.781)
|
||||
sys-quiet-off-2583: 'громкий режим' (true=system, proba(action)=0.559)
|
||||
sys-quiet-off-2584: 'выключи тихий' (true=system, proba(action)=0.372)
|
||||
sys-quiet-off-2588: 'громкий режим, пожалуйста' (true=system, proba(action)=0.528)
|
||||
sys-quiet-off-2589: 'выключи тихий, пожалуйста' (true=system, proba(action)=0.470)
|
||||
sys-quiet-off-2593: 'я хочу громкий режим' (true=system, proba(action)=0.381)
|
||||
unc-anaphora-2922: 'сделай это, пожалуйста' (true=uncertain, proba(action)=0.312)
|
||||
|
||||
## 10. Contrast-family results
|
||||
|
||||
### Experiment A (all dev)
|
||||
family count correct accuracy false_act
|
||||
------------------------------------------------------------
|
||||
question 123 91 74.0% 27
|
||||
capability_question 126 15 11.9% 111
|
||||
|
||||
### Experiment B (residual only)
|
||||
family count correct accuracy false_act
|
||||
------------------------------------------------------------
|
||||
question 123 91 74.0% 27
|
||||
capability_question 126 14 11.1% 112
|
||||
|
||||
## 11. Calibration metrics
|
||||
|
||||
### Experiment A
|
||||
- ECE: 0.045
|
||||
- Brier score: 0.459
|
||||
- Log loss: 0.929
|
||||
|
||||
### Experiment B
|
||||
- ECE: 0.046
|
||||
- Brier score: 0.467
|
||||
- Log loss: 0.946
|
||||
|
||||
## 12. Abstention curves
|
||||
|
||||
### Experiment A (all dev)
|
||||
threshold n_accepted coverage accuracy macro_f1 false_act
|
||||
--------------------------------------------------------------
|
||||
0.40 2293 92.1% 70.7% 0.706 351
|
||||
0.50 1968 79.0% 74.5% 0.751 289
|
||||
0.60 1584 63.6% 78.0% 0.781 227
|
||||
0.70 1244 50.0% 80.9% 0.812 177
|
||||
0.80 851 34.2% 84.1% 0.883 120
|
||||
0.90 438 17.6% 83.3% 0.830 73
|
||||
|
||||
### Experiment B (residual only)
|
||||
threshold n_accepted coverage accuracy macro_f1 false_act
|
||||
--------------------------------------------------------------
|
||||
0.40 2244 92.8% 70.4% 0.700 346
|
||||
0.50 1941 80.3% 73.6% 0.733 285
|
||||
0.60 1571 65.0% 77.3% 0.771 229
|
||||
0.70 1228 50.8% 79.9% 0.792 178
|
||||
0.80 845 34.9% 83.6% 0.873 120
|
||||
0.90 431 17.8% 81.9% 0.827 78
|
||||
|
||||
## 13. Action-threshold curve
|
||||
|
||||
### Experiment A
|
||||
threshold action_P action_R false_act
|
||||
----------------------------------------
|
||||
0.40 0.645 0.803 351
|
||||
0.50 0.672 0.745 289
|
||||
0.60 0.697 0.655 227
|
||||
0.70 0.708 0.539 177
|
||||
0.80 0.733 0.413 120
|
||||
0.90 0.721 0.237 73
|
||||
|
||||
### Experiment B
|
||||
threshold action_P action_R false_act
|
||||
----------------------------------------
|
||||
0.40 0.640 0.803 346
|
||||
0.50 0.664 0.736 285
|
||||
0.60 0.687 0.655 229
|
||||
0.70 0.698 0.537 178
|
||||
0.80 0.722 0.407 120
|
||||
0.90 0.690 0.227 78
|
||||
|
||||
## 14. Model artifact size and runtime cost
|
||||
|
||||
- Trainable parameters: 2310
|
||||
- 6 classes × 384 features = 2304 weights
|
||||
- 6 bias terms
|
||||
- Serialized head size: 9240 bytes (9.0 KB)
|
||||
- Additional inference FLOPs: 2304 multiply-accumulates
|
||||
- Incremental cost (e5 already computed): ~2304 FLOPs, <1µs
|
||||
- Cost if semantic router must trigger its own e5: full ONNX inference (~384 × 128 × 12 = ~590K FLOPs)
|
||||
|
||||
## 16. Recommendation
|
||||
|
||||
**linear head sufficient**
|
||||
|
||||
Residual macro F1 of 0.663 and accuracy of 67.9% exceed the legacy baseline (40.8% residual accuracy) by a meaningful margin. A linear head over frozen e5-small embeddings is a viable first production candidate.
|
||||
|
||||
## 17. Commit hash for experiment tooling
|
||||
|
||||
`59a0a08d329fbcbefad4ec858cf8c6cc07014a36`
|
||||
@@ -0,0 +1,263 @@
|
||||
# Semantic Router Linear Head Experiment — Report
|
||||
|
||||
## 1. Exact e5 representation used
|
||||
|
||||
- **Model**: model_quantized@384/tok2
|
||||
- **Checkpoint**: models/embedder/multilingual-e5-small/model_quantized.onnx
|
||||
- **Tokenizer**: models/embedder/multilingual-e5-small/tokenizer.json
|
||||
- **Dimension**: 384
|
||||
- **Pooling**: mean-pool + L2-normalize
|
||||
- **Normalization**: L2
|
||||
- **Input template**: query: <text>
|
||||
|
||||
## 2. Development/residual row counts
|
||||
|
||||
- Total corpus: 136
|
||||
- Frozen holdout: 21
|
||||
- Development pool: 115
|
||||
- Fast-path resolved: 23
|
||||
- Router-residual: 113
|
||||
|
||||
Route distribution (full corpus):
|
||||
- action: 22
|
||||
- conversation: 7
|
||||
- knowledge: 46
|
||||
- memory_write: 19
|
||||
- system: 7
|
||||
- uncertain: 35
|
||||
|
||||
## 3. Grouped fold composition
|
||||
|
||||
Folds: 5
|
||||
- Fold 0: eval=33 train=82 routes={'action': 4, 'conversation': 1, 'knowledge': 11, 'memory_write': 3, 'system': 1, 'uncertain': 13}
|
||||
- Fold 1: eval=16 train=99 routes={'action': 3, 'conversation': 1, 'knowledge': 5, 'memory_write': 4, 'system': 1, 'uncertain': 2}
|
||||
- Fold 2: eval=22 train=93 routes={'action': 3, 'conversation': 1, 'knowledge': 7, 'memory_write': 4, 'system': 2, 'uncertain': 5}
|
||||
- Fold 3: eval=22 train=93 routes={'action': 4, 'conversation': 1, 'knowledge': 6, 'memory_write': 4, 'system': 2, 'uncertain': 5}
|
||||
- Fold 4: eval=22 train=93 routes={'action': 4, 'conversation': 2, 'knowledge': 8, 'memory_write': 2, 'system': 1, 'uncertain': 5}
|
||||
|
||||
## 4. Selected regularization
|
||||
|
||||
### Experiment A: All development examples
|
||||
- Best C: 100.0
|
||||
- Mean accuracy: 65.2% ± 6.2%
|
||||
- Mean macro F1: 0.597 ± 0.070
|
||||
- Total false actions (CV): 10
|
||||
|
||||
### Experiment B: Router-residual only
|
||||
- Best C: 100.0
|
||||
- Mean accuracy: 66.5% ± 7.6%
|
||||
- Mean macro F1: 0.475 ± 0.083
|
||||
- Total false actions (CV): 0
|
||||
|
||||
### Stability across folds
|
||||
|
||||
C=0.01 acc=32.0%±3.0% f1=0.081±0.006 folds_acc=['33.3%', '31.2%', '31.8%', '27.3%', '36.4%']
|
||||
C=0.1 acc=32.0%±3.0% f1=0.081±0.006 folds_acc=['33.3%', '31.2%', '31.8%', '27.3%', '36.4%']
|
||||
C=1.0 acc=44.8%±7.0% f1=0.190±0.049 folds_acc=['36.4%', '37.5%', '50.0%', '45.5%', '54.5%']
|
||||
C=10.0 acc=58.3%±4.6% f1=0.403±0.086 folds_acc=['51.5%', '62.5%', '63.6%', '59.1%', '54.5%']
|
||||
C=100.0 acc=65.2%±6.2% f1=0.597±0.070 folds_acc=['54.5%', '62.5%', '68.2%', '68.2%', '72.7%']
|
||||
|
||||
## 5. All-example CV metrics
|
||||
|
||||
- Accuracy: 64.3%
|
||||
- Macro F1: 0.620
|
||||
- False-action count: 10
|
||||
- False-action rate: 8.7%
|
||||
- Action precision: 0.545
|
||||
- Action recall: 0.667
|
||||
- Uncertain precision: 0.800
|
||||
- Uncertain recall: 0.533
|
||||
|
||||
Per-class metrics:
|
||||
action P=0.545 R=0.667 F1=0.600 (n=18)
|
||||
conversation P=1.000 R=0.500 F1=0.667 (n=6)
|
||||
knowledge P=0.756 R=0.838 F1=0.795 (n=37)
|
||||
memory_write P=0.346 R=0.529 F1=0.419 (n=17)
|
||||
system P=1.000 R=0.429 F1=0.600 (n=7)
|
||||
uncertain P=0.800 R=0.533 F1=0.640 (n=30)
|
||||
|
||||
Confusion matrix (rows=expected, cols=predicted):
|
||||
action conversation knowledge memory_write system uncertain
|
||||
action 12 0 0 5 0 1
|
||||
conversation 0 3 1 0 0 2
|
||||
knowledge 4 0 31 2 0 0
|
||||
memory_write 4 0 3 9 0 1
|
||||
system 2 0 2 0 3 0
|
||||
uncertain 0 0 4 10 0 16
|
||||
|
||||
## 6. Residual-only CV metrics
|
||||
|
||||
- Accuracy: 64.6%
|
||||
- Macro F1: 0.429
|
||||
- False-action count: 0
|
||||
- False-action rate: 0.0%
|
||||
- Action precision: 0.000
|
||||
- Action recall: 0.000
|
||||
- Uncertain precision: 0.667
|
||||
- Uncertain recall: 0.533
|
||||
|
||||
Per-class metrics:
|
||||
action P=0.000 R=0.000 F1=0.000 (n=5)
|
||||
conversation P=1.000 R=0.500 F1=0.667 (n=6)
|
||||
knowledge P=0.750 R=0.917 F1=0.825 (n=36)
|
||||
memory_write P=0.400 R=0.625 F1=0.488 (n=16)
|
||||
system P=0.000 R=0.000 F1=0.000 (n=3)
|
||||
uncertain P=0.667 R=0.533 F1=0.593 (n=30)
|
||||
|
||||
Confusion matrix (rows=expected, cols=predicted):
|
||||
action conversation knowledge memory_write system uncertain
|
||||
action 0 0 0 2 0 3
|
||||
conversation 0 3 1 0 0 2
|
||||
knowledge 0 0 33 3 0 0
|
||||
memory_write 0 0 4 10 0 2
|
||||
system 0 0 2 0 0 1
|
||||
uncertain 0 0 4 10 0 16
|
||||
|
||||
## 7. Legacy-vs-linear comparison
|
||||
|
||||
### All examples
|
||||
metric legacy linear e5 delta
|
||||
-------------------------------------------------------
|
||||
accuracy 52.2% 64.3% 12.1%
|
||||
macro F1 — 0.620 —
|
||||
action precision — 0.545 —
|
||||
false-action rate 19.9% 8.7% -11.2%
|
||||
uncertain F1 0.000 0.640 0.640
|
||||
|
||||
### Router-residual only
|
||||
metric legacy linear e5 delta
|
||||
-------------------------------------------------------
|
||||
accuracy 40.8% 64.6% 23.8%
|
||||
macro F1 — 0.429 —
|
||||
false-action rate — 0.0% —
|
||||
|
||||
## 8. Fold variance
|
||||
|
||||
All-example CV:
|
||||
Fold 0: acc=54.5% f1=0.507 false_action=1
|
||||
Fold 1: acc=62.5% f1=0.579 false_action=2
|
||||
Fold 2: acc=68.2% f1=0.554 false_action=2
|
||||
Fold 3: acc=68.2% f1=0.632 false_action=5
|
||||
Fold 4: acc=72.7% f1=0.712 false_action=0
|
||||
|
||||
Residual-only CV:
|
||||
Fold 0: acc=53.6% f1=0.500 false_action=0
|
||||
Fold 1: acc=75.0% f1=0.573 false_action=0
|
||||
Fold 2: acc=72.2% f1=0.411 false_action=0
|
||||
Fold 3: acc=68.4% f1=0.541 false_action=0
|
||||
Fold 4: acc=63.2% f1=0.351 false_action=0
|
||||
|
||||
## 9. False-action repair/new-error analysis
|
||||
|
||||
Note: Legacy per-example predictions were not available for this experiment.
|
||||
The legacy baseline was measured in aggregate in the Go test suite.
|
||||
|
||||
Learned router false-action cases (out-of-fold):
|
||||
en-query-003: 'show me this week's weight' (true=knowledge, proba(action)=0.402)
|
||||
ru-query-014: 'я успеваю до дедлайна' (true=knowledge, proba(action)=0.343)
|
||||
ru-note-003: 'заметка про настройку vlan на свитче' (true=memory_write, proba(action)=0.335)
|
||||
ru-query-005: 'напоминания на завтра есть' (true=knowledge, proba(action)=0.336)
|
||||
ru-fact-009: 'отметь что я выпил таблетки утром' (true=memory_write, proba(action)=0.437)
|
||||
ru-query-011: 'почему сервер тормозит' (true=knowledge, proba(action)=0.381)
|
||||
ru-fact-005: 'поспал часов пять' (true=memory_write, proba(action)=0.557)
|
||||
ru-note-006: 'добавь в задачи купить молоко' (true=memory_write, proba(action)=0.739)
|
||||
ru-sys-003: 'переходи в тихий режим' (true=system, proba(action)=0.404)
|
||||
en-sys-002: 'turn quiet mode back on' (true=system, proba(action)=0.410)
|
||||
|
||||
## 10. Contrast-family results
|
||||
|
||||
### Experiment A (all dev)
|
||||
family count correct accuracy false_act
|
||||
------------------------------------------------------------
|
||||
negation 5 3 60.0% 0
|
||||
question 5 5 100.0% 0
|
||||
reported_speech 6 3 50.0% 0
|
||||
quotation 6 4 66.7% 0
|
||||
hypothetical 6 3 50.0% 0
|
||||
capability_question 6 6 100.0% 0
|
||||
|
||||
### Experiment B (residual only)
|
||||
family count correct accuracy false_act
|
||||
------------------------------------------------------------
|
||||
negation 5 3 60.0% 0
|
||||
question 5 5 100.0% 0
|
||||
reported_speech 6 3 50.0% 0
|
||||
quotation 6 4 66.7% 0
|
||||
hypothetical 6 3 50.0% 0
|
||||
capability_question 6 6 100.0% 0
|
||||
|
||||
## 11. Calibration metrics
|
||||
|
||||
### Experiment A
|
||||
- ECE: 0.116
|
||||
- Brier score: 0.418
|
||||
- Log loss: 0.832
|
||||
|
||||
### Experiment B
|
||||
- ECE: 0.159
|
||||
- Brier score: 0.411
|
||||
- Log loss: 0.804
|
||||
|
||||
## 12. Abstention curves
|
||||
|
||||
### Experiment A (all dev)
|
||||
threshold n_accepted coverage accuracy macro_f1 false_act
|
||||
--------------------------------------------------------------
|
||||
0.40 98 85.2% 72.4% 0.654 6
|
||||
0.50 75 65.2% 84.0% 0.690 2
|
||||
0.60 56 48.7% 92.9% 0.851 1
|
||||
0.70 42 36.5% 97.6% 0.714 1
|
||||
0.80 28 24.3% 100.0% 1.000 0
|
||||
0.90 10 8.7% 100.0% 1.000 0
|
||||
|
||||
### Experiment B (residual only)
|
||||
threshold n_accepted coverage accuracy macro_f1 false_act
|
||||
--------------------------------------------------------------
|
||||
0.40 86 89.6% 67.4% 0.435 0
|
||||
0.50 70 72.9% 78.6% 0.583 0
|
||||
0.60 56 58.3% 91.1% 0.721 0
|
||||
0.70 46 47.9% 95.7% 0.904 0
|
||||
0.80 32 33.3% 96.9% 0.880 0
|
||||
0.90 12 12.5% 100.0% 1.000 0
|
||||
|
||||
## 13. Action-threshold curve
|
||||
|
||||
### Experiment A
|
||||
threshold action_P action_R false_act
|
||||
----------------------------------------
|
||||
0.40 0.667 0.667 6
|
||||
0.50 0.846 0.611 2
|
||||
0.60 0.857 0.333 1
|
||||
0.70 0.750 0.167 1
|
||||
0.80 1.000 0.056 0
|
||||
0.90 0.000 0.000 0
|
||||
|
||||
### Experiment B
|
||||
threshold action_P action_R false_act
|
||||
----------------------------------------
|
||||
0.40 0.000 0.000 0
|
||||
0.50 0.000 0.000 0
|
||||
0.60 0.000 0.000 0
|
||||
0.70 0.000 0.000 0
|
||||
0.80 0.000 0.000 0
|
||||
0.90 0.000 0.000 0
|
||||
|
||||
## 14. Model artifact size and runtime cost
|
||||
|
||||
- Trainable parameters: 2310
|
||||
- 6 classes × 384 features = 2304 weights
|
||||
- 6 bias terms
|
||||
- Serialized head size: 9240 bytes (9.0 KB)
|
||||
- Additional inference FLOPs: 2304 multiply-accumulates
|
||||
- Incremental cost (e5 already computed): ~2304 FLOPs, <1µs
|
||||
- Cost if semantic router must trigger its own e5: full ONNX inference (~384 × 128 × 12 = ~590K FLOPs)
|
||||
|
||||
## 16. Recommendation
|
||||
|
||||
**need more data**
|
||||
|
||||
All-example F1 (0.620) is acceptable but residual-only F1 (0.429) drops, suggesting the contrast-family examples are hard for a linear classifier. More contrastive training data may help.
|
||||
|
||||
## 17. Commit hash for experiment tooling
|
||||
|
||||
`59a0a08d329fbcbefad4ec858cf8c6cc07014a36`
|
||||
@@ -0,0 +1,333 @@
|
||||
# Slice 17: Nonlinear MLP Probe — Action Gate Experiment
|
||||
|
||||
## 0. Frozen Artifacts from Slice 16
|
||||
|
||||
```text
|
||||
development corpus v2 hash: b27fd48f478ca477
|
||||
original frozen holdout hash: ad297fbdbbea704b (byte-identical, uninspected)
|
||||
embedder: model_quantized@384/tok2
|
||||
dimension: 384
|
||||
pooling: mean-pool + L2-normalize
|
||||
input template: query: <text>
|
||||
embedding file: /tmp/mvn-experiment/embeddings.json
|
||||
total examples: 3025
|
||||
dev pool: 2490
|
||||
frozen holdout: 535
|
||||
router-residual: 2943
|
||||
```
|
||||
|
||||
## 1. Hidden Sizes and Exact Parameter Counts
|
||||
|
||||
### Binary action gate: e5[384] → Linear(384→H) → ReLU → Linear(H→1)
|
||||
|
||||
H params fp32 bytes int8 bytes
|
||||
----------------------------------------
|
||||
8 3,089 12,356 3,089
|
||||
16 6,177 24,708 6,177
|
||||
32 12,353 49,412 12,353
|
||||
64 24,705 98,820 24,705
|
||||
|
||||
### Six-way MLP: e5[384] → Linear(384→H) → ReLU → Linear(H→6)
|
||||
|
||||
H params fp32 bytes int8 bytes
|
||||
----------------------------------------
|
||||
8 3,134 12,536 3,134
|
||||
16 6,262 25,048 6,262
|
||||
32 12,518 50,072 12,518
|
||||
64 25,030 100,120 25,030
|
||||
|
||||
## 2. Selected Regularization
|
||||
|
||||
Best binary MLP: H=32, weight_decay=0.01
|
||||
Selected by grouped development CV PR-AUC.
|
||||
|
||||
Grid results (binary MLP):
|
||||
|
||||
H wd PR-AUC ROC-AUC action_P action_R FA count FA rate
|
||||
--------------------------------------------------------------------------------
|
||||
16 0.0 0.422 0.628 0.139 0.215 155 6.2%
|
||||
16 0.0001 0.422 0.628 0.139 0.215 155 6.2%
|
||||
16 0.001 0.423 0.628 0.139 0.215 155 6.2%
|
||||
16 0.01 0.423 0.628 0.139 0.215 155 6.2%
|
||||
32 0.0 0.691 0.854 0.686 0.610 219 8.8%
|
||||
32 0.0001 0.691 0.854 0.686 0.610 219 8.8%
|
||||
32 0.001 0.691 0.854 0.686 0.611 219 8.8%
|
||||
32 0.01 0.692 0.854 0.686 0.605 220 8.8% *
|
||||
64 0.0 0.547 0.714 0.309 0.381 195 7.8%
|
||||
64 0.0001 0.547 0.714 0.309 0.381 195 7.8%
|
||||
64 0.001 0.547 0.714 0.309 0.381 195 7.8%
|
||||
64 0.01 0.545 0.713 0.308 0.368 194 7.8%
|
||||
|
||||
## 3. Binary MLP OOF Metrics (best: H=32, wd=0.01)
|
||||
|
||||
```text
|
||||
ROC-AUC: 0.854
|
||||
PR-AUC: 0.692
|
||||
action precision: 0.686
|
||||
action recall: 0.605
|
||||
false-positive: 220
|
||||
false-negative: 343
|
||||
false-action rate: 8.8%
|
||||
total: 2490
|
||||
mean iters: 64
|
||||
```
|
||||
|
||||
## 4. Fold Variance
|
||||
|
||||
Binary MLP (H=32, wd=0.01):
|
||||
|
||||
fold ROC-AUC PR-AUC action_P action_R FP FN n
|
||||
------------------------------------------------------------
|
||||
0 0.960 0.915 0.935 0.701 7 43 476
|
||||
1 0.896 0.917 0.840 0.861 33 28 341
|
||||
2 0.953 0.939 0.988 0.286 1 212 707
|
||||
3 0.825 0.454 0.440 0.767 84 20 506
|
||||
4 0.638 0.236 0.228 0.412 95 40 460
|
||||
|
||||
Fold variance comparison with linear binary probe:
|
||||
fold linear FP MLP FP linear FA% MLP FA%
|
||||
--------------------------------------------------
|
||||
0 2 7 0.4% 1.5%
|
||||
1 12 33 3.5% 9.7%
|
||||
2 0 1 0.0% 0.1%
|
||||
3 71 84 14.0% 16.6%
|
||||
4 87 95 18.9% 20.7%
|
||||
|
||||
## 5. Safety Operating Curve (best binary MLP)
|
||||
|
||||
threshold action_P action_R FA count FA rate coverage
|
||||
-----------------------------------------------------------------
|
||||
0.300 0.6224 0.7827 378 0.1518 0.9305
|
||||
0.325 0.6324 0.7563 350 0.1406 0.9221
|
||||
0.350 0.6400 0.7349 329 0.1321 0.9153
|
||||
0.375 0.6462 0.6997 305 0.1225 0.9040
|
||||
0.400 0.6536 0.6709 283 0.1137 0.8948
|
||||
0.425 0.6540 0.6482 273 0.1096 0.8876
|
||||
0.450 0.6631 0.6231 252 0.1012 0.8795
|
||||
0.475 0.6714 0.5930 231 0.0928 0.8699
|
||||
0.500 0.6731 0.5691 220 0.0884 0.8622
|
||||
0.525 0.6801 0.5503 206 0.0827 0.8562
|
||||
0.550 0.6866 0.5339 194 0.0779 0.8510
|
||||
0.575 0.6887 0.5113 184 0.0739 0.8438
|
||||
0.600 0.6968 0.4849 168 0.0675 0.8353
|
||||
0.625 0.6910 0.4523 161 0.0647 0.8249
|
||||
0.650 0.6914 0.4334 154 0.0618 0.8189
|
||||
0.675 0.6930 0.4083 144 0.0578 0.8108
|
||||
0.700 0.6856 0.3781 138 0.0554 0.8012
|
||||
0.725 0.6887 0.3530 127 0.0510 0.7932
|
||||
0.750 0.6870 0.3254 118 0.0474 0.7843
|
||||
0.775 0.7071 0.3003 99 0.0398 0.7763
|
||||
0.800 0.7204 0.2751 85 0.0341 0.7683
|
||||
0.825 0.7159 0.2374 75 0.0301 0.7562
|
||||
0.850 0.7422 0.2098 58 0.0233 0.7474
|
||||
0.875 0.7391 0.1709 48 0.0193 0.7349
|
||||
0.900 0.7518 0.1332 35 0.0141 0.7229
|
||||
0.925 0.7684 0.0917 22 0.0088 0.7096
|
||||
0.950 0.7778 0.0528 12 0.0048 0.6972
|
||||
|
||||
No threshold achieves action_P ≥ 0.95.
|
||||
|
||||
## 6. Capability-Question Boundary Diagnostic
|
||||
|
||||
capability_question → false action rate: 91.3% (115/126)
|
||||
positive modal request → action recall: 0.569 (796 examples)
|
||||
|
||||
### Paired capability-question vs action examples
|
||||
|
||||
These are the critical diagnostic pairs:
|
||||
|
||||
text route predicted action_P FA?
|
||||
-----------------------------------------------------------------------------------------------
|
||||
ты можешь выключить свет knowledge action 0.8159 YES
|
||||
ты можешь выключить жалюзи knowledge action 0.9752 YES
|
||||
ты можешь выключить вытяжку knowledge action 0.9720 YES
|
||||
ты можешь выключить вентилятор knowledge action 0.9041 YES
|
||||
умеешь ли включить свет knowledge action 0.5618 YES
|
||||
умеешь ли включить жалюзи knowledge action 0.9195 YES
|
||||
умеешь ли включить вытяжку knowledge action 0.8956 YES
|
||||
умеешь ли включить вентилятор knowledge action 0.6122 YES
|
||||
сможешь открыть свет knowledge action 0.7012 YES
|
||||
сможешь открыть жалюзи knowledge action 0.9633 YES
|
||||
сможешь открыть вытяжку knowledge action 0.9596 YES
|
||||
сможешь открыть вентилятор knowledge action 0.8491 YES
|
||||
ты можешь выключить свет, пожалуйста knowledge action 0.8682 YES
|
||||
ты можешь выключить жалюзи, пожалуйста knowledge action 0.9778 YES
|
||||
ты можешь выключить вытяжку, пожалуйста knowledge action 0.9718 YES
|
||||
...
|
||||
перезапусти контейнер maven action action 0.8106
|
||||
перезапусти контейнер nexus action action 0.8002
|
||||
перезапусти контейнер redis action action 0.6867
|
||||
перезапусти контейнер postgres action action 0.6016
|
||||
перезагрузи контейнер maven action action 0.6810
|
||||
перезагрузи контейнер nexus action action 0.6192
|
||||
перезагрузи контейнер redis action action 0.5051
|
||||
перезагрузи контейнер postgres action not_action 0.4449
|
||||
перезапусти контейнер maven, пожалуйста action action 0.8442
|
||||
перезапусти контейнер nexus, пожалуйста action action 0.8672
|
||||
|
||||
## 7. Voice-Like Stress Results
|
||||
|
||||
```text
|
||||
Total stress-testable pairs: 965
|
||||
question with ?: 123
|
||||
question without ?: 242
|
||||
positive polite/modal action: 350
|
||||
capability-question pairs: 42
|
||||
```
|
||||
|
||||
Sample affected pairs:
|
||||
|
||||
act-stop-091:
|
||||
original: "останови сервис, пожалуйста"
|
||||
stressed: "останови сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-092:
|
||||
original: "останови nginx, пожалуйста"
|
||||
stressed: "останови nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-093:
|
||||
original: "останови bотоbackup, пожалуйста"
|
||||
stressed: "останови bотоbackup пожалуйста"
|
||||
route: action
|
||||
act-stop-094:
|
||||
original: "выключи сервис, пожалуйста"
|
||||
stressed: "выключи сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-095:
|
||||
original: "выключи nginx, пожалуйста"
|
||||
stressed: "выключи nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-096:
|
||||
original: "выключи bотоbackup, пожалуйста"
|
||||
stressed: "выключи bотоbackup пожалуйста"
|
||||
route: action
|
||||
act-stop-097:
|
||||
original: "можешь останови сервис, пожалуйста"
|
||||
stressed: "можешь останови сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-098:
|
||||
original: "can you останови сервис, please"
|
||||
stressed: "can you останови сервис please"
|
||||
route: action
|
||||
act-stop-099:
|
||||
original: "можешь останови nginx, пожалуйста"
|
||||
stressed: "можешь останови nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-100:
|
||||
original: "can you останови nginx, please"
|
||||
stressed: "can you останови nginx please"
|
||||
route: action
|
||||
|
||||
Impact assessment:
|
||||
The MLP's decision boundary must not depend on punctuation artifacts.
|
||||
If MLP success depends on '?' presence, it will fail under voice input.
|
||||
|
||||
## 8. Six-Way MLP Results
|
||||
|
||||
Best six-way MLP: H=64, wd=0.001
|
||||
|
||||
```text
|
||||
accuracy: 68.7%
|
||||
macro F1: 0.681
|
||||
action precision: 0.639
|
||||
action recall: 0.830
|
||||
false-action rate: 15.0%
|
||||
false-action count: 374
|
||||
```
|
||||
|
||||
Per-route F1:
|
||||
|
||||
action P=0.639 R=0.830 F1=0.722 (n=796)
|
||||
conversation P=0.893 R=0.720 F1=0.798 (n=93)
|
||||
knowledge P=0.735 R=0.634 F1=0.681 (n=715)
|
||||
memory_write P=0.743 R=0.591 F1=0.659 (n=553)
|
||||
system P=0.646 R=0.597 F1=0.621 (n=226)
|
||||
uncertain P=0.583 R=0.626 F1=0.604 (n=107)
|
||||
|
||||
Confusion matrix (rows=expected, cols=predicted):
|
||||
action conversation knowledge memory_write system uncertain
|
||||
action 661 3 67 43 7 15
|
||||
conversation 0 67 9 3 5 9
|
||||
knowledge 181 0 453 47 33 1
|
||||
memory_write 132 0 48 327 29 17
|
||||
system 37 3 34 11 135 6
|
||||
uncertain 24 2 5 9 0 67
|
||||
|
||||
Fold variance (six-way MLP):
|
||||
|
||||
fold accuracy macro_f1 action_P action_R FA
|
||||
-------------------------------------------------------
|
||||
0 82.1% 0.774 0.871 0.938 20
|
||||
1 81.5% 0.625 0.838 0.925 36
|
||||
2 64.2% 0.580 0.722 0.771 88
|
||||
3 69.0% 0.687 0.425 0.895 104
|
||||
4 51.7% 0.452 0.212 0.500 126
|
||||
|
||||
## 9. Comparison Against All Linear Baselines
|
||||
|
||||
| model | params beyond e5 | action P | action R | FA rate | macro F1 |
|
||||
| ---------------------------- | ------------------ | ---------- | ---------- | ---------- | ---------- |
|
||||
| 6-way linear | 2,310 | 0.629 | 0.832 | 15.7% | 0.675 |
|
||||
| binary linear | ~385 | 0.688 | 0.476 | 6.9% | — |
|
||||
| structural linear | ~2,313 | 0.610 | 0.805 | 15.1% | 0.622 |
|
||||
| binary MLP (H=32) | 12,353 | 0.673 | 0.569 | 8.8% | — |
|
||||
| 6-way MLP (H=64) | 25,030 | 0.639 | 0.830 | 15.0% | 0.681 |
|
||||
|
||||
## 10. Conclusion
|
||||
|
||||
### Verdict: e5 representation inadequate for the action-pragmatics boundary
|
||||
|
||||
The tiny nonlinear MLP provides only marginal improvement over linear baselines
|
||||
and fails the capability-question diagnostic and the safety operating goal.
|
||||
|
||||
1. **Binary MLP does not beat binary linear.** PR-AUC 0.692 (MLP, H=32) vs 0.707
|
||||
(linear). The MLP trades precision for recall (P=0.673/R=0.569) and produces
|
||||
*more* false actions (220 vs 172) than the linear probe. The nonlinearity does
|
||||
not unlock a separability that the linear surface was missing — both are pinned
|
||||
by the same representation.
|
||||
|
||||
2. **Capability-question boundary is catastrophically broken for both.** The MLP
|
||||
fires action on **91.3% (115/126)** of capability questions — the primary
|
||||
diagnostic fails outright. The linear probe reaches the same place (112/126).
|
||||
A nonlinear head cannot separate "ты можешь выключить свет?" (knowledge) from
|
||||
"можешь выключить свет, пожалуйста" (action) because the frozen mean-pooled e5
|
||||
vector places them on top of each other.
|
||||
|
||||
3. **No safe operating region exists.** The MLP never reaches action precision
|
||||
≥ 0.95 with materially non-zero recall. The best it can do is P=0.778 at
|
||||
R=0.053 (essentially zero recall). At any threshold that keeps FA count low,
|
||||
it becomes useless; at any threshold that keeps recall useful, FA rate climbs
|
||||
past 10%.
|
||||
|
||||
4. **Six-way MLP is essentially flat.** F1=0.681 (H=64) vs 0.675 (linear six-way);
|
||||
FA rate 15.0% vs 15.7%. Per-route F1 is within noise of the linear head. The bad
|
||||
folds from slice 16 (fold 3 P=0.425, fold 4 P=0.212) are not repaired.
|
||||
|
||||
Per the brief's decision rule this is the third case:
|
||||
|
||||
> mean-pooled e5 representation is inadequate for Maven's action-pragmatics
|
||||
> boundary. at that point stop probing e5.
|
||||
|
||||
**Stop probing the frozen mean-pooled e5-small representation.** The signal is
|
||||
not present and not merely nonlinear. A fundamentally different representation or
|
||||
encoder is required for the action/capability-question pragmatics boundary.
|
||||
|
||||
### Capability-question diagnostic
|
||||
|
||||
capability_question false-action rate (MLP): 91.3% (115/126)
|
||||
capability_question false-action rate (linear): 88.9% (112/126)
|
||||
positive modal action recall (MLP): 0.569
|
||||
positive modal action recall (linear): 0.476
|
||||
|
||||
The MLP does not materially improve the capability-question boundary.
|
||||
|
||||
### Six-way MLP vs binary gate
|
||||
|
||||
Six-way MLP (F1=0.681) and six-way linear (F1=0.675) are statistically
|
||||
indistinguishable. Neither the six-way formulation nor the nonlinearity fixes the
|
||||
boundary. The architecture evidence does not support routing through a single
|
||||
learned head for Maven's action pragmatics as currently embedded.
|
||||
|
||||
## 11. Commit hash for diagnostic tooling
|
||||
|
||||
`e80d45f` (slice 17 MLP probe tooling: `cmd/semantic-router-experiment/slice17_mlp.py`)
|
||||
@@ -0,0 +1,327 @@
|
||||
# Fine-tuning a pretrained Russian BERT (rubert-tiny, 11.8 M) makes the in-pool pragmatics boundary the strongest seen so far (pair ordering 0.93-0.97, median margin +0.7) but fires **every one** of the 126 held-out capability-question rows as an action (FA_rate 1.000) on all 18 config/seeds — supervised fine-tuning on this split memorises the seen families' templates, does not learn the boundary, and adds no safety signal anywhere
|
||||
|
||||
Date: 2026-09-07 · Task: slice 20 (brief after the accepted slice 19, V-726) · Box: homesrv, Ryzen 5 5600U, 13 GB, CPU-only (this is the production machine) · Build: `cmd/semantic-router-experiment/slice20_*.py`, torch 2.14.0+cpu, transformers 5.16.1 in `/tmp/mvn-exp-venv`, no GPU.
|
||||
|
||||
## 0. Frozen artifacts
|
||||
|
||||
```text
|
||||
development corpus v2 hash: b27fd48f478ca477
|
||||
original frozen holdout hash: ad297fbdbbea704b (byte-identical, uninspected)
|
||||
text normalization: NormalizeMatchText (NFKC, lowercase, whitespace-collapse; punctuation and ё kept)
|
||||
embedding file: /tmp/mvn-experiment/embeddings.json
|
||||
total examples: 3025
|
||||
dev pool: 2490 (796 action / 1694 not_action)
|
||||
frozen holdout: 535
|
||||
router-residual: 2943
|
||||
capability_question family: 126 rows, all not_action (the exact safety case)
|
||||
family sizes: polite_request 481 / modal_request 223 / first_person_request 833 / reordered_target 353
|
||||
paired action/capability rows: 2268 (shared object nouns)
|
||||
```
|
||||
|
||||
Identical dev pool to slices 16-19. Training input per brief §9 is the
|
||||
punctuation-stripped text (`re.sub(r"[^\w\s]", " ", c)` + collapse); the `orig`
|
||||
and `nofinal` variants are evaluation-only stress views. Nothing in the frozen
|
||||
holdout was inspected or added; holding the slice-16 corpus line is unchanged.
|
||||
|
||||
## 1. What was tested
|
||||
|
||||
`cointegrated/rubert-tiny` — the tiny Russian BERT — fine-tuned end-to-end for
|
||||
the binary action/not_action label. The model is the 3-layer, 312-hidden, 12-head
|
||||
configuration (same depth/size family as the slice-19 transformers, but with a
|
||||
11.78 M-parameter pretrained prior on 655 M tokens of bilingual text). One
|
||||
sequence-classification head (a single output neuron) was attached: this is the
|
||||
full fine-tuning door the slice-19 conclusion asked for — "a small modern
|
||||
pretrained encoder already encodes softeners, modal auxiliaries and the trailing
|
||||
interrogative in its weights".
|
||||
|
||||
| property | value |
|
||||
| --- | --- |
|
||||
| model | `cointegrated/rubert-tiny`, revision `5441c5ea8026d4f6d7505ec004845409f1259fb1` |
|
||||
| license | MIT (2024-02-09 snapshot) |
|
||||
| params | 11,784,481 |
|
||||
| config | BERT base-tiny: hidden 312, layers 3, heads 12, intermediate 600, max_pos 512 |
|
||||
| tokenizer | WordPiece, vocab 29,564, unpadded length-capped |
|
||||
|
||||
The brief's alternatives were to hold either submission. Nothing here tests
|
||||
capacity beyond the slice-19 range (tiny two-config depth, six configs total).
|
||||
|
||||
## 2. Tokenizer audit (gate before training)
|
||||
|
||||
The slice-19 method note: BPE trained on stripped text silently drops boundary
|
||||
punctuation (`сервис,` loses the comma) and the stress axis becomes a no-op.
|
||||
WordPiece ships pretrained, so this slice audits the frozen tokenizer instead:
|
||||
|
||||
```text
|
||||
vocab: 29,564
|
||||
UNK: 73/28,051 tokens → 0.0026 (all 73 from 5 CJK word-tokens: 记住, 备份策略, 那 —
|
||||
zero Cyrillic/Latin loss in the whole dev pool)
|
||||
tokens/utterance: mean 11.4
|
||||
tokens/char: 0.426
|
||||
seq len: p50 11, p90 16, p99 19, max 21 → n above 96 = 0, above 128 = 0
|
||||
Cyrillic/mixed/Latin fragments intact (перезапусти сервис mavend → пер ##еза ##пус ##ти
|
||||
се ##рви ##с ma ##ven ##d); hashlike ids fine (ha_cam_12 → ha _ cam _ 12)
|
||||
```
|
||||
|
||||
Gate **PASSED**. The UNK floor is a constant 73 (these five CJK words are the
|
||||
same five everywhere; they occur on both sides of the label), the length
|
||||
distribution is far below any practical cap, and no useful token is lost.
|
||||
|
||||
## 3. Sequence length
|
||||
|
||||
`MAX_LEN = 25` (corpus p99 19 + 6). Truncation measured on the dev pool: 0 of
|
||||
2490 rows. The 128 cap the brief set is respected with a 5× margin.
|
||||
|
||||
## 4. Fine-tune setup
|
||||
|
||||
Fixed hyperparameters, no grid search on them; the three search axes are the
|
||||
learning rate and (implicitly, by regime) the input view.
|
||||
|
||||
| regime | training input | purpose |
|
||||
| --- | --- | --- |
|
||||
| A | natural text (orig) | brief §9 primary; punctuation seen at train |
|
||||
| B | punctuation-stripped | the slice-19 carry-out; stress-free train |
|
||||
|
||||
| hyperparameter | value |
|
||||
| --- | --- |
|
||||
| lr grid | 1e-5 · 2e-5 · 5e-5 |
|
||||
| seeds | 42 · 17 · 7 |
|
||||
| epochs | max 4, early stop on val PR-AUC (patience 1) |
|
||||
| batch | 32 |
|
||||
| weight decay | 0.01 |
|
||||
| val split | 0.12 within-train (per fold, not touching test) |
|
||||
| threads | 4 (fp32) — the fast config; 12 threads and bf16 autocast are several × slower |
|
||||
|
||||
Grouped 5-fold CV reusing the existing `cv_fold` split; the
|
||||
capability-question/question families are genuinely held out per fold. Each
|
||||
train call resamples fresh fold seeds, giving the 3 seed groupings independence
|
||||
(both for grouped CV and for the leave-generator-out runs). 18 fine-tunes × 5
|
||||
folds grouped + 18 leave-generator-out runs, ~87 min total on the box.
|
||||
|
||||
## 5. Grouped CV, binary gate (strip input, OOF at threshold 0.5)
|
||||
|
||||
The in-pool aggregate boundary. Rows are OOF (each row's label folded out of its
|
||||
train), three seeds (42/17/7), PR-AUC interval is the 3-seed min..max.
|
||||
|
||||
| config | PR-AUC | FA rate | P@0.5 | R@0.5 | FA | cap-Q in-pool |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| A @ lr1e-5 | 0.750 (0.732..0.768) | 8.2-8.9% | 0.75 | 0.68 | 205-221 | 0.49-0.60 |
|
||||
| **A @ lr2e-5** | **0.831 (0.800..0.856)** | **6.5-7.5%** | 0.81 | 0.80 | 161-186 | **0.17-0.37** |
|
||||
| A @ lr5e-5 | 0.814 (0.795..0.828) | 9.6-11.1% | 0.78 | 0.76 | 238-277 | 0.19-0.41 |
|
||||
| B @ lr1e-5 | 0.730 (0.678..0.786) | 6.9-11.9% | 0.75 | 0.68 | 171-296 | 0.41-0.87 |
|
||||
| B @ lr2e-5 | 0.810 (0.749..0.845) | 7.4-9.0% | 0.77 | 0.77 | 183-223 | 0.18-0.52 |
|
||||
| B @ lr5e-5 | 0.833 (0.808..0.865) | 6.9-8.3% | 0.80 | 0.75 | 172-207 | 0.10-0.38 |
|
||||
|
||||
The aggregate boundary ~0.83 PR-AUC enters the slice-18 sparse's territory
|
||||
(0.838) but the FA rate (6.5-11.9%) stays well above the sparse gate's 2.2%.
|
||||
The secondary window cap-Q in-pool (0.10-0.60) shows the family is *learnable
|
||||
when present in train*: the boundary exists inside the model's seen
|
||||
distribution.
|
||||
|
||||
### Fold variance (A @ lr2e-5)
|
||||
|
||||
```text
|
||||
seed val PR-AUC per fold (a 12.4%-frac of each fold) epochs per fold
|
||||
42 0.995 0.997 0.972 0.999 0.994 4 4 4 4 4
|
||||
17 0.999 0.994 0.980 0.992 0.984 4 4 4 4 4
|
||||
```
|
||||
|
||||
Early stopping never fires — every fold hits the epoch ceiling with val PR-AUC
|
||||
already ≥ 0.97, i.e. the capacity is nowhere near exhausted on the train side.
|
||||
The fold-3 (capability/question-heavy) weakness that from-scratch models showed
|
||||
is invisible here on the val split; the OOF @0.5 FA still jitters 161-186 on the
|
||||
worst seeds. This is the slope the LOFO result (§7), not the fold table, reveals.
|
||||
|
||||
## 6. Safety operating curve
|
||||
|
||||
None of the six configs has any threshold with P ≥ 0.95 at R > 0 under the
|
||||
grouped OOF (`ops` empty in the sync of every seed). The best precision on the
|
||||
entire curve of the best seed is 0.934 at R 0.230. The sparse gate's slice-18
|
||||
operating point (P ≥ 0.95 at R 0.264) does not transfer to any fine-tuned
|
||||
pretrained model either. **A fine-tuned rubert-tiny is not a safe standalone
|
||||
gate out of the box, even in-pool.**
|
||||
|
||||
## 7. Leave-generator-out: capability questions (the critical split, §8/§11)
|
||||
|
||||
Same leave-one-family-out as slice 19 §7, now 3 seeds × 6 configs. Evaluate on
|
||||
the 126 capability-question rows (0/126 positive — every row here must NOT trip
|
||||
the gate). Threshold 0.5:
|
||||
|
||||
```text
|
||||
config FA(tot=126) FA_rate mean action proba
|
||||
A @ lr1e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.86-0.87
|
||||
A @ lr2e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.965-0.972
|
||||
A @ lr5e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.988-0.991
|
||||
B @ lr1e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.876-0.893
|
||||
B @ lr2e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.965-0.967
|
||||
B @ lr5e-5 (seeds 7/17/42) 126 / 126 / 126 1.000 × 3 0.990-0.992
|
||||
```
|
||||
|
||||
**Every held-out capability question reads as an action, with near-deterministic
|
||||
confidence** (mean action proba 0.86-0.99, within-family standard deviation
|
||||
~0.0045). This is not a tuning artifact: it holds at the weakest LR (1e-5), all
|
||||
seeds, both regimes. It is the sharpest failure in the entire slice family:
|
||||
worse than e5 (0.976-1.000), worse than the from-scratch seed-stable
|
||||
transformer-medium (0.48-0.55), worse than the sparse gate (0.667). The
|
||||
fine-tuned pretrained model — the strongest *in-pool* pragmatics reader these
|
||||
slices have produced (pair 0.97, §9) — collapses to 100% fire on the exact
|
||||
unseen-generator case the task exists for.
|
||||
|
||||
## 8. The decisive reading (§17 hypothesis test)
|
||||
|
||||
The contrast that separates the two hypotheses is now sharp:
|
||||
|
||||
* **In-pool** (family present in train): cap-Q FA down to 0.17 (§5), pair
|
||||
ordering up to 0.97 (§9). The model learns the boundary whenever the generator
|
||||
family is in the training split.
|
||||
* **LOFO** (family absent): 1.000 across every config and seed.
|
||||
|
||||
A capacity or signal problem would degrade *both* in-pool and LOFO. Instead the
|
||||
fine-tune is at its *best* in-pool and at its absolute *worst* out-of-family.
|
||||
The increase in pretrained representation power over from-scratch does **not**
|
||||
transfer to the unseen generator; it sharpens the seen-family templates until
|
||||
every capability question is swept into "action". The low LR (1e-5) does not
|
||||
rescue it — it already fires 84-89% of the rows at 1e-5.
|
||||
The phenomenon is template/generator specialization, not lack of prior.
|
||||
|
||||
## 9. Paired action/capability ordering
|
||||
|
||||
2268 pairs (capability question vs executable sibling on the shared object
|
||||
noun), ordering accuracy and median margin on grouped OOF, strip view:
|
||||
|
||||
| config | ordering | median margin |
|
||||
| --- | --- | --- |
|
||||
| A @ lr1e-5 | 0.740 (0.731..0.755) | +0.20 |
|
||||
| **A @ lr2e-5** | **0.933 (0.869..0.970)** | **+0.56..+0.78** |
|
||||
| A @ lr5e-5 | 0.822 (0.690..0.952) | +0.06..+0.91 |
|
||||
| B @ lr1e-5 | 0.605 (0.482..0.787) | −0.01..+0.26 |
|
||||
| B @ lr2e-5 | 0.901 (0.874..0.933) | +0.53..+0.62 |
|
||||
| B @ lr5e-5 | 0.808 (0.743..0.918) | +0.10..+0.80 |
|
||||
|
||||
Best seeds put the executable above its sibling 96-97% of the time with a
|
||||
median margin of +0.74-0.81 — the strongest ordering ever measured on this
|
||||
fixture (from-scratch best: bigru-medium 0.866 / +0.598; sparse 0.571). Pair
|
||||
ordering is the one axis on which the pretrained model is clearly best, and it
|
||||
is the version of the task where the two members share surface structure, so a
|
||||
trained-together contrast is exactly what the model generalizes. The pair task
|
||||
is *in-distribution relative to the model's world prior*; the family boundary is
|
||||
not.
|
||||
|
||||
## 10. Punctuation / voice stress
|
||||
|
||||
Models trained on regime A (natural text) vs B (punct-stripped); punctuation is
|
||||
in the pretrained WordPiece vocabulary. Stress views `orig` / `nofinal` /
|
||||
`strip`, evaluated on the same early-stopped checkpoint:
|
||||
|
||||
```text
|
||||
config cap-Q in-pool FA (orig/nofinal/strip) pairs (orig/nofinal/strip)
|
||||
A @ lr2e-5 seed17 0.143 / 0.183 / 0.167 0.968 / 0.959 / 0.961
|
||||
A @ lr2e-5 seed42 0.294 / 0.365 / 0.365 0.893 / 0.878 / 0.869
|
||||
B @ lr5e-5 seed7 0.175 / 0.183 / 0.183 0.904 / 0.895 / 0.896
|
||||
```
|
||||
|
||||
Punctuation is not load-bearing: all three views agree within ~2 points on every
|
||||
metric, for both regimes. The model ignores the `?`/`,` under stress, which is
|
||||
the safest possible behaviour under ASR. The earlier slice-19 "stress-robust"
|
||||
was a tokenizer artifact; here it is checked with punctuation genuinely in the
|
||||
vocabulary and holds.
|
||||
|
||||
## 11. Runtime, size, ONNX (homesrv CPU, batch 1)
|
||||
|
||||
| property | value |
|
||||
| --- | --- |
|
||||
| params | 11,784,481 |
|
||||
| fp32 weights | 47.1 MB (onnx initializer raw) |
|
||||
| fp16 weights | 23.6 MB |
|
||||
| int8 weights | 11.8 MB |
|
||||
| tokenizer files | 709,227 B |
|
||||
| latency p50 / p95 (CPU batch 1, incl. tokenize) | 3.66 / 5.25 ms |
|
||||
| tokenizer | 64.7 µs/utterance |
|
||||
| threads | 12, MAX_LEN 25 |
|
||||
|
||||
**RAM** (fresh process, torch+transformers baseline then model + inference):
|
||||
baseline 408 MB RSS, with rubert-tiny resident 570 MB → incremental ~161 MB.
|
||||
That delta is what a python-side subprocess would add on top of an already
|
||||
imported transformers; a Go daemon embedding the ONNX directly would pay only
|
||||
the 47 MB fp32 (24 MB fp16 / 12 MB int8) + 0.7 MB tokenizer. Production CPU
|
||||
latency ~3.7 ms p50 satisfies the speech budget (slice-19 BERT-based rows were
|
||||
0.9-2.6 ms batch-1; this is the same order).
|
||||
|
||||
**ONNX**: export succeeds (`rubert-tiny-gate.onnx`, 411,087 B zip, fp32 raw).
|
||||
**Parity FAILED**: onnnxruntime CPU probabilities differ from torch by up to
|
||||
0.56 (mean 0.37) — the exported graph is not faithful to the torch forward on
|
||||
the same inputs. The fault is in the export path, not the fine-tune; but since
|
||||
the model did not survive the §8 selection, §15 does not gate the conclusion.
|
||||
Reported so a future candidate is not burned on a broken export again.
|
||||
|
||||
## 12. tiny2 ceiling (§13 of the brief — optional, run)
|
||||
|
||||
The brief allowed `cointegrated/rubert-tiny2` as a ceiling probe only. Run (the
|
||||
same 3-layer/312-hidden family, newer pretraining, vocab 83,828, max_pos 2048):
|
||||
|
||||
```text
|
||||
audit: tokens/utterance mean 11.4, p99 13 (newer, richer WordPiece), unk 0.37%
|
||||
cap-Q LOFO (A @ lr2e-5, seeds 7/17/42): FA = 126/126/126, FA_rate 1.000 × 3
|
||||
in-pool (grouped, single seed): PR-AUC 0.782, P 0.735, R 0.747, FA 215 (8.6%)
|
||||
pairs 0.699, cap-Q in-pool 0.619
|
||||
```
|
||||
|
||||
tiny2 is **worse in-pool** (pair 0.699 vs 0.933; cap-Q in-pool 0.62 vs 0.17)
|
||||
and **just as catastrophic on the held-out family**. A newer, larger-vocab
|
||||
pretraining of the same family does not move the LOFO boundary at all. This
|
||||
rules out the "tiny1 is somehow unlucky pretraining" explanation: the family
|
||||
prior itself does not carry the boundary.
|
||||
|
||||
## 13. Conclusion
|
||||
|
||||
### Verdict: the pretrained fine-tune closes in-pool but not the unseen generator; nothing in the supervised family passes the safety door
|
||||
|
||||
1. **In-pool, the pretrained model is the strongest pragmatics reader so far.**
|
||||
Pair ordering 0.933-0.970 with median margin up to +0.81 beats every
|
||||
from-scratch config (best 0.866) and the sparse gate (0.571); cap-Q in-pool
|
||||
FA drops to 0.10-0.37 where from-scratch was 0.23-0.54; aggregate PR-AUC
|
||||
0.83 ties the sparse row. The pretrained prior demonstrably contains the
|
||||
order/politeness/modality signal slice 18 predicted — when the generator is
|
||||
in-distribution.
|
||||
|
||||
2. **The exact safety case fails harder than everything measured before.**
|
||||
Leave-generator-out on capability questions: FA_rate **1.000 on all 18
|
||||
config/seeds** (mean proba 0.86-0.99, within-family σ ~0.0045), vs sparse
|
||||
0.667, from-scratch seed-stable 0.48-0.55, e5 0.976-1.000. A model that is
|
||||
the *best* in-pool boundary is simultaneously the *worst* out-of-family.
|
||||
More representation power sharpens the seen templates; it does not open a
|
||||
hole to the unseen generator. every supervised route — frozen, from-scratch,
|
||||
pretrained-fine-tuned — caps or collapses on this split (sparse 0.667 FA,
|
||||
from-scratch ~0.5, pretrained 1.000).
|
||||
|
||||
3. **The spike is generator/template memorization, not lack of prior.** tiny2
|
||||
(newer, larger-vocab pretraining) does not move LOFO (1.000) and is worse
|
||||
in-pool. The decisive §11 test says: the capability-question family is not
|
||||
learnable from the in-pool surface; what the fine-tune learns is "when my
|
||||
friends speak, act". The pragmatics distinction these slices chase lives in
|
||||
the *generator structure*, and in the corpus it is visible only as a
|
||||
template; a supervised encoder sees the template, not the generator.
|
||||
|
||||
### What this rules out, and what is left
|
||||
|
||||
* **No supervised sequence-model scaling is worth another slice of LOFO.**
|
||||
Frozen (e5) and fine-tuned (rubert-tiny) both sit at or above 0.976 FA on the
|
||||
holdout; from-scratch straddles chance. Three different inductive biases have
|
||||
now failed the same split the same categorical way.
|
||||
* **The safety floor remains the sparse gate** (0.667 FA, the single best LOFO
|
||||
number on the fixture) — kept, with its known 2.2% aggregate FA cost.
|
||||
* Remaining doors, in the order the brief's decision rule points at: (a) a
|
||||
**template-structure syntactic gate** (modality-verb pattern on top of sparse)
|
||||
that decomposes the generator rather than learning its shadow; (b) a
|
||||
**frozen-judge LLM** that answers "would this text execute a tool?"
|
||||
zero-shot, decoupled from any fine-tune; (c) more data from the
|
||||
capability-question *generator* (not more utterances) so the family is learned
|
||||
by construction rather than by silhouette.
|
||||
* Carry-outs that survive regardless of route: training input stays stripped
|
||||
(free stress robustness); tokenizers/embeddings audited on natural text;
|
||||
never report one-seed LOFO without its neighbours; a broken ONNX export must
|
||||
not be shipped as "parity OK" by an internal self-check.
|
||||
|
||||
## 14. Commit hash for tooling
|
||||
|
||||
`slice20_audit.py`, `slice20_pretrained.py` (tokenize/grouped/lfo/metrics/
|
||||
runtime/onnx/ceiling subcommands, `MAX_LEN=25`); artifacts under `/tmp/mvn-s20/`.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Slice 16 Diagnostic: Action/Non-Action Boundary Analysis
|
||||
|
||||
## 0. Slice 15 Commit Hashes
|
||||
|
||||
```text
|
||||
semantic seed type: 07bfcea
|
||||
merge-corpus tool: 6397ea1
|
||||
e5 embedding cache: f8ec77d
|
||||
sklearn experiment: 9df2239
|
||||
corpus-factory: 4bb7555
|
||||
expanded corpus: 87411e5
|
||||
contract tests: ad3f2d6
|
||||
experiment reports: cbac8b9
|
||||
|
||||
development corpus v2 hash: b27fd48f478ca477
|
||||
original frozen holdout hash: ad297fbdbbea704b (byte-identical, uninspected)
|
||||
```
|
||||
|
||||
## 1. Router-Residual OOF Safety Metrics
|
||||
|
||||
```text
|
||||
action precision: 0.620
|
||||
action recall: 0.826
|
||||
false-action count: 388
|
||||
false-action rate: 16.0%
|
||||
uncertain F1: 0.587
|
||||
total examples: 2418
|
||||
best C: 10.0
|
||||
```
|
||||
|
||||
## 2. False-Action Decomposition by Semantic Family
|
||||
|
||||
Total false actions: 388
|
||||
|
||||
### By semantic family
|
||||
|
||||
family count rate
|
||||
----------------------------------------
|
||||
memory_write 133 34.3%
|
||||
capability_question 112 28.9%
|
||||
knowledge_general 77 19.8%
|
||||
system 29 7.5%
|
||||
uncertain 26 6.7%
|
||||
question 9 2.3%
|
||||
conversation 2 0.5%
|
||||
|
||||
### By held-out split group (top 20)
|
||||
|
||||
split_group count
|
||||
-------------------------------------
|
||||
knowledge:capability-ha 70
|
||||
knowledge:world-def 46
|
||||
free:remember 45
|
||||
knowledge:capability-tool 42
|
||||
note:task 28
|
||||
system:self-version 27
|
||||
fact:meal 27
|
||||
knowledge:homelab-status 15
|
||||
uncertain:ambiguous-noun 15
|
||||
note:homelab 11
|
||||
fact:water 10
|
||||
note:idea 8
|
||||
knowledge:deadline 7
|
||||
uncertain:single-word-verb 7
|
||||
knowledge:world-explain 6
|
||||
knowledge:homelab-disk 5
|
||||
knowledge:task-check 5
|
||||
uncertain:multi-ambiguous 3
|
||||
fact:pills 2
|
||||
system:quiet-on 2
|
||||
|
||||
### By fold
|
||||
|
||||
fold count
|
||||
------------
|
||||
0 28
|
||||
1 38
|
||||
2 92
|
||||
3 104
|
||||
4 126
|
||||
|
||||
### Diagnosis
|
||||
|
||||
- Distinct semantic families contributing false actions: 7
|
||||
- Distinct held-out split groups: 24
|
||||
- Largest single family: memory_write (133 false actions)
|
||||
- Fold false-action count CV (std/mean): 0.49
|
||||
- Broad distribution across families suggests systematic action/non-action overlap
|
||||
|
||||
## 3. Paired E5 Geometry
|
||||
|
||||
cosine(action seed, positive action):
|
||||
mean=0.907 std=0.039 min=0.765 max=0.997 n=38222
|
||||
cosine(action seed, capability question):
|
||||
mean=0.897 std=0.035 min=0.782 max=0.979 n=796
|
||||
cosine(action seed, other negative contrast):
|
||||
mean=0.893 std=0.031 min=0.822 max=0.975 n=796
|
||||
|
||||
Separation gap (positive - capability): 0.010
|
||||
**WARNING**: e5 maps action seeds and capability questions nearly on top of each other.
|
||||
The representation itself may not preserve useful separation for this boundary.
|
||||
|
||||
## 4. Binary Action Probe (Linear Logistic)
|
||||
|
||||
```text
|
||||
ROC-AUC: 0.863
|
||||
PR-AUC: 0.707
|
||||
precision: 0.726
|
||||
recall: 0.538
|
||||
false-positive: 172
|
||||
false-negative: 417
|
||||
total: 2490
|
||||
best C: 1.0
|
||||
```
|
||||
|
||||
Per-fold:
|
||||
Fold 0: ROC=0.967 PR=0.937 P=0.978 R=0.618 FP=2 FN=55 n=476
|
||||
Fold 1: ROC=0.914 PR=0.940 P=0.919 R=0.682 FP=12 FN=64 n=341
|
||||
Fold 2: ROC=0.962 PR=0.952 P=1.000 R=0.195 FP=0 FN=239 n=707
|
||||
Fold 3: ROC=0.832 PR=0.472 P=0.482 R=0.767 FP=71 FN=20 n=506
|
||||
Fold 4: ROC=0.640 PR=0.235 P=0.250 R=0.426 FP=87 FN=39 n=460
|
||||
|
||||
## 5. Cost-Sensitive Linear Action Classification
|
||||
|
||||
cost action_P action_R FA count FA rate
|
||||
----------------------------------------------
|
||||
1 0.601 0.804 390 15.7%
|
||||
2 0.562 0.847 481 19.3%
|
||||
4 0.531 0.891 589 23.5%
|
||||
8 0.494 0.919 711 28.6%
|
||||
16 0.461 0.953 847 34.2%
|
||||
|
||||
## 6. Expanded Action-Threshold Sweep (30 action groups)
|
||||
|
||||
threshold action_P action_R FA count coverage
|
||||
--------------------------------------------------
|
||||
0.50 0.664 0.736 285 92.9%
|
||||
0.60 0.687 0.655 229 88.0%
|
||||
0.70 0.698 0.537 178 82.1%
|
||||
0.80 0.722 0.407 120 75.6%
|
||||
0.85 0.714 0.322 99 72.1%
|
||||
0.90 0.690 0.227 78 68.2%
|
||||
0.95 0.727 0.115 33 62.8%
|
||||
|
||||
## 7. Voice-Like Punctuation Stress Evaluation
|
||||
|
||||
Total stress-testable pairs: 965
|
||||
|
||||
Sample stress pairs:
|
||||
act-stop-091:
|
||||
original: "останови сервис, пожалуйста"
|
||||
stressed: "останови сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-092:
|
||||
original: "останови nginx, пожалуйста"
|
||||
stressed: "останови nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-093:
|
||||
original: "останови bотоbackup, пожалуйста"
|
||||
stressed: "останови bотоbackup пожалуйста"
|
||||
route: action
|
||||
act-stop-094:
|
||||
original: "выключи сервис, пожалуйста"
|
||||
stressed: "выключи сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-095:
|
||||
original: "выключи nginx, пожалуйста"
|
||||
stressed: "выключи nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-096:
|
||||
original: "выключи bотоbackup, пожалуйста"
|
||||
stressed: "выключи bотоbackup пожалуйста"
|
||||
route: action
|
||||
act-stop-097:
|
||||
original: "можешь останови сервис, пожалуйста"
|
||||
stressed: "можешь останови сервис пожалуйста"
|
||||
route: action
|
||||
act-stop-098:
|
||||
original: "can you останови сервис, please"
|
||||
stressed: "can you останови сервис please"
|
||||
route: action
|
||||
act-stop-099:
|
||||
original: "можешь останови nginx, пожалуйста"
|
||||
stressed: "можешь останови nginx пожалуйста"
|
||||
route: action
|
||||
act-stop-100:
|
||||
original: "can you останови nginx, please"
|
||||
stressed: "can you останови nginx please"
|
||||
route: action
|
||||
|
||||
Impact assessment:
|
||||
Removal of punctuation changes:
|
||||
- trailing '?' removal eliminates the strongest question signal
|
||||
- lowercase normalization removes proper-noun casing cues
|
||||
- whitespace collapse has minimal effect on e5 (subword tokenizer)
|
||||
Production voice punctuation is unreliable; the model must not depend on it.
|
||||
|
||||
Capability-question pairs total in stress set: 42
|
||||
Capability-question pairs with trailing '?': 21
|
||||
Capability-question pairs with comma only: 21
|
||||
|
||||
|
||||
## 8. E5 + Tiny Structural Features
|
||||
|
||||
```text
|
||||
Features: [e5(384) ; IsQuestion(1) ; trailing_?(1) ; prohibition(1)] = 387 dims
|
||||
Macro F1: 0.622
|
||||
False-action rate: 15.1%
|
||||
False-action count: 373
|
||||
best C: 10.0
|
||||
```
|
||||
|
||||
Comparison with pure e5:
|
||||
pure e5: macro_f1=0.616 FA_rate=15.7%
|
||||
e5 + structural bits: macro_f1=0.622 FA_rate=15.1%
|
||||
|
||||
## 9. Four-Hypothesis Comparison Table
|
||||
|
||||
| experiment | action_P | action_R | FA rate | macro F1 |
|
||||
| ----------------------------------- | ---------- | ---------- | ---------- | ---------- |
|
||||
| six-way e5 linear (residual) | 0.620 | 0.826 | 16.0% | 0.663 |
|
||||
| six-way + action threshold | 0.727 | 0.115 | 1.4% | — |
|
||||
| binary linear action probe | 0.726 | 0.538 | 6.9% | — |
|
||||
| e5 + tiny structural features | 0.610 | 0.805 | 15.1% | 0.622 |
|
||||
|
||||
## 10. Conservative Interpretation
|
||||
|
||||
### Action/capability-question embeddings are nearly indistinguishable
|
||||
|
||||
The representation itself is suspect for this boundary.
|
||||
Do not claim the boundary is merely nonlinear.
|
||||
A fundamentally different representation or encoder may be needed.
|
||||
|
||||
**E5 geometry verdict**: action seeds and capability questions are nearly
|
||||
indistinguishable in embedding space. The representation intentionally
|
||||
maps pragmatically different but semantically similar sentences close together.
|
||||
This is a fundamental limitation of the frozen e5 representation for this boundary.
|
||||
|
||||
**Softmax formulation verdict**: the six-way head produces 388 false actions
|
||||
at 16.0% rate. The binary probe produces 172 false actions.
|
||||
The binary probe has 0.4x the false-action count of the six-way head.
|
||||
This confirms the six-way softmax competition is a significant contributor.
|
||||
|
||||
## 11. Commit hash for diagnostic tooling
|
||||
|
||||
`774c066`
|
||||
@@ -0,0 +1,243 @@
|
||||
# Slice 18: Sparse lexical gate beats every e5 head on the aggregate action boundary, and still collapses on capability questions it has not seen
|
||||
|
||||
## 0. Frozen Artifacts (unchanged from slices 16-17)
|
||||
|
||||
```text
|
||||
development corpus v2 hash: b27fd48f478ca477
|
||||
original frozen holdout hash: ad297fbdbbea704b (byte-identical, uninspected)
|
||||
text normalization: NormalizeMatchText (NFKC, lowercase, whitespace-collapse; punctuation and ё kept)
|
||||
representation: sparse TF-IDF over RAW TEXT (e5 vectors ignored)
|
||||
embedding file: /tmp/mvn-experiment/embeddings.json
|
||||
total examples: 3025
|
||||
dev pool: 2490
|
||||
frozen holdout: 535
|
||||
router-residual: 2943
|
||||
```
|
||||
|
||||
No ONNX runtime on this box, so no e5 re-embedding; sparse features read the
|
||||
`text` field directly. Action class: 796 action vs 1694 not_action in dev pool.
|
||||
|
||||
## 1. What was tested
|
||||
|
||||
Three order-sensitive sparse representations, each with an L2-regularised
|
||||
logistic head, under the same grouped 5-fold CV as slices 15-17:
|
||||
|
||||
| repr | tokenization | vocab |
|
||||
| --- | --- | --- |
|
||||
| word | word 1-2 grams | 1,271 |
|
||||
| char | Unicode char 3-5 grams | 8,132 |
|
||||
| both | [word ; char] concatenated | 9,403 |
|
||||
|
||||
All fitted on the *development corpus only*, evaluated by grouped CV with the
|
||||
existing `cv_fold` assignment. Hyperparameters fixed (C=1.0, TF-IDF
|
||||
sublinear_tf, min_df=2) — no grid search, to report the floor.
|
||||
|
||||
## 2. Representation comparison, grouped CV (binary action gate)
|
||||
|
||||
| repr | ROC-AUC | PR-AUC | action_P | action_R | FA count | FA rate |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| word | 0.895 | 0.833 | 0.921 | 0.425 | 29 | 1.2% |
|
||||
| char | 0.894 | 0.802 | 0.804 | 0.470 | 91 | 3.7% |
|
||||
| both | 0.909 | **0.838** | 0.875 | 0.485 | 55 | 2.2% |
|
||||
|
||||
"both" is the best by PR-AUC and is used for every section below.
|
||||
|
||||
### Fold variance (both, at 0.5)
|
||||
|
||||
```text
|
||||
fold ROC-AUC PR-AUC action_P action_R FP FN n
|
||||
------------------------------------------------------------
|
||||
0 0.962 0.903 0.893 0.347 6 94 476
|
||||
1 0.926 0.946 0.926 0.687 11 63 341
|
||||
2 0.936 0.942 1.000 0.367 0 188 707
|
||||
3 0.901 0.699 0.699 0.674 25 28 506
|
||||
4 0.879 0.675 0.705 0.456 13 37 460
|
||||
```
|
||||
|
||||
Fold 2 and fold 3 are the hard ones, as in every slice: fold 3 (the capability /
|
||||
recall-heavy held-out split) sees most of the remaining 25 false actions.
|
||||
|
||||
## 3. Comparison against every e5 baseline (slice 17)
|
||||
|
||||
| model | extras | PR-AUC | action_P | action_R | FA rate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| e5 binary linear | ~385 | 0.707 | 0.688 | 0.476 | 6.9% |
|
||||
| e5 binary MLP H=32 | 12,353 | 0.692 | 0.673 | 0.569 | 8.8% |
|
||||
| **sparse word+char logistic** | **9,403 (sparse)** | **0.838** | 0.875 | 0.485 | **2.2%** |
|
||||
|
||||
The sparse lexical head **raises binary PR-AUC from 0.707 → 0.838** (e5 linear)
|
||||
and cuts the false-action rate from 6.9% to 2.2%, with comparable recall. On the
|
||||
*aggregate* action/non-action boundary that Maven guards, a cheap TF-IDF n-gram
|
||||
surface strictly dominates a frozen mean-pooled e5 vector.
|
||||
|
||||
## 4. Safety operating curve (both)
|
||||
|
||||
A threshold exists that clears P ≥ 0.95 with materially better recall than e5:
|
||||
|
||||
```text
|
||||
threshold action_P action_R FA count FA rate recall @ P>=0.95
|
||||
-----------------------------------------------------------------
|
||||
0.620 0.940 0.355 18 0.0072 0.000
|
||||
0.655 0.935 0.325 18 0.0072 0.000
|
||||
0.715 0.959 0.264 9 0.0036 ✓
|
||||
0.730 0.966 0.247 7 0.0028 ✓
|
||||
0.745 0.968 0.225 6 0.0024 ✓
|
||||
...
|
||||
0.955 1.000 0.004 0 0.0000 ✓
|
||||
```
|
||||
|
||||
Best recall inside the P ≥ 0.95 region is **0.264** (FA 9, rate 0.36%). That is a
|
||||
real usable operating point for a strict gate — slice 17's e5 MLP had *no*
|
||||
threshold reaching P ≥ 0.95 at all.
|
||||
|
||||
## 5. Leave-generator-family-out (both, held-out family)
|
||||
|
||||
Train without a family, evaluate on that family. The families that exist in the
|
||||
development corpus:
|
||||
|
||||
| held-out family | rows | pos/neg | action_P | action_R | FA | accuracy |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| polite_request | 460 | 127/333 | 0.927 | 1.000 | 10 | 97.8% |
|
||||
| modal_request | 223 | 223/0 | 1.000 | 0.852 | 0 | 85.2% |
|
||||
| first_person_request | 791 | 223/568 | 0.995 | 0.906 | 1 | 97.2% |
|
||||
| reordered_target | 332 | 96/236 | 1.000 | 1.000 | 0 | 100.0% |
|
||||
| **capability_question** | **126** | **0/126** | **—** | **—** | **84** | **33.3%** |
|
||||
| question | 102 | 0/102 | — | — | 0 | 100.0% |
|
||||
|
||||
Request-form families generalize cleanly (0-10 FA). The **capability_question
|
||||
family collapses when held out: 84 of 126 (66.7%) fire as actions.** That is the
|
||||
semantic-pragmatics family, not a surface-form family. `negation`,
|
||||
`reported_speech`, `quotation`, `hypothetical` have zero tagged rows in v2 dev,
|
||||
so they cannot be held out here; they remain a coverage gap for a later corpus.
|
||||
|
||||
## 6. Paired action/capability ordering test (both, no leakage)
|
||||
|
||||
The task's core: rank an executable action above its semantically-identical
|
||||
capability-question sibling. Paired by shared object noun (device/entity) plus
|
||||
home domain:
|
||||
|
||||
```text
|
||||
pairs: 2268
|
||||
ordering accuracy: 0.571 (chance = 0.5)
|
||||
mean margin (act-cap): +0.074 (tiny)
|
||||
median margin: +0.066
|
||||
reversed pairs: 972
|
||||
```
|
||||
|
||||
0.571 ordering accuracy is barely above chance. Sparse local features see the
|
||||
same verb-object n-grams in both members of a pair and cannot decide which one
|
||||
is executable. This is the *specific* weakness — the gate that passes the aggregate
|
||||
binary test (above) fails the pairwise pragmatics test.
|
||||
|
||||
Sample reversed pairs (capability question scored as *more* action-like than its
|
||||
executable sibling):
|
||||
|
||||
```text
|
||||
cap 'ты можешь выключить свет' P=0.726 < act 'выключи свет в спальне' P=0.527
|
||||
cap 'ты можешь выключить свет' P=0.726 < act 'выключить свет на кухне' P=0.361
|
||||
cap 'ты можешь выключить свет' P=0.726 < act 'выключи свет в спальне, пожалуйста' P=0.569
|
||||
```
|
||||
|
||||
## 7. Punctuation ablation (both)
|
||||
|
||||
Training on punctuation-stripped text and evaluating both forms:
|
||||
|
||||
```text
|
||||
trained-stripped, eval punctuated: P=0.993 R=1.000 FA=6 (0.2%)
|
||||
trained-stripped, eval stripped: P=0.993 R=1.000 FA=6 (0.2%)
|
||||
```
|
||||
|
||||
Stripping punctuation *before* training removes the trailing `?`/`,` noise and
|
||||
cuts false actions from 55 to 6 with perfect recall. The gate does not need
|
||||
punctuation cues — and under voice input (which the stress protocol simulates)
|
||||
it must not rely on them. Stripped-input training is the better configuration.
|
||||
|
||||
## 8. Voice-like stress (both, model trained on all text)
|
||||
|
||||
```text
|
||||
all: n=874 capability-question FA 0.0% (42) modal action recall 1.000 (350)
|
||||
no_final: n=132 capability-question FA 0.0% (21) modal action recall 0.000 (0)
|
||||
```
|
||||
|
||||
Under the stress protocol the sparse gate keeps every capability question below
|
||||
threshold and every modal/polite action above it, on the *in-distribution*
|
||||
subset (the training set includes capability questions). This is consistent with
|
||||
the ablation: punctuation is not load-bearing. The honest generalization test
|
||||
remains section 5, where unseen capability questions do break.
|
||||
|
||||
## 9. Six-way probe (both, sparse logistic)
|
||||
|
||||
```text
|
||||
accuracy: 63.1%
|
||||
macro F1: 0.470
|
||||
action P: 0.531 action R: 0.923
|
||||
FA rate: 26.1% (649)
|
||||
```
|
||||
|
||||
Per-route F1:
|
||||
|
||||
```text
|
||||
action P=0.531 R=0.923 F1=0.674 (n=796)
|
||||
conversation P=0.250 R=0.011 F1=0.021 (n=93)
|
||||
knowledge P=0.755 R=0.678 F1=0.715 (n=715)
|
||||
memory_write P=0.770 R=0.430 F1=0.552 (n=553)
|
||||
system P=0.759 R=0.376 F1=0.503 (n=226)
|
||||
uncertain P=0.667 R=0.243 F1=0.356 (n=107)
|
||||
```
|
||||
|
||||
As a *routing* head sparse is worse than e5 (six-way macro F1 0.470 vs e5 linear
|
||||
0.675) — the non-action routes need real semantics, which n-grams do not carry.
|
||||
The win is specific to the **binary action gate**, not to full routing.
|
||||
|
||||
## 10. Artifact size and latency
|
||||
|
||||
```text
|
||||
repr vocab fp32 model bytes vectorize+fit (2490 rows)
|
||||
word 1,271 5,084 0.07s
|
||||
char 8,132 32,528 0.10s
|
||||
both 9,403 37,612 0.12s
|
||||
```
|
||||
|
||||
A hashed-n-gram production form (fixed-width, no vocabulary growth, sub-50 kB
|
||||
weights) is clearly feasible; it was not implemented in Go for this slice.
|
||||
|
||||
## 11. Conclusion
|
||||
|
||||
### Verdict: sparse lexical features are sufficient for the aggregate binary gate, insufficient for the pragmatics boundary
|
||||
|
||||
Three findings, one per decision rule in the brief:
|
||||
|
||||
1. **Sparse is sufficient for the aggregate boundary.** PR-AUC 0.838 vs e5's
|
||||
0.707, false-action rate 2.2% vs 6.9%, and — unlike e5 — a real P ≥ 0.95
|
||||
operating point with recall 0.264 (FA 9, 0.36%). A simple TF-IDF n-gram head
|
||||
over the raw text beats every frozen e5 head tested on the class that Maven
|
||||
actually guards. This is not e5 vs sparse being close; it is a large,
|
||||
reproducible margin.
|
||||
|
||||
2. **It is not template leakage — it is semantic-family confusion.** Held-out
|
||||
capability questions fail at 84/126 (66.7%), and the pairwise ordering test
|
||||
lands at 0.571 (chance). These are not distinct surface forms leaking into one
|
||||
another; the capability question and its executable sibling share the same
|
||||
verb-object n-grams verbatim. The residual false actions concentrate exactly
|
||||
there (capability_question + capability-ha/tool split groups dominate the
|
||||
decomposition). A representation that held on to per-family surface templates
|
||||
and nothing else would still collapse on these — the two members of each pair
|
||||
*are* surface-identical apart from the handful of politeness/modal tokens that
|
||||
the n-grams cannot learn to weigh.
|
||||
|
||||
3. **A sequence encoder is justified for the pragmatics boundary.** Order and
|
||||
the trailing politeness/modality are the deciding signal, and local n-grams
|
||||
demonstrably cannot rank them (0.571). The aggregate binary gate is a solved
|
||||
sub-problem that a cheap sparse head holds at FA 2.2%; the open question is
|
||||
whether an order-sensitive encoder separates capability questions from their
|
||||
executable siblings without losing that. That is now the measured, specific
|
||||
target for the next slice, and the paired-ordering test in section 6 is the
|
||||
metric to drive it.
|
||||
|
||||
Practical recommendation carried out of this slice: if a sparse gate ships, train
|
||||
it on **punctuation-stripped** text — it is strictly better (FA 6) and immune to
|
||||
the voice-stress artifact that capped the e5 MLP.
|
||||
|
||||
## 12. Commit hash for tooling
|
||||
|
||||
`f2b65cd` — `cmd/semantic-router-experiment/slice18_sparse.py`
|
||||
@@ -0,0 +1,315 @@
|
||||
# On the frozen 1652-row five-way residual non-action task the deployed trained cascade (routing heads + e5 centroid classifier, LLM out) already matches a fresh linear head on the same frozen embeddings — 0.715 vs 0.712 macro-F1 — while the deterministic hash-classifier floor resolves only 10.9% of the pool (acc 0.171, 89.1% clarify); cross-family generalization is structurally absent (leave-one-family-out macro-F1 0.150; fact holdout acc 0.018; system/conversation/uncertain holdouts 0.000), the trained cascade calls an executable action on 143 residual non-action rows (8.7%; 91 of them knowledge), and the five-way head swallows 94% of the 766 action OOD rows into non-action buckets at argmax — a new representation or a new head are not justified; the levers are deterministic
|
||||
|
||||
Date: 2026-09-08 · Task: slice 22 (brief after the accepted slice 21, task/725) · Box: workpc, Arch, RX 7900 GRE, 32 GB · Build: `cmd/semantic-router-experiment/slice22` (Go 1.25.12, `-mode legacy`/`-mode heads`) + `slice22_emit.py` + `slice22_main.py` in the frozen venv `/tmp/mvn-exp-venv` (sklearn 1.9.0). ONNX for the heads run via `MAVEN_ONNX_LIB` pointing at the venv's `libonnxruntime.so.1.29.0`.
|
||||
|
||||
## 0. Frozen artifacts and population
|
||||
|
||||
```text
|
||||
corpus (3266 → frozen 3025): dev pool 2490, byte-identical to slices 16-21
|
||||
dev residual (fast_path_resolved=false): 2418
|
||||
residual non-action pool (this slice): 1652
|
||||
knowledge 715 / memory_write 553 / system 184 / conversation 93 / uncertain 107
|
||||
53 family_id = 53 split_group (no row differs); folds {0:317, 1:140, 2:410, 3:408, 4:377}
|
||||
action rows held out as OOD probes: 766
|
||||
frozen embeddings: model_quantized@384/tok2, mean-pool + L2, 'query: ' prefix
|
||||
```
|
||||
|
||||
Artifacts under `/tmp/mvn-s22/`: `pool.json` (1652 rows, `idx` = position in dev
|
||||
pool dev-order, so embeddings align by index with `slice19.load_dev`),
|
||||
`ood.json` (766), `legacy.json` (mode `legacy`), `legacy_heads.json` (mode
|
||||
`heads`), `results.json` (all sections below). All three JSON outputs are
|
||||
reproducible: the Go harness is deterministic, and the Python sections carry
|
||||
`random_state=42`.
|
||||
|
||||
## 1. What is being asked
|
||||
|
||||
When the fast path misses, the execution-frame guard passes, and no executable
|
||||
action is warranted, the utterance is one of five residual non-action
|
||||
semantics: **conversation, knowledge, memory_write, system, uncertain**. This
|
||||
slice measures whether the deployed e5-small embeddings (384-d, frozen) with a
|
||||
linear softmax head suffice for that five-way decision, against three floors
|
||||
and two deployed baselines. The executable-action population is out of scope
|
||||
for the head (closed in slices 18-21); its rows are OOD probes only.
|
||||
|
||||
Decision criteria carried from the brief, judged in §13:
|
||||
e5-linear sufficient / sparse sufficient / corpus-taxonomy problem / new
|
||||
representation justified.
|
||||
|
||||
## 2. Method and baselines
|
||||
|
||||
Five-way projection rules (the daemon's behaviour, `project()` in
|
||||
`legacy_main.go`): `chat → conversation`, `query → knowledge`,
|
||||
`fact/note → memory_write`, `system → system`; `act/reminder` on a trusted
|
||||
non-action row is recorded **verbatim as `action` with
|
||||
`illegal_action_prediction=true`** and never softly re-mapped to `uncertain`;
|
||||
`Clarify` and route errors become `uncertain`. In the metrics of §4-§5 the 143
|
||||
`action` projections count as errors (predicted `uncertain`), so the reported
|
||||
scores already carry the price of the false-action leak.
|
||||
|
||||
Three routers measured (all: stage-0 grammars, `StubDateTimeParser`,
|
||||
`DefaultFactParser`, act allowlist of 18 verbs, threshold 0.55):
|
||||
|
||||
```text
|
||||
legacy buildMinimalRouter(): StageZeroGrammars + HashEmbedder(1024)
|
||||
nearest-centroid classifier, seeds models/seeds (331 examples)
|
||||
→ the deterministic floor, no model, no ONNX
|
||||
heads grammars + RouterHeads (router_heads.onnx, 0.6 decline) + e5 ONNX
|
||||
embedder nearest-centroid classifier (same seeds)
|
||||
→ the deployed cascade with the LLM stage out (docs/routing.md
|
||||
pickLLMRouter → classifier); the production offline behaviour
|
||||
```
|
||||
|
||||
The two modes were smoke-verified to diverge on the same 40-row slice (the
|
||||
NN heads fire at softmax 0.94-0.98 on residual rows the hash floor clarifies at
|
||||
0.16-0.24), and the full-population files were checked for internal
|
||||
consistency (producer/stage/stage-0 counts add to 1652) before use. Any number
|
||||
in this report comes from `results.json`, not from a transcript.
|
||||
|
||||
## 3. Stage-0 grammar hit under the live rule set: corpus drift
|
||||
|
||||
185 of the 1652 "residual" rows are resolved by the **current** stage-0
|
||||
grammars (`producer=grammar`): by intent `query 157 / note 14 / chat 7 /
|
||||
system 6 / reminder 1`, by truth-route `knowledge 157 / memory_write 12 /
|
||||
system 6 / conversation 2 / uncertain 8`. `corpus fast_path_resolved` was set
|
||||
by a corpus-side mirror (`cmd/corpus-factory/main.go`), not by the live rule
|
||||
set; a grammar landed (or the mirror froze) after the corpus was built. The
|
||||
pool remains 1652 for the primary metrics — dropping the 185 would not move
|
||||
any conclusion below — and this drift is reported separately as
|
||||
`grammar_drift: hits 185 / grammar-pure 1467`. The mirror should be reconciled
|
||||
with the live grammar set.
|
||||
|
||||
## 4. Baseline A — the deterministic floor (`legacy`)
|
||||
|
||||
```text
|
||||
acc 0.1707 macro-F1 0.1202 illegal_action_prediction 3
|
||||
conversation P 0.000 R 0.000 F1 0.000 n=93
|
||||
knowledge P 1.000 R 0.220 F1 0.360 n=715
|
||||
memory_write P 0.857 R 0.022 F1 0.042 n=553
|
||||
system P 1.000 R 0.033 F1 0.063 n=184
|
||||
uncertain P 0.073 R 1.000 F1 0.135 n=107
|
||||
```
|
||||
|
||||
The hash classifier fires above 0.55 on 180 rows (10.9%); the other 1472
|
||||
(89.1%) are below-threshold `uncertain`. The three illegal predictions are all
|
||||
`uncertain:incomplete-reminder` ("напомни", "напомни, пожалуйста", "я хочу
|
||||
напомни") — the reminder grammar fires and the daemon would drive an action
|
||||
probe on a row the corpus trusted as non-action. This is the boundary class
|
||||
the deterministic floor cannot price, and it is small.
|
||||
|
||||
## 5. Baseline B — the deployed cascade with the LLM out (`heads`)
|
||||
|
||||
```text
|
||||
acc 0.7724 macro-F1 0.7150 illegal_action_prediction 143
|
||||
conversation P 0.550 R 0.828 F1 0.661 n=93
|
||||
knowledge P 0.858 R 0.744 F1 0.797 n=715
|
||||
memory_write P 0.940 R 0.825 F1 0.879 n=553
|
||||
system P 0.924 R 0.658 F1 0.768 n=184
|
||||
uncertain P 0.326 R 0.841 F1 0.470 n=107
|
||||
```
|
||||
|
||||
Producer mix: NN-heads 1176, e5-classifier 291, grammar 185. The NN heads name
|
||||
`act` on 159 rows and `reminder` on 18; **143 of those land on trusted
|
||||
non-action rows** (`illegal`), split `knowledge 91 / memory_write 46 / system 2 /
|
||||
uncertain 4`. By family the biggest offenders are `knowledge:capability-ha` 45,
|
||||
`knowledge:capability-tool` 38 (i.e. 83 capability-question rows — the slice-21
|
||||
population — called executable), `mw-free-remember` 35, `kq-homelab-disk` 5,
|
||||
`mw-note-task` 5. Producer split of the illegal 143: classifier 102 (conf
|
||||
0.89-0.91), NN-heads 40, grammar 1. Example rows: "хватает ли места на диске"
|
||||
→ `act` 0.90; "я хочу покажи шаги за неделю" → `reminder` 0.91; "машинное
|
||||
обучение опиши" → `act` 0.89. The trained components cannot refuse: their
|
||||
largest directional error is toward execution, which is exactly the boundary
|
||||
the deterministic stage-0 + execution-frame-guard arm owns.
|
||||
|
||||
This is the same shape the fresh head shows in §8: confidence 0.89-0.91 is not
|
||||
a refusal signal either.
|
||||
|
||||
## 6. The fresh e5-linear head (the ask)
|
||||
|
||||
Out-of-fold, grouped by the pool's split folds, C grid {0.1, 1, 10}:
|
||||
|
||||
```text
|
||||
C=0.1 acc 0.6096 macro-F1 0.2728
|
||||
C=1.0 acc 0.6949 macro-F1 0.5974
|
||||
C=10.0 acc 0.7222 macro-F1 0.7117 ← used everywhere below
|
||||
```
|
||||
|
||||
Per-fold OOF (C=10): fold 0 acc 0.782 / macro 0.783; fold 1 (n=140) 0.736 /
|
||||
0.605; fold 2 0.705 / 0.576; fold 3 0.713 / 0.710; fold 4 0.695 / 0.592;
|
||||
mean acc 0.726 ± 0.031, mean macro-F1 0.653 ± 0.080.
|
||||
|
||||
```text
|
||||
OOF per class (C=10):
|
||||
conversation P 0.840 R 0.731 F1 0.782 n=93
|
||||
knowledge P 0.713 R 0.761 F1 0.736 n=715
|
||||
memory_write P 0.720 R 0.749 F1 0.734 n=553
|
||||
system P 0.687 R 0.500 F1 0.579 n=184
|
||||
uncertain P 0.758 R 0.701 F1 0.728 n=107
|
||||
```
|
||||
|
||||
The fresh head and the deployed cascade land at the same operating point:
|
||||
**OOF 0.712 vs deployed 0.715 macro-F1, 0.722 vs 0.772 accuracy.** The linear
|
||||
head on the deployed embeddings does not beat what is already wired, and the
|
||||
accuracy gap is explained by the 143 illegal rows the head cannot make (it has
|
||||
no action class) — a structural, not a representational, difference.
|
||||
|
||||
## 7. Floors
|
||||
|
||||
```text
|
||||
majority (all knowledge) acc 0.4328 macro-F1 0.1208
|
||||
centroid (cosine, in-fold means) acc 0.6731 macro-F1 0.6494
|
||||
sparse word+char TF-IDF logistic acc 0.6247 macro-F1 0.4463 vocab 7079
|
||||
e5-linear (the ask) acc 0.7222 macro-F1 0.7117
|
||||
```
|
||||
|
||||
Sparse is below e5-linear on both axes despite a 7079-term vocabulary; the
|
||||
lexical signal is present but orders of magnitude weaker than the embedding.
|
||||
Centroid cosine is the closest floor and e5-linear beats it by +0.062 macro-F1.
|
||||
|
||||
## 8. Cross-family generalization is absent
|
||||
|
||||
Route-family leave-*-out on the frozen 53 families. Each group below is a
|
||||
single-route family set, so the route is literally absent from training when
|
||||
the family is removed:
|
||||
|
||||
```text
|
||||
family (route) n acc macro-F1 dominant prediction
|
||||
capability (kno) 126 0.2619 0.0830 knowledge (F1 0.42)
|
||||
world (kno) 143 0.7622 0.1730 knowledge (F1 0.87)
|
||||
calendar (kno) 92 0.6304 0.1547 knowledge (F1 0.77)
|
||||
recall (kno) 114 0.7632 0.1731 knowledge (F1 0.87)
|
||||
fact (mwr) 389 0.0180 0.0071 memory_write (F1 0.04)
|
||||
note (mwr) 104 0.2212 0.0724 memory_write (F1 0.36)
|
||||
remember (mwr) 60 0.2000 0.0667 memory_write (F1 0.33)
|
||||
system (sys) 184 0.0000 0.0000 (nothing)
|
||||
conversation (conv) 93 0.0000 0.0000 (nothing)
|
||||
uncertain (unc) 107 0.0000 0.0000 (nothing)
|
||||
leave-one-family-out over all 53: acc mean 0.682, macro-F1 mean 0.149
|
||||
```
|
||||
|
||||
The world/calendar/recall rows read as "good acc" only because knowledge is the
|
||||
majority: the head's macro-F1 collapses (0.15-0.17). On fact, correct
|
||||
predictions fall to 1.8%; on system, conversation and uncertain to exactly
|
||||
zero. Within-family OOF macro-F1 is 0.71 (all routes present), and removing one
|
||||
family drops it to 0.15. The head is a per-family-template memoriser: it
|
||||
transfers **no route semantics** to an unseen template family. This is the
|
||||
ceiling of the frozen representation, not a hyperparameter problem.
|
||||
|
||||
## 9. knowledge vs memory_write, matched pairs
|
||||
|
||||
`build_pairs` pairs every recall/knowledge row against every write row that
|
||||
shares a corpus-justified lexeme, and asks whether the model puts the higher
|
||||
probability on the truth side (memory_write for write, knowledge for recall):
|
||||
|
||||
```text
|
||||
lexeme pairs mw_over_k order_acc mean_margin
|
||||
"вод" 480 0.863 +0.298
|
||||
"задач" 720 0.735 +0.122
|
||||
"dns/сервер/vlan" 180 0.106 -0.228 ← homelab notes vs homelab status
|
||||
```
|
||||
|
||||
The fact/recall contrast ("воды попил" vs "сколько воды") separates well. The
|
||||
**collision is the note-keep vs report-family**: `note:homelab` and
|
||||
`note:task` write rows are ranked *below* their `knowledge:homelab-status` and
|
||||
`knowledge:task-check` partners 89% of the time, with a negative margin. The
|
||||
frozen embeddings put both sides of "дом" as one cluster; the head cannot
|
||||
resolve intent direction there. This is the concrete, smallest instance of the
|
||||
§8 collapse and the strongest single argument that a deterministic
|
||||
note-vs-recall disambiguator (subject class, scheduling verbs, report vs
|
||||
record verbs) would buy more than any representation retrain.
|
||||
|
||||
## 10. uncertain as an explicit class
|
||||
|
||||
```text
|
||||
OOF: uncertain P 0.758 / R 0.701 / F1 0.728 (n=107)
|
||||
pulled out of uncertain (true uncertain, mislabelled): knowledge 14,
|
||||
memory_write 11, conversation 7, system 0
|
||||
pulled into uncertain (not uncertain, predicted uncertain): conversation 9,
|
||||
knowledge 7, memory_write 5, system 3
|
||||
```
|
||||
|
||||
`uncertain` is taught and recalled (R 0.70), and the pulls are small and
|
||||
mirrored — the "ask instead" bucket behaves like a class, which is what the
|
||||
daemon needs it to be only if the ask threshold is real (§11).
|
||||
|
||||
## 11. Calibration, abstention, and the action OOD
|
||||
|
||||
Confidence (max-softmax of the OOF head): correct-mean 0.766, wrong-mean 0.612,
|
||||
ECE 0.0275, log-loss 0.7365, macro-Brier 0.0781. The head is well calibrated
|
||||
on-pool.
|
||||
|
||||
```text
|
||||
threshold (max-softmax) coverage accuracy macro-F1
|
||||
0.10 1.000 0.722 0.712
|
||||
0.28 0.996 0.725 0.715
|
||||
0.46 0.907 0.756 0.757
|
||||
0.64 0.676 0.822 0.829
|
||||
0.82 0.379 0.887 0.913
|
||||
```
|
||||
|
||||
**Action OOD probes** — the 766 frozen action rows, scored by the same five-way
|
||||
head, are the wrong-population test the head must refuse:
|
||||
|
||||
```text
|
||||
argmax class: knowledge 370 (48.3%), memory_write 347 (45.3%),
|
||||
uncertain 38 (5.0%), conversation 11, system 0
|
||||
conf mean 0.653, conf median 0.644, conf > 0.9 on 46 rows (6.0%)
|
||||
```
|
||||
|
||||
94.6% of action utterances are named a resolvable non-action route at argmax,
|
||||
and 6% land above the pool's own 0.82 high-confidence line. A five-way head
|
||||
replacing the current cascade would turn action rows into confident
|
||||
knowledge/write decisions. Two consequences: (a) the head must stay behind the
|
||||
deterministic act/reminder + execution-frame-guard arm and never precede it —
|
||||
the current cascade order already does that; (b) it cannot double as an
|
||||
action-refuser by thresholding, so the reminder/act grammar family stays the
|
||||
only refusal mechanism, and its 143-row miss rate (§5) is the real open
|
||||
boundary for the trained arm, priced at 8.7% of residual non-action.
|
||||
|
||||
## 12. Artifact cost
|
||||
|
||||
Linear head: 1925 params (5×384 + 5 bias), 7.52 KiB fp32, incremental
|
||||
decision latency mean 190 µs / p50 165 µs (pure head, Python timing on the
|
||||
box; the shared cost is the e5 forward the routing-heads path already pays).
|
||||
The head itself is negligible; the representation forward is not the lever.
|
||||
|
||||
## 13. Verdict on the decision criteria
|
||||
|
||||
| criterion | verdict |
|
||||
| --- | --- |
|
||||
| e5-linear suffices | **No.** In-fold parity with what is deployed (0.712 vs 0.715 macro-F1), but it transfers nothing across template families (LOO macro-F1 0.149) and cannot abstain on action rows (94.6% swallowed). |
|
||||
| sparse suffices | **No.** 0.6247 / 0.4463, below the linear head on both axes. |
|
||||
| corpus-taxonomy problem | **Partly.** 185 rows the corpus called residual are resolved by the live grammar set today (mirror staleness), and the two largest illegal-action families are capability questions — a corpus-trust boundary, not a representation one. |
|
||||
| new representation justified | **No on this evidence.** The ceiling is family-lexicon transfer, not embedding capacity: replacing the frozen e5 changes nothing measured; the floors show the same collapse. A new representation would need to demonstrate the §8 holdouts moving before it earns a retrain. |
|
||||
|
||||
## 14. Operational implications
|
||||
|
||||
* **Keep the cascade order.** Stage-0 grammars and the execution-frame guard
|
||||
(slice 21) are the only two mechanisms that keep the 8.7% false-action leak
|
||||
from reaching tools; neither the linear head nor the deployed NN heads can
|
||||
be promoted to the action boundary.
|
||||
* **Reconcile the corpus fast-path mirror** against the live grammar set (185
|
||||
drifting rows). Purely deterministic, zero-model, and removes a class of
|
||||
"legacy understates the router" arguments.
|
||||
* **Add a deterministic note-vs-recall disambiguator** for the homelab/task
|
||||
lexical clusters (the 0.106 ordering in §9): subject class, report verbs vs
|
||||
record verbs, date/scheduling adverbs. Expected lever: most of the
|
||||
memory_write recall gap without touching embeddings.
|
||||
* **Watch the classifier's confident `act` calls** (conf 0.89-0.91 on
|
||||
capability-question and free-remember rows, §5). The "хватает ли места на
|
||||
диске" → `act` 0.90 row is a slice-21 capability question that the trained
|
||||
arm still prices as executable; the deterministic guard is the only thing
|
||||
standing between it and an action probe.
|
||||
* **Do not threshold the uncertain bucket for action refusals.** The 38/766
|
||||
uncertain picks on OOD action rows show an explicit uncertain class is near
|
||||
useless as a guard; the numbers are confidence, not category.
|
||||
|
||||
## 15. Commit hashes
|
||||
|
||||
* Tooling (emit step, Go harness both modes, Python experiment): `29f74dd`
|
||||
(`router/semantic: slice 22 residual non-action router — emit, harness,
|
||||
experiment`).
|
||||
* Report + eval index row: this file with its `docs/evals/CLAUDE.md` entry.
|
||||
* Artifacts under `/tmp/mvn-s22/` (not committed; reproducible by
|
||||
`slice22_emit.py`, then `go run ./cmd/semantic-router-experiment/slice22/
|
||||
-mode legacy` and `-mode heads` with `MAVEN_ONNX_LIB`, then
|
||||
`slice22_main.py`).
|
||||
@@ -0,0 +1,190 @@
|
||||
# Rebuilding corpus fast-path metadata from the real router moves the slice-22 1652-row five-way numbers by ≤2pp (heads 0.772→0.751 acc, e5-linear 0.722/0.712→0.720/0.731 macro-F1) and every aggregate slice-22 conclusion survives, so the learned non-action router investigation is closed as run; the residual pool is 1509 non-action + 720 action OOD rows, and the deterministic hash-classifier floor collapses to acc 0.066 because its earlier 0.171 was almost entirely the 185 rows the stale mirror had mislabelled as residual while the real grammars already resolve them
|
||||
|
||||
Date: 2026-09-08 · Task: slice 23 (fast-path metadata reconciliation; postscript to the accepted slice 22, task/725) · Box: workpc, Arch, RX 7900 GRE, 32 GB · Build: `internal/router/semantic/fastpath.go` + `cmd/semantic-router-experiment/slice23/` (Go 1.25.12) + `slice23_emit.py`/`slice23_main.py` in the frozen venv `/tmp/mvn-exp-venv` (sklearn 1.9.0); ONNX via `MAVEN_ONNX_LIB` pointing at the venv's `libonnxruntime.so.1.29.0`.
|
||||
|
||||
Replaces the corrected-population numbers in [slice 22](2026-09-08-slice22-residual-nonaction-router.md). The slice-22 file keeps its role for the run itself; its 1652-row pool is no longer the current measurement population.
|
||||
|
||||
## 0. What changed and what did not
|
||||
|
||||
The corpus's `fast_path_resolved` flag was stamped by a hand-written regex mirror
|
||||
(`classifyFastPath` in `cmd/corpus-factory/main.go`) that drifted from the
|
||||
stage-0 grammars. Slice 22 noticed 185 residual rows resolving at runtime. This
|
||||
slice makes the flag a derivation from the real router and re-measures.
|
||||
|
||||
```text
|
||||
corpus 3025 rows, dev pool 2490, frozen holdout 535 unchanged
|
||||
dataset_hash b27fd48f478ca477cab1e59773bb353a unchanged (texts)
|
||||
fast_path_resolved 82 → 271 (dev 72 → 261, frozen 10, preserved)
|
||||
residual 2943 → 2754 (dev 2418 → 2229, frozen 525, preserved)
|
||||
residual non-action pool 1652 → 1509
|
||||
knowledge 715→558 / memory_write 553→541 / system 184→220 / conversation 93→91 / uncertain 107→99
|
||||
action OOD probes 766 → 720
|
||||
residual dev pool folds {0:317,1:140,2:410,3:408,4:377} → {0:247,1:111,2:373,3:386,4:392}
|
||||
```
|
||||
|
||||
The rebuild changed **exactly the derived fields on exactly the 277 dev rows**
|
||||
that disagree with the router: 0 rows differ in text, route, source, source_id,
|
||||
split_group, or the non-`router_residual` tags. The 22 stale frozen rows (see §1)
|
||||
are preserved verbatim per the merge rule — reported, never silently rewritten.
|
||||
|
||||
## 1. The drift, decomposed (299 disagreements across 3025 corpus rows)
|
||||
|
||||
`cmd/semantic-router-experiment/slice23` runs `DeriveFastPath` (the production
|
||||
`TryFastPath` over `StageZeroGrammars` with the experiment act allowlist) on
|
||||
every row and attributes the winner. Output: `/tmp/mvn-s23/drift.json`.
|
||||
|
||||
```text
|
||||
mirror_missed (stored residual, runtime resolves) 249
|
||||
dev 233 = 185 non-action + 48 action
|
||||
dev non-action by grammar: calendar-query 66, definition-query 30,
|
||||
narrative-query 26, task-capture 12, agenda-query 9, task-list-query 9,
|
||||
plan-day-query 8, ambiguous-fragment 7, time-query 6, rest-of-day 5,
|
||||
event-time 4, possession-statement 2, reminder-wakeword 1
|
||||
frozen 16 (possession 6, implicit-elapsed 3, wakeword-act 2, + 5 singles)
|
||||
claimed_fast_now_miss (stored fast, runtime declines) 50
|
||||
dev 44 (system 42, action 2) + frozen 6 (memory_write "note: " rows)
|
||||
every one has ≥1 stage-0 grammar matching its shape and declining the build;
|
||||
none is a "no grammar matches" row
|
||||
```
|
||||
|
||||
The **185 dev non-action `mirror_missed` rows are byte-identical in count and
|
||||
route split** to the slice-22 harness's `grammar_hits_in_pool: 185`
|
||||
(knowledge 157, memory_write 12, system 6, conversation 2, uncertain 8) — a
|
||||
strong cross-check that `DeriveFastPath` equals what the slice-22 harness
|
||||
measured through `Route()`.
|
||||
|
||||
Root cause, two mechanisms:
|
||||
|
||||
1. **Grammar coverage grew after the corpus froze.** `calendar-query` (67),
|
||||
`definition-query` (31), `narrative-query` (27), `task-status` (24),
|
||||
`agenda-query` (9), `possession-statement` (8), `plan-day-query` (8),
|
||||
`event-time-query` (4) and the rest are current stage-0 rules with no
|
||||
counterpart in the mirror. The mirror ever only knew a fixed verb list.
|
||||
2. **The mirror's verb lists over-stamped.** The 50 `claimed_fast_now_miss`
|
||||
rows ("покажи uptime", "note: кран на кухне капает", "what's the date
|
||||
сегодня") matched `toolAliasRe`/`captureVerbRe`/`timeQueryRe`, but no real
|
||||
grammar accepts them: rules like `task-list-query`/`list-capture` use a
|
||||
catch-all `(?s)^\s*(.+)$` pattern with the real gate in `Build`, which
|
||||
declines. The mirror had no concept of a build gate.
|
||||
|
||||
The frozen 22 (16 + 6) are the same two mechanisms on holdout families,
|
||||
including three `ru_routing_v1` rows ("что у меня стоит в календаре на
|
||||
послезавтра", "расскажи про битву при Ватерлоо", "кто такой Линус Торвальдс?")
|
||||
now resolved by calendar/narrative/definition grammars.
|
||||
|
||||
## 2. The authoritative derivation
|
||||
|
||||
`internal/router/semantic/fastpath.go` (non-test) owns:
|
||||
|
||||
- `ExperimentActVerbs()` — the 18 verbs formerly duplicated in
|
||||
`helpers_test.go`, `legacy_build.go` and `heads_main.go`; now one non-test
|
||||
list, so propagation can never drift from measurement.
|
||||
- `DeriveFastPath(text) FastPathOutcome{Matched, Grammar}` — builds the exact
|
||||
router the legacy baseline measures (`router.StageZeroGrammars` + the
|
||||
experiment act matcher + `StubDateTimeParser`/`DefaultFactParser`) and runs
|
||||
`TryFastPath(NormalizedInput{Text, MatchText: NormalizeMatchText(Text)})`.
|
||||
Grammar attribution replays the ordered first-accept walk the router performs,
|
||||
including wake-stripped alternates and build-declined fall-through.
|
||||
|
||||
Consumers:
|
||||
|
||||
- `cmd/corpus-factory` stamps `fast_path_resolved`/`router_residual` from
|
||||
`DeriveFastPath`; the regex mirror is deleted.
|
||||
- `cmd/merge-corpus` validates every v2 row against the derivation and **fails
|
||||
on a stale value**; frozen holdout drift is reported only.
|
||||
- `internal/router/semantic/fastpath_invariant_test.go` asserts every dev row
|
||||
satisfies `fast_path_resolved == DeriveFastPath(text).Matched`; frozen rows
|
||||
are exempt by the merge rule.
|
||||
|
||||
## 3. Rebuild proofs
|
||||
|
||||
```text
|
||||
factory v2: 3008 rows, text-set identical old↔new
|
||||
diff = exactly the fast-path fields on 296 rows; 0 other diffs
|
||||
merge: v2 dev rows 2490 all validated against the router (0 stale)
|
||||
frozen 535 preserved; 22 drift rows reported, none rewritten
|
||||
v1 embedded: 3025 rows, text-set identical; dataset_hash unchanged
|
||||
diff = fast-path fields on the 277 dev rows; 0 other diffs
|
||||
embeddings: regenerated (fast_path_count 82 → 271, residual_count 2943 → 2754)
|
||||
all 3025 vectors differ by ≤ ~0.02 max-abs from the slice-22 file
|
||||
(onnxruntime build noise); on the same 1509 pool the e5-linear
|
||||
score moves only −0.0014 acc / −0.0002 macro-F1 (C=10), i.e. the
|
||||
population effect, not the embedding noise, drives every delta
|
||||
```
|
||||
|
||||
## 4. Baseline deltas on the corrected population
|
||||
|
||||
| measure | slice 22 (n=1652) | slice 23 (n=1509) |
|
||||
| --- | --- | --- |
|
||||
| legacy hash floor | 0.1707 / 0.1202 (illegal 3) | **0.0663 / 0.0265 (illegal 2)** |
|
||||
| deployed cascade (heads) | 0.7724 / 0.7150 (illegal 143) | 0.7515 / 0.7065 (illegal 142) |
|
||||
| e5-linear C=10 | 0.7222 / 0.7117 | 0.7203 / 0.7310 |
|
||||
| centroid (cosine) | 0.6731 / 0.6494 | 0.6753 / 0.6494 |
|
||||
| sparse word+char (both) | 0.6247 / 0.4463 (vocab 6951) | 0.5991 / 0.4588 |
|
||||
| majority | 0.4328 / 0.1208 | 0.3698 / 0.1080 |
|
||||
| leave-one-family-out | 0.6824 / 0.1499, 53 fm | 0.6863 / 0.1519, 53 fm |
|
||||
| fact holdout | 0.0180 (n 389) | 0.0077 (n 389) |
|
||||
| world holdout | 0.7622 (n 143) | 0.6552 (n 87) |
|
||||
| recall holdout | 0.7632 (n 114) | 0.5667 (n 90) |
|
||||
| calendar holdout | 0.6304 (n 92) | 0.5185 (n 27) |
|
||||
| K/MW water pairs | 0.863 (480), +0.298 | 0.850 (480), +0.275 |
|
||||
| K/MW homelab pairs | 0.106 (180), −0.228 | 0.072 (180), −0.247 |
|
||||
| K/MW task pairs | 0.735 (720), +0.122 | 0.403 (216), −0.095 |
|
||||
| uncertain OOF P/R/F1 | 0.758 / 0.701 / 0.728 | 0.734 / 0.697 / 0.715 |
|
||||
| action OOD swallowed | knowledge 370, mw 347 of 766 (conf>0.9: 0.060) | knowledge 364, mw 312 of 720 (conf>0.9: 0.056) |
|
||||
| grammar hits in pool | 185 / 1467 pure | **0 / 1509 pure** |
|
||||
|
||||
The hash floor's collapse is explained structurally: the 185 removed rows were
|
||||
nearly all the hash classifier's correct knowledge hits (correct predictions
|
||||
282 → 98, a one-to-one loss with the drift), so the deterministic floor "resolves"
|
||||
the same rows the real grammars already own.
|
||||
|
||||
## 5. The decisions requested
|
||||
|
||||
1. **Drift decomposition** — §1; 299 = 249 + 50; the slice-22 185 reproduces exactly.
|
||||
2. **Root cause** — §1; coverage growth + build-gate over-stamp, neither visible to a regex mirror.
|
||||
3. **Authoritative derivation** — §2; `DeriveFastPath` on the standard experiment router; duplicated act list removed.
|
||||
4. **Corpus-builder change** — §2-§3; factory stamps from the router, merge validates (fail on stale) + reports frozen drift, invariant test enforces it.
|
||||
5. **Corrected counts** — §0; fast 82→271, residual 2943→2754, pool 1652→1509 non-action + 720 OOD; dataset hash unchanged proves only the derived field moved.
|
||||
6. **Baseline deltas** — §4; all ≤2pp on the head scores; hash floor collapses because its output was the drifted rows.
|
||||
|
||||
## 6. Conclusion survival and the gate
|
||||
|
||||
Every aggregate slice-22 conclusion is unchanged on the corrected population:
|
||||
|
||||
- **e5-linear ≈ deployed cascade.** e5 0.7203 vs heads 0.7515 acc (slice 22:
|
||||
0.7222 vs 0.7724); macro-F1 e5 0.7310 now *exceeds* heads 0.7065. The ≈3pp
|
||||
gap stands; a head on frozen e5 embeddings adds nothing a linear probe lacks.
|
||||
- **Cross-family transfer is structurally absent.** LOFO macro-F1 0.1519;
|
||||
fact holdout 0.0077; system/conversation/uncertain holdouts 0.0000.
|
||||
- **K/MW is a deterministic semantic defect.** Homelab pairs still order
|
||||
backwards (0.072, margin −0.247); task pairs now order backwards too
|
||||
(0.403, margin −0.095, pairs 720→216 as the calendar/task queries leave the
|
||||
pool). Water stays right (0.850). No learned head fixes this at the margin.
|
||||
- **The deterministic floor is not a baseline.** acc 0.0663 ≈ predicting
|
||||
uncertain for everything.
|
||||
- **Action OOD is still swallowed.** 676 of 720 (93.9%) land in
|
||||
knowledge/memory_write at argmax; conf>0.9 5.6%.
|
||||
|
||||
Decision gate from the brief: conclusions **materially the same**. The learned
|
||||
non-action-router investigation is **closed**; no new representation or head is
|
||||
justified. Next slice: the note-vs-recall disambiguator (the one genuine residual
|
||||
deterministic defect the K/MW numbers keep surfacing).
|
||||
|
||||
## Artifacts
|
||||
|
||||
Re-run (corrected population):
|
||||
|
||||
```sh
|
||||
go run ./cmd/semantic-router-experiment/slice23/ -out /tmp/mvn-s23/drift.json
|
||||
go build -o /tmp/mvn-s23/s23 ./cmd/semantic-router-experiment/slice22/
|
||||
MAVEN_ONNX_LIB=…/libonnxruntime.so.1.29.0 /tmp/mvn-s23/s23 -mode legacy -pool /tmp/mvn-s23/pool.json -out /tmp/mvn-s23/legacy.json
|
||||
MAVEN_ONNX_LIB=… /tmp/mvn-s23/s23 -mode heads -pool /tmp/mvn-s23/pool.json -out /tmp/mvn-s23/legacy_heads.json
|
||||
python3 slice23_emit.py # → /tmp/mvn-s23/{pool,ood,stats}.json (1509/720)
|
||||
MAVEN_ONNX_LIB=… python3 slice23_main.py # → /tmp/mvn-s23/results.json
|
||||
```
|
||||
|
||||
Corpus rebuild (proofs in §3): `corpus-factory -out /tmp/corpus_v2.json`, then
|
||||
`merge-corpus -v1 corpus_v1.json -v2 /tmp/corpus_v2.json -out corpus_v1.json`
|
||||
(identity of the change verified against `/tmp/mvn-s23/corpus_v1.old.json`).
|
||||
Embedding regeneration (noise isolation against `/tmp/mvn-s23/embeddings.old.json`).
|
||||
@@ -0,0 +1,305 @@
|
||||
# Slice 19: from-scratch tiny sequence encoders beat sparse and e5 on the capability-question holdout (seed-stable FA 0.48-0.55 vs 0.667 / ~0.98) yet cap at ~50% accuracy on the leave-generator-out pragmatics split — a pretrained prior is the next step
|
||||
|
||||
Date: 2026-09-08 · Task: V-726 (slice 19) · Box: homesrv, Ryzen 5 5600U, 13 GB, CPU-only (this is the production machine) · Build: `cmd/semantic-router-experiment/slice19_*.py`, torch 2.14.0+cpu in `/tmp/mvn-exp-venv`, no GPU.
|
||||
|
||||
## 0. Frozen artifacts
|
||||
|
||||
```text
|
||||
development corpus v2 hash: b27fd48f478ca477
|
||||
original frozen holdout hash: ad297fbdbbea704b (byte-identical, uninspected)
|
||||
text normalization: NormalizeMatchText (NFKC, lowercase, whitespace-collapse; punctuation and ё kept)
|
||||
embedding file: /tmp/mvn-experiment/embeddings.json
|
||||
total examples: 3025
|
||||
dev pool: 2490 (796 action / 1694 not_action)
|
||||
frozen holdout: 535
|
||||
router-residual: 2943
|
||||
```
|
||||
|
||||
Same data as slices 16-18. Training input per brief §9 is always the
|
||||
punctuation-stripped text (`re.sub(r"[^\w\s]", " ", c)` + collapse); the `orig`
|
||||
and `nofinal` variants are evaluation-only stress views.
|
||||
|
||||
### Method note: the tokenizer axis was broken once and fixed
|
||||
|
||||
The BPE first trained on the *stripped* texts. `tokenizers`' ByteLevel BPE then
|
||||
silently **drops boundary punctuation at inference** (`сервис,` → the `сервис`
|
||||
token, comma gone; a comma observed in training keeps its own id 55), so `orig`
|
||||
vs `strip` differed on only 0.6% of the corpus and the BPE stress axis was a
|
||||
no-op. Fixed by training both tokenizers on the *natural* dev texts (punctuation
|
||||
kept, per the normalization line above) while training input remains stripped.
|
||||
After the fix punctuation fires as real in-vocabulary symbols under stress, and
|
||||
35.1% of rows differ between `orig` and `strip`. All numbers below are from the
|
||||
fixed build; the pre-fix run is discarded (its "stress-identical → robust"
|
||||
reading for BPE models was an artifact).
|
||||
|
||||
## 1. What was tested
|
||||
|
||||
Three from-scratch, order-sensitive encoders, each in a size ladder, all trained
|
||||
only on the binary action/not-action label from the dev pool:
|
||||
|
||||
| family | A. CharCNN | B. BiGRU | C. TinyTransformer |
|
||||
| --- | --- | --- | --- |
|
||||
| input | codepoint ids | subword ids (BPE) | subword ids (BPE) |
|
||||
| depth | 1D convs (widths 2-5), global max-pool | 1-layer bidirectional GRU, max-pool~final | 2-4 self-attention blocks (PreNorm, GELU FFN 4x), learned pos, max-pool ~ CLS |
|
||||
| sizes | tiny, medium | tiny, medium, large | small, medium |
|
||||
| params | 27 k / 149 k | 129 k / 356 k / 1,106 k | 558 k / 2,022 k |
|
||||
|
||||
Chosen per the slice-18 handoff: the deciding signal is order and trailing
|
||||
politeness/modality, which local n-grams provably could not rank (pair 0.571).
|
||||
|
||||
## 2. Tokenizers
|
||||
|
||||
Trained on the dev pool only.
|
||||
|
||||
| tokenizer | vocab | notes |
|
||||
| --- | --- | --- |
|
||||
| CharVocab | 73 (id 0 = PAD; OOV → 0) | codepoint ids incl. punctuation and the occasional CJK char |
|
||||
| BpeVocab | 1,234 / 2,048 cap | byte-level BPE, serialized 52,897 bytes |
|
||||
|
||||
Max lengths: char 64, BPE 24. No narrowing to Russian; the corpus is the vocab.
|
||||
|
||||
## 3. Training setup
|
||||
|
||||
Fixed hyperparameters, no grid search, seed = 42 + fold for grouped CV:
|
||||
|
||||
| family | epochs | lr | batch | grad clip | optimiser |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| char_cnn | 20 | 1e-3 | 64 | none | AdamW wd 1e-4, BCE-with-logits |
|
||||
| bigru | 15 | 5e-4 | 64 | 1.0 | AdamW wd 1e-4, BCE-with-logits |
|
||||
| tiny_transformer | 25 | 5e-4 | 64 | 1.0 | AdamW wd 1e-4, BCE-with-logits |
|
||||
|
||||
Grouped 5-fold CV reusing the existing `cv_fold` split; the capability-question
|
||||
and question families are genuinely held out per fold. Padded sequences,
|
||||
zero-pad ignored.
|
||||
|
||||
## 4. Grouped CV, binary gate (strip input, OOF at threshold 0.5)
|
||||
|
||||
| config | PR-AUC | ROC-AUC | action_P | action_R | FA | FA rate |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| char_cnn_tiny | 0.814 | 0.908 | 0.808 | 0.672 | 127 | 5.1% |
|
||||
| char_cnn_medium | **0.838** | 0.917 | **0.816** | 0.706 | 127 | **5.1%** |
|
||||
| bigru_tiny | 0.813 | 0.889 | 0.726 | 0.716 | 215 | 8.6% |
|
||||
| bigru_medium | 0.794 | 0.864 | 0.684 | 0.693 | 255 | 10.2% |
|
||||
| bigru_large | 0.837 | 0.893 | 0.689 | 0.755 | 271 | 10.9% |
|
||||
| tiny_transformer_small | 0.731 | 0.820 | 0.704 | 0.670 | 224 | 9.0% |
|
||||
| tiny_transformer_medium | 0.742 | 0.844 | 0.686 | 0.750 | 273 | 11.0% |
|
||||
|
||||
The char CNN is the best *aggregate* binary separator (as in slice 18, where the
|
||||
n-gram representation owned the aggregate boundary). The sequence models trade
|
||||
aggregate precision for spread-out recall.
|
||||
|
||||
### Fold variance (at 0.5)
|
||||
|
||||
```text
|
||||
config PR mean PR min PR max FA per fold (n)
|
||||
char_cnn_tiny 0.791 0.585 0.967 10, 17, 13, 66, 21
|
||||
char_cnn_medium 0.802 0.572 0.974 12, 27, 10, 59, 19
|
||||
bigru_tiny 0.803 0.632 0.880 25, 53, 37, 63, 37
|
||||
bigru_medium 0.751 0.461 0.930 49, 25, 62, 38, 81
|
||||
bigru_large 0.790 0.511 0.921 20, 43, 58, 60, 90
|
||||
tiny_transformer_small 0.713 0.529 0.923 64, 17, 61, 23, 59
|
||||
tiny_transformer_medium 0.714 0.492 0.900 38, 46, 48, 97, 44
|
||||
```
|
||||
|
||||
Fold 3 (`n=506`, the capability/question-heavy split) is the hard fold for the
|
||||
CNN/GRU families exactly as in every slice since 16.
|
||||
|
||||
## 5. Aggregate comparison against every prior head
|
||||
|
||||
| model | extras | PR-AUC | action_P | action_R | FA rate | cap-Q LOFO FA | pair order |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| e5 binary linear (C=1.0) * | ~385 | 0.623 | 0.688 | 0.476 | 6.9% | 1.000 | 0.114 |
|
||||
| e5 binary MLP H=32 * | 12,353 | 0.674 | 0.673 | 0.569 | 8.8% | 0.976 | 0.136 |
|
||||
| sparse word+char logistic (sl. 18) | 9,403 | 0.838 | 0.875 | 0.485 | 2.2% | 0.667 | 0.571 |
|
||||
| char_cnn_medium (19) | 149 k | 0.838 | 0.816 | 0.706 | 5.1% | 0.976 | 0.535 |
|
||||
| bigru_tiny (19) | 129 k | 0.813 | 0.726 | 0.716 | 8.6% | 0.540 | 0.635 |
|
||||
| tiny_transformer_small (19) | 558 k | 0.731 | 0.704 | 0.670 | 9.0% | 0.421 | 0.763 |
|
||||
|
||||
\* e5 rows are recomputed here with fixed single hyperparameters and the slice 17
|
||||
configuration (`MLP early_stopping=True, validation_fraction=0.15,
|
||||
n_iter_no_change=10`; logistic C=1.0). FA rates reproduce the published slice
|
||||
16/17 numbers (6.9% / 8.8%). The published **PR-AUC 0.707** for the linear head
|
||||
came from the slice 16 C-grid sweep; a fixed-C reproduction lands at 0.623 (and
|
||||
even the best C on the grid, C=100, reaches only 0.675). The slice 18 table's PR
|
||||
"0.707 → 0.838 sparse gain" is therefore overstated; the honest sparse-vs-e5 PR
|
||||
gap is ~0.838 vs 0.62-0.68.
|
||||
|
||||
On the **aggregate** boundary the from-scratch sequence models neither beat the
|
||||
sparse gate's 2.2% FA (5.1-11.0%) nor reach any P ≥ 0.95 operating point (see §6).
|
||||
Aggregate accuracy was downgraded to a secondary metric from this slice.
|
||||
|
||||
## 6. Safety operating curve
|
||||
|
||||
None of the seven from-scratch configs has any threshold with P ≥ 0.95 at R > 0
|
||||
under the grouped OOF (the `ops` field is empty for every config). The sparse
|
||||
gate's slice-18 operating point (P ≥ 0.95 at R 0.264) does not transfer to any
|
||||
sequence model. A from-scratch tiny sequence encoder is not a safe standalone
|
||||
gate out of the box.
|
||||
|
||||
## 7. Leave-generator-out: capability questions (the critical split)
|
||||
|
||||
Train without the `capability_question` family, evaluate on its 126 rows (0/126
|
||||
positive — every row here must *not* trip the gate). Threshold 0.5, seed 17:
|
||||
|
||||
| config | FA | FA rate | accuracy |
|
||||
| --- | --- | --- | --- |
|
||||
| **tiny_transformer_small** | **53** | **0.421** | **0.579** |
|
||||
| bigru_tiny | 68 | 0.540 | 0.460 |
|
||||
| tiny_transformer_medium | 69 | 0.548 | 0.452 |
|
||||
| bigru_medium | 93 | 0.738 | 0.262 |
|
||||
| char_cnn_tiny | 97 | 0.770 | 0.230 |
|
||||
| bigru_large | 113 | 0.897 | 0.103 |
|
||||
| char_cnn_medium | 123 | 0.976 | 0.024 |
|
||||
|
||||
Against the sparse gate (0.667 FA → 0.333 accuracy) the leading from-scratch
|
||||
configs cut the fail rate by a quarter to a third on seed-stable seeds. Against
|
||||
e5 (1.000 / 0.976) the win is decisive —
|
||||
the frozen embedder genuinely cannot separate a held-out capability question
|
||||
from its executable sibling.
|
||||
|
||||
### But the single-seed number is not trustworthy
|
||||
|
||||
Same leave-generator-out training, three seeds (17 / 41 / 7), cap-Q FA rate:
|
||||
|
||||
```text
|
||||
tiny_transformer_small 0.603 0.421 0.937 (spread 0.52)
|
||||
tiny_transformer_medium 0.548 0.484 0.524 (spread 0.06)
|
||||
bigru_tiny 0.690 0.540 0.476 (spread 0.21)
|
||||
bigru_medium 0.738 0.389 0.952 (spread 0.56)
|
||||
```
|
||||
|
||||
Only the **transformer-medium is seed-stable**, and it lands at **0.48-0.55 FA
|
||||
(~50% ± 3 accuracy)**: a genuine but near-chance separation on the exact
|
||||
generator family the task exists for. The transformer-small's best single seed
|
||||
(0.421) and the bigru-medium pair-ordering win (§9) are both inside unstable
|
||||
regimes. The tooling that produced the lucky number is right; the number itself
|
||||
is luck.
|
||||
|
||||
## 8. Full-family LOFO, leading config per architecture
|
||||
|
||||
Seed 41, held-out family evaluated in full (accuracy; FA; action recall):
|
||||
|
||||
| held-out family | rows | char_cnn_medium | bigru_tiny | tiny_transformer_small |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| polite_request | 481 | 0.279 acc / FA 347 / R 1.000 | 0.486 / FA 247 / R 1.000 | **0.975 / FA 12 / R 1.000** |
|
||||
| modal_request | 223 | R 0.430 (P inf) | R 0.484 (P 1.000) | **R 0.561 (P 1.000)** |
|
||||
| first_person_request | 833 | **R 0.942** (P 0.991) | R 0.000 (acc 1.000, no FAs) | R 0.646 (P 1.000) |
|
||||
| reordered_target | 353 | 1.000 / FA 0 | 0.994 / FA 2 | 1.000 / FA 0 |
|
||||
| question | 123 | 1.000 / FA 0 | 1.000 / FA 0 | 1.000 / FA 0 |
|
||||
|
||||
The transformer-small is the only config that survives the **polite_request**
|
||||
holdout — the family that sounds most like a capability question under voice
|
||||
("пожалуйста" + soft predicate). The CNN collapses it into "action" (347 of 481
|
||||
false actions); the GRU nearly so. The same split in reverse: char_cnn owns
|
||||
first_person recall (short imperatives), which the other two under-recall.
|
||||
|
||||
## 9. Paired action/capability ordering
|
||||
|
||||
2268 pairs (capability question vs executable sibling on the shared object
|
||||
noun), ordering accuracy and median margin on grouped OOF, strip text:
|
||||
|
||||
| config | ordering | median margin | reversed pairs |
|
||||
| --- | --- | --- | --- |
|
||||
| char_cnn_tiny | 0.406 (reversed) | −0.090 | 1347 |
|
||||
| char_cnn_medium | 0.535 | +0.021 | 1054 |
|
||||
| bigru_tiny | 0.635 | +0.169 | 828 |
|
||||
| bigru_medium | **0.866** | +0.598 | 304 |
|
||||
| bigru_large | 0.724 | +0.282 | 627 |
|
||||
| tiny_transformer_small | 0.763 | +0.804 | 538 |
|
||||
| tiny_transformer_medium | 0.648 | +0.019 | 799 |
|
||||
|
||||
bigru_medium reaches 0.866 ordering with a healthy +0.598 median margin — the
|
||||
first head since the slices began that can more often than not put the executable
|
||||
above its sibling (sparse: 0.571, margin +0.066). Caveat: this is the same
|
||||
bigru_medium whose LOFO accuracy swings 0.39-0.95 across seeds; margin and
|
||||
ordering were measured from the grouped-CV models (seed 42+fold), not the same
|
||||
seed as the LOFO numbers, and the two axes were not measured jointly. The margin
|
||||
being three times the sparse margin on the *strip* basis is still a real,
|
||||
sequence-level result. The char CNN's 0.406-0.535 confirms it has no pragmatics
|
||||
signal at all — it is a shallow aggregate-n-gram reader.
|
||||
|
||||
## 10. Punctuation / voice stress
|
||||
|
||||
Models trained on stripped text; tokenizers trained on natural text (so
|
||||
punctuation is a real, seen-at-vocab, unseen-at-training symbol). Stress views
|
||||
`orig` (full punctuation) / `nofinal` (trailing-only) / `strip`.
|
||||
|
||||
Capability-question FA (in-distribution, 126 rows), and pair ordering per view:
|
||||
|
||||
```text
|
||||
config capQ_FA orig/nofinal/strip pairs orig/nofinal/strip
|
||||
char_cnn_medium 0.540 / 0.540 / 0.532 0.538 / 0.538 / 0.535
|
||||
bigru_tiny 0.230 / 0.270 / 0.310 0.660 / 0.604 / 0.635
|
||||
tiny_transformer_small 0.119 / 0.135 / 0.143 0.797 / 0.791 / 0.763
|
||||
tiny_transformer_medium 0.262 / 0.262 / 0.286 0.631 / 0.632 / 0.648
|
||||
```
|
||||
|
||||
Punctuation is not load-bearing for any config (all three views within a couple
|
||||
of points), and the transformer *gains* ordering accuracy when full punctuation
|
||||
is present (tf_small 0.797 orig > 0.763 strip). The models ignore the `?`/`,`,
|
||||
which is the safest possible behaviour under ASR, where they cannot be trusted.
|
||||
The "stress-robust" claim here is real (unlike the pre-fix build): the
|
||||
punctuation codepoints existed in the boxes' vocabularies and were simply not
|
||||
used.
|
||||
|
||||
## 11. Runtime and size (homesrv CPU, batch 1, p50 inference + tokenisation)
|
||||
|
||||
| config | params | fp32 | int8 (=params) | latency p50/p95 | tok | RAM delta over torch |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| char_cnn_tiny | 27,297 | 107 KiB | 27 k | 0.28 / 0.42 ms | 3.8 µs | — |
|
||||
| char_cnn_medium | 149,313 | 583 KiB | 149 k | 0.45 / 0.67 ms | 2.4 µs | ~118 MB |
|
||||
| bigru_tiny | 129,025 | 504 KiB | 129 k | 0.41 / 0.73 ms | 16.7 µs | ~48 MB |
|
||||
| bigru_medium | 356,353 | 1.4 MiB | 356 k | 0.52 / 0.82 ms | 15.3 µs | ~63 MB |
|
||||
| bigru_large | 1,105,921 | 4.2 MiB | 1.1 M | 0.85 / 1.62 ms | 19.5 µs | — |
|
||||
| tiny_transformer_small | 557,953 | 2.1 MiB | 558 k | 0.89 / 1.30 ms | 24.4 µs | ~89 MB |
|
||||
| tiny_transformer_medium | 2,021,569 | 7.7 MiB | 2.0 M | 2.57 / 3.78 ms | 17.8 µs | — |
|
||||
|
||||
Every config is sub-4 ms end to end on the production CPU; BPE tokenisation is
|
||||
the dominant term for a single utterance. RAM deltas are fresh-process
|
||||
high-water marks over a ~225 MB torch baseline (which is itself the item the
|
||||
deploy would have to absorb — ~chromosome 48 MB at the model level, crane).
|
||||
|
||||
## 12. Conclusion
|
||||
|
||||
### Verdict: sequence sensitivity is real and useful, from-scratch tiny is not enough
|
||||
|
||||
Three findings, one per decision rule:
|
||||
|
||||
1. **The critical split improved by half, on stable seeds.** The
|
||||
transformer-medium holds capability-question LOFO FA at 0.48-0.55 across
|
||||
seeds (sparse gate: 0.667; e5: 1.000/0.976). The transformer-small reaches
|
||||
0.421 on its lucky seed. Sequence structure demonstrably carries *some* of
|
||||
the pragmatics distinction that local features cannot (pair ordering 0.866
|
||||
with +0.598 margin for bigru_medium; tf_small 0.975 accuracy on the held-out
|
||||
polite_request family vs 0.279 for the CNN). The slice-18 direction —
|
||||
"order and trailing politeness/modality are the deciding signal" — is
|
||||
confirmed, not refuted.
|
||||
|
||||
2. **It is still a near-coin-flip on the exact unseen-generator case, and the
|
||||
variance is the story.** Best seed-stable accuracy is ~52%, and only one of
|
||||
seven configs is seed-stable on that metric (the others swing 0.21-0.56 of
|
||||
FA rate across three seeds). No config reaches a P ≥ 0.95 gate, and every
|
||||
config's aggregate FA rate (5.1-11.0%) is worse than the sparse gate's 2.2%.
|
||||
A from-scratch model this size can *see* a difference between the two members
|
||||
of a pair from data within the pool, but cannot *generalise it* to a
|
||||
generator family it has never seen. It memorises family-ish behaviour; when
|
||||
the family is truly new it reverts toward chance.
|
||||
|
||||
3. **The next step is a pretrained prior, not a bigger from-scratch model.**
|
||||
The distinguishing features — polite softeners, modal auxiliaries, the
|
||||
trailing interrogative falling back on world structure — are things a 4 M-8 M
|
||||
pretrained encoder (e.g. a small modern multilingual transformer) already
|
||||
encodes in its weights, so fine-tuning on the same 2490-row dev pool can be
|
||||
expected to move the seed-stable ~0.5 accuracy rather than shuffle the dice
|
||||
in a new random draw. Same frozen data, same grouped folds, same 15-section
|
||||
metrics — with the seed-stability measurement (§7) carried forward as
|
||||
mandatory, since it is what disambiguates a result from a luck draw.
|
||||
|
||||
Practical carry-outs: training input stays punctuation-stripped (free stress
|
||||
robustness, no cost); tokenizers are trained on natural text so the stress axis
|
||||
is real; never report a single-seed LOFO number without its seed neighbours.
|
||||
|
||||
## 13. Commit hash for tooling
|
||||
|
||||
`7d31de5` — `cmd/semantic-router-experiment/slice19_models.py`,
|
||||
`slice19_bpe.py`, `slice19_main.py`; artifacts under `/tmp/mvn-s19/`.
|
||||
@@ -46,6 +46,16 @@ A pair in `docs/routing.md` went stale unnoticed. Its source predated the
|
||||
| [gemma-4-E4B against gemma-4-12B on the routing fixture](2026-08-09-e4b-vs-12b-routing.md) | live |
|
||||
| [The classifier baseline after the tokenizer fix](2026-08-11-classifier-baseline-after-tokenizer-fix.md) | live |
|
||||
| [The CPT+SFT Qwen3-1.7B routes better and cannot hold a sentence](2026-08-19-maven-model-cpt-sft.md) | live |
|
||||
| [Linear-head experiment on e5-small (slice 14 baseline, 136 examples)](2026-09-07-linear-e5-router-experiment.md) | superseded |
|
||||
| [Expanded corpus linear-head experiment (3025 examples, 93 seed families)](2026-09-07-expanded-corpus-linear-head-experiment.md) | live |
|
||||
| [Slice 16 diagnostic: action/non-action boundary analysis](2026-09-07-slice16-diagnostic.md) | live |
|
||||
| [Slice 17 nonlinear e5 MLP probe](2026-09-07-nonlinear-e5-mlp-probe.md) | live |
|
||||
| [Sparse lexical action-gate probe (slice 18)](2026-09-07-sparse-lexical-action-gate.md) | live |
|
||||
| [From-scratch tiny sequence pragmatics specialist (slice 19)](2026-09-08-tiny-sequence-pragmatics-specialist.md) | live |
|
||||
| [Pretrained rubert-tiny pragmatics baseline (slice 20)](2026-09-07-pretrained-rubert-tiny-pragmatics-baseline.md) | live |
|
||||
| [Deterministic execution-frame guard (slice 21)](2026-09-07-execution-frame-guard.md) | live |
|
||||
| [Five-way residual non-action router: linear e5 vs the deployed cascade (slice 22)](2026-09-08-slice22-residual-nonaction-router.md) | superseded by 2026-09-08-slice23 |
|
||||
| [Fast-path metadata rebuilt from the real router: the slice-22 numbers, corrected (slice 23)](2026-09-08-slice23-fast-path-metadata-reconciliation.md) | live |
|
||||
|
||||
`docs/routing.md` holds the arm table these feed. Cite from there, not from here.
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Slice 6b: Capability Selection Boundary
|
||||
|
||||
*2026-09-06, from 05f79173*
|
||||
|
||||
## 1. CapabilitySelection / selector contract
|
||||
|
||||
```go
|
||||
type CapabilitySelection struct {
|
||||
Fn string
|
||||
Args []string
|
||||
Resolved bool
|
||||
Method ActionResolutionMethod
|
||||
InputKind SelectionInputKind
|
||||
Producer RouteProducer
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
type SelectionInputKind string
|
||||
|
||||
const (
|
||||
SelectionRaw SelectionInputKind = "raw"
|
||||
SelectionLLMText SelectionInputKind = "llm_text"
|
||||
SelectionDeterministic SelectionInputKind = "deterministic"
|
||||
)
|
||||
|
||||
func SelectCapability(dec Decision, m ActMatcher) CapabilitySelection
|
||||
func applyCapabilityToSlots(dec *Decision, sel CapabilitySelection)
|
||||
```
|
||||
|
||||
`SelectCapability` is the single entry point for capability selection. It sits
|
||||
between route resolution and action candidate production.
|
||||
|
||||
`applyCapabilityToSlots` propagates the selection into `Decision.Slots.Fn/Args/HasFn`
|
||||
for backward compatibility. `CapabilitySelection` is the authoritative record.
|
||||
|
||||
## 2. Where the selector lives
|
||||
|
||||
`internal/router/capability.go` — new file, 115 lines.
|
||||
|
||||
Called from `Router.Route` in `internal/router/router.go` after each cascade path:
|
||||
- Stage-0 grammar path (line ~108)
|
||||
- Stage-0b heads path (line ~148)
|
||||
- Stage-1a LLM path (line ~190)
|
||||
- Stage-1 classifier path (line ~250)
|
||||
|
||||
## 3. Old vs new ownership
|
||||
|
||||
| Concern | Before | After |
|
||||
|---|---|---|
|
||||
| Raw capability extraction | `Extractor.Extract(IntentAct)` inside `fillMatchedSlots` | Same extractor, but `CapabilitySelection` is the authoritative record |
|
||||
| LLM text capability backfill | Embedded in `fillSlots` (lines 319-324) | Moved to `SelectCapability` |
|
||||
| Fallback matcher | `ResolveActionCandidate` (lines 160-171) | Same location, unchanged |
|
||||
| Resolution method tracking | `Slots.ResolvedBy` only | `CapabilitySelection.Method` (authoritative), `Slots.ResolvedBy` (compatibility) |
|
||||
|
||||
## 4. Compatibility fields
|
||||
|
||||
`Decision.Slots.Fn/Args/HasFn/ResolvedBy` are still populated by
|
||||
`applyCapabilityToSlots` from the `CapabilitySelection` result. They exist for
|
||||
backward compatibility with code that reads slots directly (tests, rebuilt
|
||||
decisions). The ownership distinction is documented on the `Decision` struct.
|
||||
|
||||
## 5. Before/after flow
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Route → Decision (Slots.Fn set by extractor/fillSlots)
|
||||
↓
|
||||
resolveAction → ResolveActionCandidate(dec, matcher)
|
||||
→ if Slots.HasFn: candidate from slots
|
||||
→ else: fallback matcher
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Route → Decision
|
||||
↓
|
||||
fillMatchedSlots (time, key extraction)
|
||||
↓
|
||||
SelectCapability(dec, matcher) → CapabilitySelection
|
||||
↓
|
||||
applyCapabilityToSlots(dec, sel) → populates Slots.Fn/Args/HasFn for compat
|
||||
↓
|
||||
resolveAction → ResolveActionCandidate(dec, matcher)
|
||||
→ if CapabilitySelection.Resolved: candidate from selection
|
||||
→ else if Slots.HasFn: backward compat path
|
||||
→ else: fallback matcher
|
||||
```
|
||||
|
||||
## 6. Fixture matrix before/after
|
||||
|
||||
```
|
||||
Routing fixture (11 act cases):
|
||||
grammar_fixed=2, grammar_matcher=2, extractor_raw=4, extractor_llm_text=0, fallback_matcher=0
|
||||
resolved: 8, unresolved: 3
|
||||
|
||||
Ecosystem fixture (24 act cases):
|
||||
grammar_fixed=12, grammar_matcher=0, extractor_raw=11, extractor_llm_text=0, fallback_matcher=0
|
||||
resolved: 23, unresolved: 1
|
||||
```
|
||||
|
||||
Identical before and after. Zero distribution change.
|
||||
|
||||
## 7. Fallback matcher usage
|
||||
|
||||
The fallback matcher in `ResolveActionCandidate` is unchanged. It still runs
|
||||
when `CapabilitySelection.Resolved == false` and `Slots.HasFn == false`. The
|
||||
fallback matcher fires zero times in the eval fixture, consistent with previous
|
||||
measurements.
|
||||
|
||||
## 8. Tests
|
||||
|
||||
New test file: `internal/router/capability_test.go` — 14 tests.
|
||||
|
||||
| Test | What it pins |
|
||||
|---|---|
|
||||
| `TestSelectCapability_DeterministicBypass` | Grammar-fixed acts bypass selector |
|
||||
| `TestSelectCapability_ExtractorRawBypass` | Extractor-raw acts bypass selector |
|
||||
| `TestSelectCapability_GrammarMatcherBypass` | Grammar-matcher acts bypass selector |
|
||||
| `TestSelectCapability_LLMTextMatch` | LLM cleaned text resolves when raw misses |
|
||||
| `TestSelectCapability_LLMTextSameAsUtterance` | No double-match when Text == Utterance |
|
||||
| `TestSelectCapability_Unresolved` | No match on any input |
|
||||
| `TestSelectCapability_NonActIntent` | Non-act returns empty selection |
|
||||
| `TestSelectCapability_NilMatcher` | Nil matcher does not panic |
|
||||
| `TestApplyCapabilityToSlots_PopulatesCompatibilityFields` | Compat fields populated from selection |
|
||||
| `TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots` | Unresolved selection does not overwrite slots |
|
||||
| `TestResolveActionCandidate_CapabilitySelectionSource` | Selection produces route-sourced candidate |
|
||||
| `TestResolveActionCandidate_CapabilitySelectionOverSlots` | CapabilitySelection takes precedence over Slots.HasFn |
|
||||
| `TestResolveActionCandidate_BackwardCompatSlotsHasFn` | Decisions with HasFn but no CapabilitySelection still work |
|
||||
| `TestRouterRoute_CapabilitySelectionPopulated` | Router.Route sets CapabilitySelection on Decision |
|
||||
| `TestSelectionInputKindConstants` | Three input kind constants are distinct |
|
||||
|
||||
Modified tests: `actioncandidate_test.go` — 8 tests updated to set
|
||||
`CapabilitySelection` alongside `Slots.HasFn`.
|
||||
|
||||
## 9. Fn/Args result confirmation
|
||||
|
||||
Every `Fn` and `Args` value is unchanged:
|
||||
- Routing fixture: identical distribution (8 resolved, 3 unresolved)
|
||||
- Ecosystem fixture: identical distribution (23 resolved, 1 unresolved)
|
||||
- All 57 router tests pass
|
||||
- All cmd/mavend tests pass
|
||||
- All eval fixture tests pass
|
||||
|
||||
## 10. Newly exposed architectural problems
|
||||
|
||||
None. The extraction is clean and mechanical. The backward compatibility
|
||||
path in `ResolveActionCandidate` (checking `Slots.HasFn` when `CapabilitySelection`
|
||||
is not set) is a temporary bridge that should be removed when all call sites
|
||||
produce decisions through the router.
|
||||
|
||||
## 11. Commit hash
|
||||
|
||||
Pending commit on branch `task/slice-6b-capability-selection`.
|
||||
@@ -130,21 +130,37 @@ func (r ActionValidationResult) Valid() bool { return r.Status == ActionValid }
|
||||
//
|
||||
// Resolution rules:
|
||||
// - Non-act intents: candidate is not applicable (Fn empty, source empty).
|
||||
// - Act with Slots.HasFn: the router already resolved the function upstream
|
||||
// (stage-0 grammar, stage-2 extractor, or LLM slot backfill). Candidate
|
||||
// source is ActionSourceRoute.
|
||||
// - Act without Fn: the fallback matcher runs against the text slot.
|
||||
// Candidate source is ActionSourceMatcher on match, or Fn stays empty.
|
||||
// - Act with resolved CapabilitySelection: the capability-selection stage
|
||||
// already resolved the function. Candidate source is ActionSourceRoute.
|
||||
// - Act without resolved CapabilitySelection: the fallback matcher runs
|
||||
// against the text slot. Candidate source is ActionSourceMatcher on match,
|
||||
// or Fn stays empty.
|
||||
//
|
||||
// The matcher algorithm, enabled-tool set, alias behavior, fuzzy-prefix
|
||||
// behavior, and ordering are unchanged — this is a mechanical extraction of
|
||||
// the same matching call that actionAct previously owned.
|
||||
// The CapabilitySelection is the authoritative source for Fn/Args. The
|
||||
// compatibility fields on Decision.Slots (Fn/Args/HasFn) are still populated
|
||||
// for backward compatibility but are not read here.
|
||||
func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate {
|
||||
if dec.Intent != IntentAct {
|
||||
return ActionCandidate{}
|
||||
}
|
||||
|
||||
// Router resolved the function upstream.
|
||||
// Capability selection resolved the function upstream. This is the
|
||||
// authoritative path for decisions produced by the router (which calls
|
||||
// SelectCapability).
|
||||
if dec.CapabilitySelection.Resolved {
|
||||
return ActionCandidate{
|
||||
Fn: dec.CapabilitySelection.Fn,
|
||||
Args: dec.CapabilitySelection.Args,
|
||||
Source: ActionSourceRoute,
|
||||
Producer: dec.Producer,
|
||||
ResolvedBy: dec.CapabilitySelection.Method,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: decisions constructed outside the router
|
||||
// (tests, rebuilt decisions) may set Slots.HasFn without
|
||||
// CapabilitySelection. Read the compatibility fields.
|
||||
if dec.Slots.HasFn {
|
||||
return ActionCandidate{
|
||||
Fn: dec.Slots.Fn,
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveActionCandidate_RouteSource pins that an act with HasFn=true
|
||||
// produces a candidate from the route, not the matcher.
|
||||
// TestResolveActionCandidate_RouteSource pins that an act with a resolved
|
||||
// CapabilitySelection produces a candidate from the route, not the matcher.
|
||||
func TestResolveActionCandidate_RouteSource(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
||||
Method: ActionResolutionExtractorRaw,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -80,7 +84,7 @@ func TestResolveActionCandidate_NonAct(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_Stage0Match pins that a stage-0 act (which
|
||||
// sets HasFn=true) produces a route-sourced candidate.
|
||||
// has a resolved CapabilitySelection) produces a route-sourced candidate.
|
||||
func TestResolveActionCandidate_Stage0Match(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
@@ -88,6 +92,11 @@ func TestResolveActionCandidate_Stage0Match(t *testing.T) {
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
Producer: RouteProducerGrammar,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
||||
Method: ActionResolutionGrammarMatcher, InputKind: SelectionDeterministic,
|
||||
Producer: RouteProducerGrammar, Confidence: 1.0,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -364,6 +373,10 @@ func TestResolveActionCandidate_GrammarFixed(t *testing.T) {
|
||||
Fn: "resolve_item", HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarFixed,
|
||||
},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "resolve_item", Resolved: true,
|
||||
Method: ActionResolutionGrammarFixed, InputKind: SelectionDeterministic,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -386,6 +399,10 @@ func TestResolveActionCandidate_GrammarMatcher(t *testing.T) {
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
||||
Method: ActionResolutionGrammarMatcher, InputKind: SelectionDeterministic,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -406,6 +423,11 @@ func TestResolveActionCandidate_ExtractorRaw(t *testing.T) {
|
||||
ResolvedBy: ActionResolutionExtractorRaw,
|
||||
},
|
||||
Producer: RouteProducerClassifier,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Resolved: true,
|
||||
Method: ActionResolutionExtractorRaw, InputKind: SelectionDeterministic,
|
||||
Producer: RouteProducerClassifier,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -430,6 +452,11 @@ func TestResolveActionCandidate_ExtractorLLMText(t *testing.T) {
|
||||
ResolvedBy: ActionResolutionExtractorLLMText,
|
||||
},
|
||||
Producer: RouteProducerLLM,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Resolved: true,
|
||||
Method: ActionResolutionExtractorLLMText, InputKind: SelectionLLMText,
|
||||
Producer: RouteProducerLLM,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
@@ -481,7 +508,8 @@ func TestResolveActionCandidate_UnresolvedNoFalseMethod(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_PropagatesResolvedBy pins that ResolvedBy
|
||||
// travels from Slots through to ActionCandidate for every route-sourced case.
|
||||
// travels from CapabilitySelection through to ActionCandidate for every
|
||||
// route-sourced case.
|
||||
func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) {
|
||||
methods := []ActionResolutionMethod{
|
||||
ActionResolutionGrammarFixed,
|
||||
@@ -494,6 +522,9 @@ func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Fn: "restart", HasFn: true, ResolvedBy: m},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Resolved: true, Method: m,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if c.ResolvedBy != m {
|
||||
@@ -513,6 +544,10 @@ func TestResolveActionCandidate_FnArgsIdentical(t *testing.T) {
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
||||
Method: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if c.Fn != "restart" || len(c.Args) != 1 || c.Args[0] != "nginx" {
|
||||
|
||||
@@ -14,12 +14,12 @@ func ActHasEntityTarget(decision Decision) bool {
|
||||
if decision.Intent != IntentAct {
|
||||
return false
|
||||
}
|
||||
if decision.Slots.HasFn {
|
||||
if len(decision.Slots.Args) > 0 {
|
||||
return hasNamedEntityToken(decision.Slots.Args)
|
||||
if decision.CapabilitySelection.Resolved {
|
||||
if len(decision.CapabilitySelection.Args) > 0 {
|
||||
return hasNamedEntityToken(decision.CapabilitySelection.Args)
|
||||
}
|
||||
// A resident-model act may name the accepted verb and its target in
|
||||
// Text while leaving Args empty. HasFn establishes that the first token
|
||||
// Text while leaving Args empty. Resolved establishes that the first token
|
||||
// is the operation; only a meaningful tail can establish the entity.
|
||||
tokens := planTokens(decision.Slots.Text)
|
||||
return len(tokens) > 1 && hasNamedEntityToken(tokens[1:])
|
||||
|
||||
@@ -10,37 +10,42 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "matched function and argument",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true, Args: []string{"nginx"}},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "model function and target text",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "restart", HasFn: true, Text: "перезапусти гитею",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true},
|
||||
Slots: Slots{Text: "перезапусти гитею"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matched function alone",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Text: "выключи",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true},
|
||||
Slots: Slots{Text: "выключи"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matched function with politeness only",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Args: []string{"пожалуйста"}, Text: "выключи пожалуйста",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"пожалуйста"}},
|
||||
Slots: Slots{Text: "выключи пожалуйста"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matched function with anaphora only",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Args: []string{"его"}, Text: "выключи его",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"его"}},
|
||||
Slots: Slots{Text: "выключи его"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -67,3 +72,150 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_BlankedSlotsStillWorks proves that ActHasEntityTarget
|
||||
// reads CapabilitySelection.Resolved and CapabilitySelection.Args, not the
|
||||
// compatibility Slots.Fn/Args/HasFn. When Slots fields are blank but
|
||||
// CapabilitySelection is populated, behavior must remain correct.
|
||||
func TestActHasEntityTarget_BlankedSlotsStillWorks(t *testing.T) {
|
||||
// Resolved capability with entity in Args, but Slots.Fn/Args/HasFn are blank.
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
Args: []string{"nginx"},
|
||||
},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec) {
|
||||
t.Fatal("should detect entity in CapabilitySelection.Args even with blank Slots")
|
||||
}
|
||||
|
||||
// Resolved capability with entity in Text (no Args), Slots.Fn/HasFn blank.
|
||||
dec2 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "перезапусти гитею"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec2) {
|
||||
t.Fatal("should detect entity in Slots.Text via CapabilitySelection.Resolved branch")
|
||||
}
|
||||
|
||||
// Unresolved capability with Slots.Fn set (simulating stale compat state).
|
||||
// Must NOT enter the resolved branch.
|
||||
dec3 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Resolved: false,
|
||||
},
|
||||
Slots: Slots{Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec3) {
|
||||
t.Fatal("unresolved capability with stale Slots should still find entity via text fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_PraxisHexisRoutingUnchanged proves that the Praxis
|
||||
// and Hexis entity-target routing semantics are unchanged by the migration.
|
||||
func TestActHasEntityTarget_PraxisHexisRoutingUnchanged(t *testing.T) {
|
||||
// Praxis act: resolved capability with entity target.
|
||||
praxis := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "entity_attention",
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "что с muzick"},
|
||||
}
|
||||
if !ActHasEntityTarget(praxis) {
|
||||
t.Fatal("Praxis entity act should have entity target")
|
||||
}
|
||||
|
||||
// Hexis act: unresolved capability, entity in text.
|
||||
hexis := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Text: "перезапусти muzick indexer"},
|
||||
}
|
||||
if !ActHasEntityTarget(hexis) {
|
||||
t.Fatal("Hexis entity act should have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_Stage0DeterministicActUnchanged proves that a
|
||||
// stage-0 grammar-fixed act (like command prohibition) is handled correctly.
|
||||
func TestActHasEntityTarget_Stage0DeterministicActUnchanged(t *testing.T) {
|
||||
// Prohibited act: resolved capability, no entity in Args or Text.
|
||||
// "вот это" — filler particle + demonstrative, no named entity.
|
||||
prohibited := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "вот это"},
|
||||
}
|
||||
if ActHasEntityTarget(prohibited) {
|
||||
t.Fatal("prohibited act with demonstrative-only tail should not have entity target")
|
||||
}
|
||||
|
||||
// Prohibited act WITH entity text still reports the entity — the function
|
||||
// checks entity presence, not prohibition status.
|
||||
prohibitedWithEntity := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "don't restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(prohibitedWithEntity) {
|
||||
t.Fatal("prohibited act with entity text should still report entity target")
|
||||
}
|
||||
|
||||
// Task status act: resolved capability, no entity.
|
||||
taskStatus := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: TaskStatusFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "задачи"},
|
||||
}
|
||||
if ActHasEntityTarget(taskStatus) {
|
||||
t.Fatal("task status act should not have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_ClassifierExtractorActUnchanged proves that the
|
||||
// classifier/extractor act path is unchanged.
|
||||
func TestActHasEntityTarget_ClassifierExtractorActUnchanged(t *testing.T) {
|
||||
// Classifier resolved act with entity in Args.
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
Args: []string{"nginx"},
|
||||
},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec) {
|
||||
t.Fatal("classifier resolved act with entity in Args should have entity target")
|
||||
}
|
||||
|
||||
// Classifier unresolved act: entity in text.
|
||||
dec2 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Resolved: false,
|
||||
},
|
||||
Slots: Slots{Text: "перезапусти muzick"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec2) {
|
||||
t.Fatal("classifier unresolved act with entity in text should have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestAgendaQuestionsRouteToQuery(t *testing.T) {
|
||||
"какие у меня встречи завтра",
|
||||
"покажи расписание на среду",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestAgendaGrammarsLeaveTheClockAlone(t *testing.T) {
|
||||
"который час",
|
||||
"сколько сейчас времени",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
||||
"у меня кончилась вода",
|
||||
"напомни мне завтра позвонить маме",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||
"объясни как работает дизель",
|
||||
"опиши Ватерлоо",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", u, err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
||||
"во сколько созвон",
|
||||
"когда будет совещание",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
||||
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "расскажи и запиши что я пил воду"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func TestAgendaGrammarsLeaveTheWorldAlone(t *testing.T) {
|
||||
"когда была битва при ватерлоо",
|
||||
"когда изобрели телефон",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
|
||||
@@ -7,19 +7,22 @@ import (
|
||||
)
|
||||
|
||||
// TestNormalizedInputIsMinimalValueObject — the typed ingress boundary carries
|
||||
// text and source and nothing else. This test pins the shape so a future slice
|
||||
// text, match text and source. This test pins the shape so a future slice
|
||||
// cannot add fields without updating every construction site.
|
||||
func TestNormalizedInputIsMinimalValueObject(t *testing.T) {
|
||||
input := NormalizedInput{Text: "привет", Source: InputSourceVoice}
|
||||
input := NormalizedInput{Text: "привет", MatchText: "привет", Source: InputSourceVoice}
|
||||
if input.Text != "привет" {
|
||||
t.Errorf("Text = %q, want %q", input.Text, "привет")
|
||||
}
|
||||
if input.MatchText != "привет" {
|
||||
t.Errorf("MatchText = %q, want %q", input.MatchText, "привет")
|
||||
}
|
||||
if input.Source != InputSourceVoice {
|
||||
t.Errorf("Source = %q, want %q", input.Source, InputSourceVoice)
|
||||
}
|
||||
// Empty zero value is usable.
|
||||
var zero NormalizedInput
|
||||
if zero.Text != "" || zero.Source != "" {
|
||||
if zero.Text != "" || zero.MatchText != "" || zero.Source != "" {
|
||||
t.Errorf("zero value is not empty: %+v", zero)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +58,7 @@ func TestStage0SetsGrammarProducer(t *testing.T) {
|
||||
r := buildTestRouter(t)
|
||||
now := time.Now()
|
||||
// "напомни позвонить маме завтра" — a reminder grammar match.
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме завтра", now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме завтра"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func TestClassifierSetsProducer(t *testing.T) {
|
||||
r := buildTestRouterNoModel(t)
|
||||
now := time.Now()
|
||||
// "как дела" — a free-form chat utterance that no grammar matches.
|
||||
d, err := r.Route(context.Background(), "как дела", now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "как дела"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
@@ -90,7 +93,7 @@ func TestClarifyProducerIsClassifier(t *testing.T) {
|
||||
now := time.Now()
|
||||
// "привет как дела что нового" — a long ambiguous utterance that no
|
||||
// grammar matches and the classifier scores below the clarify threshold.
|
||||
d, err := r.Route(context.Background(), "привет как дела что нового", now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "привет как дела что нового"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
@@ -116,7 +119,7 @@ func TestStage0ProducerOnEveryGrammar(t *testing.T) {
|
||||
{"который час", "system-time"},
|
||||
}
|
||||
for _, u := range utterances {
|
||||
d, err := r.Route(context.Background(), u.text, now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s: Route: %v", u.name, err)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package router
|
||||
|
||||
// CapabilitySelection — the result of the explicit capability-selection stage.
|
||||
// It says what executable capability matched, separate from what kind of turn
|
||||
// this is (RouteDecision/Decision) and separate from the downstream action
|
||||
// artifact (ActionCandidate).
|
||||
//
|
||||
// CapabilitySelection is the authoritative record of which exact function was
|
||||
// selected and by which component. Decision.Slots.Fn/Args/HasFn remain as
|
||||
// compatibility representations that are populated FROM the selection; later
|
||||
// action execution must not depend on those compatibility fields.
|
||||
type CapabilitySelection struct {
|
||||
// Fn — the resolved function/tool identity. Empty when no capability
|
||||
// matched the input.
|
||||
Fn string
|
||||
|
||||
// Args — positional arguments passed to the tool. May be nil when Fn
|
||||
// is empty or when the match produced no arguments.
|
||||
Args []string
|
||||
|
||||
// Resolved — whether a capability was matched. Fn may be non-empty
|
||||
// even when Resolved is false (grammar-fixed paths set Fn without
|
||||
// going through the general selector). This field distinguishes "the
|
||||
// selector ran and matched" from "a deterministic path set Fn".
|
||||
Resolved bool
|
||||
|
||||
// Method — which component actually selected the function. Five
|
||||
// disjoint values from ActionResolutionMethod; empty when no function
|
||||
// was resolved.
|
||||
Method ActionResolutionMethod
|
||||
|
||||
// InputKind — what the selector selected against. Distinguishes the
|
||||
// raw utterance from LLM-cleaned text, so the observability trace
|
||||
// can name the exact input the matcher saw.
|
||||
InputKind SelectionInputKind
|
||||
|
||||
// Producer — which cascade stage produced the routing decision that
|
||||
// led here. Carried for observability; not used for dispatch.
|
||||
Producer RouteProducer
|
||||
|
||||
// Confidence — the routing confidence from the decision. Carried for
|
||||
// observability; not used for dispatch.
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
// SelectionInputKind — what the selector selected against. Three disjoint
|
||||
// values.
|
||||
type SelectionInputKind string
|
||||
|
||||
const (
|
||||
// SelectionRaw — the selector matched against the original raw
|
||||
// utterance from the user.
|
||||
SelectionRaw SelectionInputKind = "raw"
|
||||
|
||||
// SelectionLLMText — the selector matched against LLM-normalized or
|
||||
// cleaned action text from Slots.Text, which may differ from the raw
|
||||
// utterance.
|
||||
SelectionLLMText SelectionInputKind = "llm_text"
|
||||
|
||||
// SelectionDeterministic — the selector was bypassed because a
|
||||
// deterministic grammar already resolved the function. The selector
|
||||
// did not run; this records that the bypass happened.
|
||||
SelectionDeterministic SelectionInputKind = "deterministic"
|
||||
)
|
||||
|
||||
// applyCapabilityToSlots propagates the CapabilitySelection result into the
|
||||
// Decision.Slots compatibility fields. This preserves backward compatibility
|
||||
// for code that still reads Slots.Fn/Args/HasFn, while CapabilitySelection
|
||||
// remains the authoritative record. Later action execution must read the
|
||||
// candidate produced from CapabilitySelection, not these compatibility fields.
|
||||
func applyCapabilityToSlots(dec *Decision, sel CapabilitySelection) {
|
||||
dec.CapabilitySelection = sel
|
||||
if sel.Resolved {
|
||||
dec.Slots.Fn = sel.Fn
|
||||
dec.Slots.Args = sel.Args
|
||||
dec.Slots.HasFn = true
|
||||
dec.Slots.ResolvedBy = sel.Method
|
||||
}
|
||||
}
|
||||
|
||||
// SelectCapability is the explicit capability-selection stage. It sits between
|
||||
// route resolution and action candidate production, answering: which exact
|
||||
// executable capability matched this turn?
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. HasFn already set (grammar-fixed or extractor raw): bypass the general
|
||||
// selector. CapabilitySelection records the existing result with
|
||||
// InputKind=SelectionDeterministic.
|
||||
// 2. Raw utterance not matching the allowlist, but LLM cleaned text available:
|
||||
// try Acts.Match(LLM text). This is the extractor_llm_text path.
|
||||
// 3. No match on either input: unresolved.
|
||||
//
|
||||
// The matcher algorithm, enabled-tool set, alias behavior, fuzzy-prefix
|
||||
// behavior, and ordering are all unchanged — SelectCapability delegates to
|
||||
// the same Acts.Match call that Extract and ResolveActionCandidate always used.
|
||||
func SelectCapability(dec Decision, m ActMatcher) CapabilitySelection {
|
||||
// Non-act intents have no capability to select.
|
||||
if dec.Intent != IntentAct {
|
||||
return CapabilitySelection{
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic path: a grammar or the raw extractor already resolved
|
||||
// the function. The general selector does not re-run.
|
||||
if dec.Slots.HasFn {
|
||||
return CapabilitySelection{
|
||||
Fn: dec.Slots.Fn,
|
||||
Args: dec.Slots.Args,
|
||||
Resolved: true,
|
||||
Method: dec.Slots.ResolvedBy,
|
||||
InputKind: SelectionDeterministic,
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// General path: the raw utterance did not match. Try the LLM-cleaned
|
||||
// text when it differs from the raw utterance.
|
||||
if m != nil && dec.Slots.Text != "" && dec.Slots.Text != dec.Utterance {
|
||||
if fn, args, ok := m.Match(dec.Slots.Text); ok {
|
||||
return CapabilitySelection{
|
||||
Fn: fn,
|
||||
Args: args,
|
||||
Resolved: true,
|
||||
Method: ActionResolutionExtractorLLMText,
|
||||
InputKind: SelectionLLMText,
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unresolved: no capability matched on any input.
|
||||
return CapabilitySelection{
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- SelectCapability tests ---
|
||||
|
||||
// TestSelectCapability_DeterministicBypass pins that a grammar-fixed act
|
||||
// bypasses the general selector and records the existing result.
|
||||
func TestSelectCapability_DeterministicBypass(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "resolve_item", HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarFixed,
|
||||
},
|
||||
Producer: RouteProducerGrammar,
|
||||
Confidence: 1.0,
|
||||
}
|
||||
sel := SelectCapability(dec, nil)
|
||||
if !sel.Resolved {
|
||||
t.Fatal("expected resolved")
|
||||
}
|
||||
if sel.Fn != "resolve_item" {
|
||||
t.Errorf("Fn = %q, want resolve_item", sel.Fn)
|
||||
}
|
||||
if sel.Method != ActionResolutionGrammarFixed {
|
||||
t.Errorf("Method = %q, want grammar_fixed", sel.Method)
|
||||
}
|
||||
if sel.InputKind != SelectionDeterministic {
|
||||
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
||||
}
|
||||
if sel.Producer != RouteProducerGrammar {
|
||||
t.Errorf("Producer = %q, want grammar", sel.Producer)
|
||||
}
|
||||
if sel.Confidence != 1.0 {
|
||||
t.Errorf("Confidence = %f, want 1.0", sel.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_ExtractorRawBypass pins that an extractor-raw act
|
||||
// bypasses the general selector.
|
||||
func TestSelectCapability_ExtractorRawBypass(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionExtractorRaw,
|
||||
},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
sel := SelectCapability(dec, nil)
|
||||
if !sel.Resolved {
|
||||
t.Fatal("expected resolved")
|
||||
}
|
||||
if sel.Fn != "restart" {
|
||||
t.Errorf("Fn = %q, want restart", sel.Fn)
|
||||
}
|
||||
if sel.Method != ActionResolutionExtractorRaw {
|
||||
t.Errorf("Method = %q, want extractor_raw", sel.Method)
|
||||
}
|
||||
if sel.InputKind != SelectionDeterministic {
|
||||
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_GrammarMatcherBypass pins that a grammar-matcher act
|
||||
// (wakeword-act) bypasses the general selector.
|
||||
func TestSelectCapability_GrammarMatcherBypass(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
Producer: RouteProducerGrammar,
|
||||
}
|
||||
sel := SelectCapability(dec, nil)
|
||||
if !sel.Resolved {
|
||||
t.Fatal("expected resolved")
|
||||
}
|
||||
if sel.Method != ActionResolutionGrammarMatcher {
|
||||
t.Errorf("Method = %q, want grammar_matcher", sel.Method)
|
||||
}
|
||||
if sel.InputKind != SelectionDeterministic {
|
||||
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_LLMTextMatch pins that when the raw utterance did not
|
||||
// match but LLM cleaned text does, the selector resolves from LLM text.
|
||||
func TestSelectCapability_LLMTextMatch(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Utterance: "maven could you restart nginx",
|
||||
Slots: Slots{
|
||||
Text: "restart nginx",
|
||||
},
|
||||
Producer: RouteProducerLLM,
|
||||
}
|
||||
sel := SelectCapability(dec, m)
|
||||
if !sel.Resolved {
|
||||
t.Fatal("expected resolved from LLM text")
|
||||
}
|
||||
if sel.Fn != "restart" {
|
||||
t.Errorf("Fn = %q, want restart", sel.Fn)
|
||||
}
|
||||
if len(sel.Args) != 1 || sel.Args[0] != "nginx" {
|
||||
t.Errorf("Args = %v, want [nginx]", sel.Args)
|
||||
}
|
||||
if sel.Method != ActionResolutionExtractorLLMText {
|
||||
t.Errorf("Method = %q, want extractor_llm_text", sel.Method)
|
||||
}
|
||||
if sel.InputKind != SelectionLLMText {
|
||||
t.Errorf("InputKind = %q, want llm_text", sel.InputKind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_LLMTextSameAsUtterance pins that when Slots.Text equals
|
||||
// the utterance, the selector does NOT try LLM text (no second attempt).
|
||||
func TestSelectCapability_LLMTextSameAsUtterance(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart"}}
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
sel := SelectCapability(dec, m)
|
||||
// Text == Utterance means no LLM cleaned text; raw match should have
|
||||
// been done by the extractor. Since HasFn is false, selector sees no
|
||||
// LLM text to try.
|
||||
if sel.Resolved {
|
||||
t.Fatal("expected unresolved when Text == Utterance and no HasFn")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_Unresolved pins that when neither the grammar/extractor
|
||||
// nor the LLM text matches, the selection is unresolved.
|
||||
func TestSelectCapability_Unresolved(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Utterance: "deploy the thing",
|
||||
Slots: Slots{Text: "deploy the thing"},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
sel := SelectCapability(dec, m)
|
||||
if sel.Resolved {
|
||||
t.Fatal("expected unresolved")
|
||||
}
|
||||
if sel.Fn != "" {
|
||||
t.Errorf("Fn = %q, want empty", sel.Fn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_NonActIntent pins that a non-act intent returns an
|
||||
// empty selection.
|
||||
func TestSelectCapability_NonActIntent(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentFact,
|
||||
Slots: Slots{Key: "water", HasKey: true},
|
||||
}
|
||||
sel := SelectCapability(dec, nil)
|
||||
if sel.Resolved {
|
||||
t.Fatal("expected unresolved for non-act")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapability_NilMatcher pins that a nil matcher does not panic
|
||||
// and produces an unresolved selection when no grammar matched.
|
||||
func TestSelectCapability_NilMatcher(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
sel := SelectCapability(dec, nil)
|
||||
if sel.Resolved {
|
||||
t.Fatal("expected unresolved with nil matcher")
|
||||
}
|
||||
}
|
||||
|
||||
// --- applyCapabilityToSlots tests ---
|
||||
|
||||
// TestApplyCapabilityToSlots_PopulatesCompatibilityFields pins that the
|
||||
// compatibility fields on Decision.Slots are populated from the selection.
|
||||
func TestApplyCapabilityToSlots_PopulatesCompatibilityFields(t *testing.T) {
|
||||
dec := Decision{}
|
||||
sel := CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Args: []string{"nginx"},
|
||||
Resolved: true,
|
||||
Method: ActionResolutionExtractorRaw,
|
||||
InputKind: SelectionDeterministic,
|
||||
Producer: RouteProducerClassifier,
|
||||
Confidence: 0.85,
|
||||
}
|
||||
applyCapabilityToSlots(&dec, sel)
|
||||
|
||||
if dec.CapabilitySelection.Fn != "restart" {
|
||||
t.Errorf("CapabilitySelection.Fn = %q, want restart", dec.CapabilitySelection.Fn)
|
||||
}
|
||||
if !dec.Slots.HasFn {
|
||||
t.Error("Slots.HasFn should be true")
|
||||
}
|
||||
if dec.Slots.Fn != "restart" {
|
||||
t.Errorf("Slots.Fn = %q, want restart", dec.Slots.Fn)
|
||||
}
|
||||
if len(dec.Slots.Args) != 1 || dec.Slots.Args[0] != "nginx" {
|
||||
t.Errorf("Slots.Args = %v, want [nginx]", dec.Slots.Args)
|
||||
}
|
||||
if dec.Slots.ResolvedBy != ActionResolutionExtractorRaw {
|
||||
t.Errorf("Slots.ResolvedBy = %q, want extractor_raw", dec.Slots.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots pins that an unresolved
|
||||
// selection does not populate the compatibility fields.
|
||||
func TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots(t *testing.T) {
|
||||
dec := Decision{Slots: Slots{Fn: "old", HasFn: true}}
|
||||
sel := CapabilitySelection{
|
||||
Resolved: false,
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
applyCapabilityToSlots(&dec, sel)
|
||||
|
||||
if dec.CapabilitySelection.Resolved {
|
||||
t.Error("CapabilitySelection.Resolved should be false")
|
||||
}
|
||||
// Compatibility fields should remain unchanged.
|
||||
if dec.Slots.Fn != "old" {
|
||||
t.Errorf("Slots.Fn = %q, want old (unchanged)", dec.Slots.Fn)
|
||||
}
|
||||
if !dec.Slots.HasFn {
|
||||
t.Error("Slots.HasFn should still be true")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveActionCandidate from CapabilitySelection tests ---
|
||||
|
||||
// TestResolveActionCandidate_CapabilitySelectionSource pins that a resolved
|
||||
// CapabilitySelection produces a route-sourced candidate.
|
||||
func TestResolveActionCandidate_CapabilitySelectionSource(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
||||
Method: ActionResolutionExtractorRaw,
|
||||
},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.Fn != "restart" {
|
||||
t.Errorf("Fn = %q, want restart", c.Fn)
|
||||
}
|
||||
if c.Source != ActionSourceRoute {
|
||||
t.Errorf("Source = %q, want route", c.Source)
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionExtractorRaw {
|
||||
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_CapabilitySelectionOverSlots pins that
|
||||
// CapabilitySelection takes precedence over Slots.HasFn when both are set.
|
||||
func TestResolveActionCandidate_CapabilitySelectionOverSlots(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "old_fn", HasFn: true,
|
||||
ResolvedBy: ActionResolutionExtractorRaw,
|
||||
},
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "new_fn", Resolved: true,
|
||||
Method: ActionResolutionExtractorLLMText,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if c.Fn != "new_fn" {
|
||||
t.Errorf("Fn = %q, want new_fn (CapabilitySelection wins)", c.Fn)
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionExtractorLLMText {
|
||||
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_BackwardCompatSlotsHasFn pins that decisions
|
||||
// with Slots.HasFn but no CapabilitySelection still work (backward compat).
|
||||
func TestResolveActionCandidate_BackwardCompatSlotsHasFn(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarFixed,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate from backward compat")
|
||||
}
|
||||
if c.Fn != "restart" {
|
||||
t.Errorf("Fn = %q, want restart", c.Fn)
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionGrammarFixed {
|
||||
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Router integration: CapabilitySelection populated by Route ---
|
||||
|
||||
// TestRouterRoute_CapabilitySelectionPopulated pins that Router.Route sets
|
||||
// CapabilitySelection on the returned Decision for each cascade path.
|
||||
func TestRouterRoute_CapabilitySelectionPopulated(t *testing.T) {
|
||||
r := newTestRouter(t, 0.3)
|
||||
|
||||
// Stage-0 grammar path: wakeword-act.
|
||||
d, err := r.Route(t.Context(), NormalizedInput{Text: "maven restart nginx"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.CapabilitySelection.Resolved {
|
||||
t.Error("stage-0: CapabilitySelection not resolved")
|
||||
}
|
||||
if d.CapabilitySelection.Fn != "restart" {
|
||||
t.Errorf("stage-0: Fn = %q, want restart", d.CapabilitySelection.Fn)
|
||||
}
|
||||
if d.CapabilitySelection.InputKind != SelectionDeterministic {
|
||||
t.Errorf("stage-0: InputKind = %q, want deterministic", d.CapabilitySelection.InputKind)
|
||||
}
|
||||
|
||||
// Classifier path: raw utterance matches allowlist.
|
||||
d, err = r.Route(t.Context(), NormalizedInput{Text: "restart nginx"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Intent != IntentAct {
|
||||
t.Skipf("classifier routed to %q, not act", d.Intent)
|
||||
}
|
||||
if !d.CapabilitySelection.Resolved {
|
||||
t.Error("classifier: CapabilitySelection not resolved")
|
||||
}
|
||||
if d.CapabilitySelection.Fn != "restart" {
|
||||
t.Errorf("classifier: Fn = %q, want restart", d.CapabilitySelection.Fn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterRoute_ClassifierUnresolvedAct pins that an act intent where the
|
||||
// raw utterance does not match the allowlist has an unresolved selection.
|
||||
func TestRouterRoute_ClassifierUnresolvedAct(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
||||
emb := NewHashEmbedder(1024)
|
||||
c := NewClassifier(emb)
|
||||
seedClassifier(t, c)
|
||||
ex := Extractor{Acts: m}
|
||||
r := New(Config{
|
||||
Classifier: c,
|
||||
Extractor: ex,
|
||||
Threshold: 0.3,
|
||||
})
|
||||
|
||||
d, err := r.Route(t.Context(), NormalizedInput{Text: "deploy the thing"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Intent != IntentAct {
|
||||
t.Skipf("classifier routed to %q, not act", d.Intent)
|
||||
}
|
||||
if d.CapabilitySelection.Resolved {
|
||||
t.Error("expected unresolved selection for non-matching utterance")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SelectionInputKind constants ---
|
||||
|
||||
// TestSelectionInputKindConstants pins that the three input kind constants
|
||||
// are distinct and non-empty.
|
||||
func TestSelectionInputKindConstants(t *testing.T) {
|
||||
kinds := []SelectionInputKind{SelectionRaw, SelectionLLMText, SelectionDeterministic}
|
||||
seen := make(map[SelectionInputKind]bool)
|
||||
for _, k := range kinds {
|
||||
if k == "" {
|
||||
t.Error("SelectionInputKind constant is empty")
|
||||
}
|
||||
if seen[k] {
|
||||
t.Errorf("SelectionInputKind %q appears twice", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestClaimOfLeavesTheDecisionAlone(t *testing.T) {
|
||||
Extractor: Extractor{},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
before, err := r.Route(context.Background(), "напомни полить цветы", time.Now())
|
||||
before, err := r.Route(context.Background(), NormalizedInput{Text: "напомни полить цветы"}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func reportConfidences(t *testing.T, name string, emb router.Embedder) {
|
||||
byMargin := map[string]*bucket{}
|
||||
var cosines, margins []float64
|
||||
for _, c := range f.Cases {
|
||||
d, err := r.Route(context.Background(), c.Utterance, now)
|
||||
d, err := r.Route(context.Background(), router.NormalizedInput{Text: c.Utterance}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.ID, err)
|
||||
}
|
||||
|
||||
@@ -103,17 +103,17 @@ func (f Fixture) Now() (time.Time, error) {
|
||||
// Router — the one thing a route decider must do to be scorable. *router.Router
|
||||
// satisfies it directly; an LLM-only path wraps its Route in RouterFunc.
|
||||
type Router interface {
|
||||
Route(ctx context.Context, utterance string, now time.Time) (router.Decision, error)
|
||||
Route(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error)
|
||||
}
|
||||
|
||||
// RouterFunc adapts a bare function to Router — for scoring a single stage
|
||||
// (e.g. *router.LLMRouter, whose Route returns an extra ok bool) without
|
||||
// standing up the whole cascade.
|
||||
type RouterFunc func(ctx context.Context, utterance string, now time.Time) (router.Decision, error)
|
||||
type RouterFunc func(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error)
|
||||
|
||||
// Route implements Router.
|
||||
func (f RouterFunc) Route(ctx context.Context, utterance string, now time.Time) (router.Decision, error) {
|
||||
return f(ctx, utterance, now)
|
||||
func (f RouterFunc) Route(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error) {
|
||||
return f(ctx, input, now)
|
||||
}
|
||||
|
||||
// Outcome — one scored case. Reasons is empty exactly when Pass is true.
|
||||
@@ -231,7 +231,7 @@ func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error
|
||||
|
||||
for _, c := range f.Cases {
|
||||
start := time.Now()
|
||||
d, err := r.Route(ctx, c.Utterance, now)
|
||||
d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, now)
|
||||
o := Outcome{Case: c, Decision: d, Err: err, Latency: time.Since(start)}
|
||||
lat = append(lat, o.Latency)
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ func TestLLMRouterBaseline(t *testing.T) {
|
||||
// llm-only: the LLM stage in isolation. Route returns (Decision, ok, err);
|
||||
// !ok without an error would be a contract violation, so it is surfaced as
|
||||
// one rather than silently scored as a miss.
|
||||
llmOnly := RouterFunc(func(ctx context.Context, u string, now time.Time) (router.Decision, error) {
|
||||
d, ok, err := lr.Route(ctx, u, now)
|
||||
llmOnly := RouterFunc(func(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error) {
|
||||
d, ok, err := lr.Route(ctx, input.Text, now)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
@@ -152,8 +152,8 @@ func TestReachWithLLMRouter(t *testing.T) {
|
||||
lr := router.NewLLMRouter(client)
|
||||
m := router.DefaultActMatcher{Fns: actFns}
|
||||
|
||||
llmOnly := RouterFunc(func(ctx context.Context, u string, now time.Time) (router.Decision, error) {
|
||||
d, ok, err := lr.Route(ctx, u, now)
|
||||
llmOnly := RouterFunc(func(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error) {
|
||||
d, ok, err := lr.Route(ctx, input.Text, now)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func ScoreReach(ctx context.Context, name string, r Router, m router.ActMatcher,
|
||||
|
||||
for _, c := range f.Cases {
|
||||
start := time.Now()
|
||||
d, err := r.Route(ctx, c.Utterance, now)
|
||||
d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, now)
|
||||
o := ReachOutcome{Case: c, Decision: d, Err: err, Latency: time.Since(start)}
|
||||
lat = append(lat, o.Latency)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestResolutionMethodMatrix(t *testing.T) {
|
||||
resolved := 0
|
||||
counts := [5]int{} // fixed, matcher, raw, llm, fallback
|
||||
for _, c := range rf.Cases {
|
||||
d, err := r.Route(ctx, c.Utterance, rfNow)
|
||||
d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, rfNow)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestResolutionMethodMatrix(t *testing.T) {
|
||||
resolved := 0
|
||||
counts := [5]int{}
|
||||
for _, c := range ef.Cases {
|
||||
d, err := r2.Route(ctx, c.Utterance, efNow)
|
||||
d, err := r2.Route(ctx, router.NormalizedInput{Text: c.Utterance}, efNow)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func TestShadowMatcherComparison(t *testing.T) {
|
||||
|
||||
same, different, routeOnly, matcherOnly, missBoth, total := 0, 0, 0, 0, 0, 0
|
||||
for _, c := range f.Cases {
|
||||
d, err := r.Route(ctx, c.Utterance, now)
|
||||
d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, now)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FastPathResult is the outcome of the stage-0 deterministic fast path.
|
||||
// Matched is true when a grammar accepted the input. Decision carries the
|
||||
// full routing result for a match. When Matched is false the caller falls
|
||||
// through to the general cascade (heads → LLM → classifier → extraction → gate).
|
||||
type FastPathResult struct {
|
||||
Matched bool
|
||||
Decision Decision
|
||||
}
|
||||
|
||||
// TryFastPath runs the stage-0 exact-match grammars. It owns the ordered
|
||||
// grammar list, first-match-wins semantics, wake-token alternate handling,
|
||||
// slot filling, capability selection, and per-grammar trace recording.
|
||||
//
|
||||
// When no grammar matches, Matched is false and the caller falls through
|
||||
// to the general cascade.
|
||||
//
|
||||
// TryFastPath receives NormalizedInput so later recognizers can opt into
|
||||
// MatchText, but no existing grammar switches in this slice — they all
|
||||
// receive input.Text (the raw utterance) exactly as they do today.
|
||||
func (r *Router) TryFastPath(
|
||||
ctx context.Context,
|
||||
input NormalizedInput,
|
||||
now time.Time,
|
||||
) (FastPathResult, error) {
|
||||
// stage 0 — exact match / grammar. First match wins; grammars are ordered.
|
||||
// Grammars like time/date/reminder don't expect a wake-word prefix, but
|
||||
// the STT often includes one (transcribed phonetically, any script) — try
|
||||
// the wake-stripped utterance too so those grammars still fire.
|
||||
stripped, hadWake := StripWakeToken(input.Text)
|
||||
// declinedBuild — the grammars that matched the shape and refused the
|
||||
// content, kept for the decision record (V-564) so a reader can tell that
|
||||
// rule from one whose pattern never fired.
|
||||
var declinedBuild map[int]bool
|
||||
for i, g := range r.grammars {
|
||||
d, matched, ok := g.Evaluate(input.Text)
|
||||
if !matched && hadWake {
|
||||
d, matched, ok = g.Evaluate(stripped)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
if declinedBuild == nil {
|
||||
declinedBuild = map[int]bool{}
|
||||
}
|
||||
declinedBuild[i] = true
|
||||
continue // grammar matched shape but not content → fall through
|
||||
}
|
||||
d.Utterance = input.Text
|
||||
d.Producer = RouteProducerGrammar
|
||||
// A literal pattern named that destination, which is the one provenance
|
||||
// allowed to take the personal boundary off a turn (V-666). Set here and
|
||||
// nowhere else, so no other arm of the cascade can claim it.
|
||||
d.SourceAnchored = d.Source != SourceUnknown
|
||||
// The grammar decided the intent; the extractor fills the slots it did
|
||||
// not match (V-572). See fillMatchedSlots for why every grammar gets it.
|
||||
r.fillMatchedSlots(ctx, &d, now)
|
||||
// Capability selection: deterministic grammars that already resolved Fn
|
||||
// (wakeword-act, praxis, task-status) bypass the general selector.
|
||||
// SelectCapability records the existing result with
|
||||
// InputKind=SelectionDeterministic.
|
||||
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
|
||||
r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent)
|
||||
return FastPathResult{Matched: true, Decision: d}, nil
|
||||
}
|
||||
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
|
||||
return FastPathResult{}, nil
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTryFastPathMatchesReminderGrammar(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
now := refNow()
|
||||
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "напомни позвонить маме завтра"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true")
|
||||
}
|
||||
d := fast.Decision
|
||||
if d.Intent != IntentReminder {
|
||||
t.Errorf("Intent = %q, want %q", d.Intent, IntentReminder)
|
||||
}
|
||||
if d.Stage != 0 {
|
||||
t.Errorf("Stage = %d, want 0", d.Stage)
|
||||
}
|
||||
if d.Producer != RouteProducerGrammar {
|
||||
t.Errorf("Producer = %q, want %q", d.Producer, RouteProducerGrammar)
|
||||
}
|
||||
if d.Confidence != 1.0 {
|
||||
t.Errorf("Confidence = %f, want 1.0", d.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathMatchesWithWakeToken(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
now := refNow()
|
||||
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "Мэйвен который час"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true")
|
||||
}
|
||||
d := fast.Decision
|
||||
if d.Intent != IntentSystem {
|
||||
t.Errorf("Intent = %q, want %q", d.Intent, IntentSystem)
|
||||
}
|
||||
if d.Stage != 0 {
|
||||
t.Errorf("Stage = %d, want 0", d.Stage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathMissFallsThrough(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
now := refNow()
|
||||
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "как дела"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if fast.Matched {
|
||||
t.Fatal("expected Matched=false for unmatched utterance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathGrammarOrderPreserved(t *testing.T) {
|
||||
acts := DefaultActMatcher{Fns: []string{"restart"}}
|
||||
grammars := StageZeroGrammars(acts)
|
||||
if len(grammars) == 0 {
|
||||
t.Fatal("StageZeroGrammars returned empty list")
|
||||
}
|
||||
|
||||
r := New(Config{
|
||||
Grammars: grammars,
|
||||
Classifier: nil,
|
||||
Extractor: Extractor{Time: StubDateTimeParser{}, Acts: acts},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
|
||||
// Verify TryFastPath uses the same ordered list by checking grammar names.
|
||||
// We can't read r.grammars directly from outside the package, but we can
|
||||
// verify the count matches.
|
||||
if len(r.grammars) != len(grammars) {
|
||||
t.Errorf("Router.grammars length = %d, want %d", len(r.grammars), len(grammars))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathCapabilitySelection(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
now := refNow()
|
||||
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true")
|
||||
}
|
||||
d := fast.Decision
|
||||
if d.Intent != IntentAct {
|
||||
t.Errorf("Intent = %q, want %q", d.Intent, IntentAct)
|
||||
}
|
||||
if !d.CapabilitySelection.Resolved {
|
||||
t.Error("CapabilitySelection.Resolved = false, want true")
|
||||
}
|
||||
if d.CapabilitySelection.Fn != "restart" {
|
||||
t.Errorf("CapabilitySelection.Fn = %q, want %q", d.CapabilitySelection.Fn, "restart")
|
||||
}
|
||||
if d.CapabilitySelection.Method != ActionResolutionGrammarMatcher {
|
||||
t.Errorf("CapabilitySelection.Method = %q, want %q", d.CapabilitySelection.Method, ActionResolutionGrammarMatcher)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathDoesNotReadMatchText(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
now := refNow()
|
||||
|
||||
// Pass an input where MatchText differs from Text. If any grammar
|
||||
// consumed MatchText, the result would differ from using Text alone.
|
||||
input := NormalizedInput{
|
||||
Text: "который час",
|
||||
MatchText: "totally different text that should not be used",
|
||||
}
|
||||
fast, err := r.TryFastPath(context.Background(), input, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true for time query")
|
||||
}
|
||||
if fast.Decision.Intent != IntentSystem {
|
||||
t.Errorf("Intent = %q, want %q (grammar should use Text, not MatchText)", fast.Decision.Intent, IntentSystem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteIdenticalBeforeAfter(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
now := refNow()
|
||||
|
||||
utterances := []struct {
|
||||
text string
|
||||
intent Intent
|
||||
stage int
|
||||
}{
|
||||
{"напомни позвонить маме завтра", IntentReminder, 0},
|
||||
{"который час", IntentSystem, 0},
|
||||
{"как дела", IntentChat, 2}, // falls through to classifier (stage 2)
|
||||
}
|
||||
|
||||
for _, u := range utterances {
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s: Route: %v", u.text, err)
|
||||
continue
|
||||
}
|
||||
if d.Intent != u.intent {
|
||||
t.Errorf("%s: Intent = %q, want %q", u.text, d.Intent, u.intent)
|
||||
}
|
||||
if d.Stage != u.stage {
|
||||
t.Errorf("%s: Stage = %d, want %d", u.text, d.Stage, u.stage)
|
||||
}
|
||||
if u.stage == 0 && d.Producer != RouteProducerGrammar {
|
||||
t.Errorf("%s: Producer = %q, want %q", u.text, d.Producer, RouteProducerGrammar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathSourceAnchored(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||
now := refNow()
|
||||
|
||||
// "что в календаре на завтра" — calendar-query names SourceCalendar.
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "что в календаре на завтра"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true")
|
||||
}
|
||||
d := fast.Decision
|
||||
if d.Source != SourceCalendar {
|
||||
t.Errorf("Source = %q, want %q", d.Source, SourceCalendar)
|
||||
}
|
||||
if !d.SourceAnchored {
|
||||
t.Error("SourceAnchored = false, want true (grammar named the destination)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryFastPathFillMatchedSlots(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
now := refNow()
|
||||
|
||||
// "напомни в 11:00 позвонить маме" — grammar captures text, extractor fills time.
|
||||
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "напомни в 11:00 позвонить маме"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath: %v", err)
|
||||
}
|
||||
if !fast.Matched {
|
||||
t.Fatal("expected Matched=true")
|
||||
}
|
||||
d := fast.Decision
|
||||
if !d.Slots.HasTime {
|
||||
t.Error("HasTime = false, want true (fillMatchedSlots should fill time)")
|
||||
}
|
||||
if got, want := d.Slots.Time.Format("15:04"), "11:00"; got != want {
|
||||
t.Errorf("Time = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizedInputReachesRouteIntact pins that the NormalizedInput
|
||||
// constructed at ingress arrives at Router.Route without reconstruction.
|
||||
func TestNormalizedInputReachesRouteIntact(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
now := refNow()
|
||||
|
||||
input := NormalizedInput{
|
||||
Text: "который час",
|
||||
MatchText: "который час",
|
||||
Source: InputSourceText,
|
||||
}
|
||||
d, err := r.Route(context.Background(), input, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
if d.Intent != IntentSystem {
|
||||
t.Errorf("Intent = %q, want %q", d.Intent, IntentSystem)
|
||||
}
|
||||
if d.Utterance != input.Text {
|
||||
t.Errorf("Utterance = %q, want %q (Decision.Utterance must equal input.Text)", d.Utterance, input.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryFastPathReceivesMatchText pins that TryFastPath receives the
|
||||
// same NormalizedInput that Route was given (including MatchText).
|
||||
// Today no grammar reads MatchText, so the result must be identical
|
||||
// whether MatchText is set or empty — this freezes the dark-data contract.
|
||||
func TestTryFastPathReceivesMatchText(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
now := refNow()
|
||||
|
||||
without := NormalizedInput{Text: "который час"}
|
||||
with := NormalizedInput{Text: "который час", MatchText: "который час"}
|
||||
|
||||
fastWithout, err := r.TryFastPath(context.Background(), without, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath (without): %v", err)
|
||||
}
|
||||
fastWith, err := r.TryFastPath(context.Background(), with, now)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFastPath (with): %v", err)
|
||||
}
|
||||
if fastWithout.Matched != fastWith.Matched {
|
||||
t.Errorf("Matched: without=%v, with=%v", fastWithout.Matched, fastWith.Matched)
|
||||
}
|
||||
if fastWithout.Matched && fastWith.Matched {
|
||||
if fastWithout.Decision.Intent != fastWith.Decision.Intent {
|
||||
t.Errorf("Intent: without=%q, with=%q", fastWithout.Decision.Intent, fastWith.Decision.Intent)
|
||||
}
|
||||
if fastWithout.Decision.Confidence != fastWith.Decision.Confidence {
|
||||
t.Errorf("Confidence: without=%f, with=%f", fastWithout.Decision.Confidence, fastWith.Decision.Confidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchTextDoesNotChangeRouting pins the dark-data invariant:
|
||||
// same Text, different MatchText → same Decision. This must hold until
|
||||
// an explicit later slice opts a consumer into MatchText.
|
||||
func TestMatchTextDoesNotChangeRouting(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
now := refNow()
|
||||
|
||||
utterances := []struct {
|
||||
text string
|
||||
want Intent
|
||||
}{
|
||||
{"который час", IntentSystem},
|
||||
{"напомни позвонить маме завтра", IntentReminder},
|
||||
}
|
||||
|
||||
for _, u := range utterances {
|
||||
without := NormalizedInput{Text: u.text}
|
||||
with := NormalizedInput{Text: u.text, MatchText: NormalizeMatchText(u.text)}
|
||||
|
||||
dWithout, err := r.Route(context.Background(), without, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s (without MatchText): Route: %v", u.text, err)
|
||||
continue
|
||||
}
|
||||
dWith, err := r.Route(context.Background(), with, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s (with MatchText): Route: %v", u.text, err)
|
||||
continue
|
||||
}
|
||||
if dWithout.Intent != dWith.Intent {
|
||||
t.Errorf("%s: Intent changed: without=%q, with=%q", u.text, dWithout.Intent, dWith.Intent)
|
||||
}
|
||||
if dWithout.Confidence != dWith.Confidence {
|
||||
t.Errorf("%s: Confidence changed: without=%f, with=%f", u.text, dWithout.Confidence, dWith.Confidence)
|
||||
}
|
||||
if dWithout.Stage != dWith.Stage {
|
||||
t.Errorf("%s: Stage changed: without=%d, with=%d", u.text, dWithout.Stage, dWith.Stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecisionUtteranceEqualsInputText pins that Decision.Utterance is
|
||||
// always input.Text, regardless of which cascade path was taken.
|
||||
func TestDecisionUtteranceEqualsInputText(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
now := refNow()
|
||||
|
||||
utterances := []struct {
|
||||
text string
|
||||
want Intent
|
||||
}{
|
||||
{"который час", IntentSystem},
|
||||
{"напомни позвонить маме завтра", IntentReminder},
|
||||
{"как дела", IntentChat},
|
||||
}
|
||||
|
||||
for _, u := range utterances {
|
||||
input := NormalizedInput{Text: u.text}
|
||||
d, err := r.Route(context.Background(), input, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s: Route: %v", u.text, err)
|
||||
continue
|
||||
}
|
||||
if d.Intent != u.want {
|
||||
t.Errorf("%s: Intent = %q, want %q", u.text, d.Intent, u.want)
|
||||
continue
|
||||
}
|
||||
if d.Utterance != u.text {
|
||||
t.Errorf("%s: Decision.Utterance = %q, want %q", u.text, d.Utterance, u.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestFeedQuestionsRouteToQuery(t *testing.T) {
|
||||
"расскажи что в новостных лентах",
|
||||
"покажи ленту",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) {
|
||||
"что нового?",
|
||||
"у меня новая лента в инстаграме",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,16 @@ type Decision struct {
|
||||
Slots Slots
|
||||
Clarify bool // stage 3: below threshold — ask, don't guess
|
||||
|
||||
// CapabilitySelection — the authoritative result of the capability-
|
||||
// selection stage. Says what executable capability matched, separate
|
||||
// from what kind of turn this is (Decision.Intent) and separate from
|
||||
// the downstream action artifact (ActionCandidate).
|
||||
//
|
||||
// Decision.Slots.Fn/Args/HasFn remain as compatibility representations
|
||||
// populated FROM this selection. Later action execution must read the
|
||||
// candidate produced from this selection, not the compatibility fields.
|
||||
CapabilitySelection CapabilitySelection
|
||||
|
||||
// Producer — which cascade stage produced this decision. Recorded for
|
||||
// observability so a trace can name the winning component directly.
|
||||
Producer RouteProducer
|
||||
|
||||
@@ -168,7 +168,7 @@ func TestRouterFallsBackWhenLLMRefuses(t *testing.T) {
|
||||
Threshold: 0.4,
|
||||
LLM: NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}),
|
||||
})
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func newLLMTestRouter(t *testing.T, out string) *Router {
|
||||
// reminder was dropped as "no time".
|
||||
func TestLLMDecisionGetsReminderTime(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме через 2 часа", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме через 2 часа"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestLLMDecisionGetsReminderTime(t *testing.T) {
|
||||
// daemon says it could not read the time.
|
||||
func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) {
|
||||
// An act decision arrived with no Fn, so the tool never ran.
|
||||
func TestLLMDecisionGetsActFn(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`)
|
||||
d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "слушай, restart nginx пожалуйста"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestLLMDecisionGetsActFn(t *testing.T) {
|
||||
// The model's own slots win; extraction only fills gaps.
|
||||
func TestLLMSlotsWinOverExtraction(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","key":"hydration","value":"выпил"}`)
|
||||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestLLMSlotsWinOverExtraction(t *testing.T) {
|
||||
// A fact the model left keyless still gets one from the parser.
|
||||
func TestLLMFactGetsKeyFromParser(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`)
|
||||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func TestLLMFactGetsKeyFromParser(t *testing.T) {
|
||||
// fact/query coin flip. The gate must ask rather than guess confidently.
|
||||
func TestLLMRouterSingleTokenTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"query","text":"вода"}`)
|
||||
d, err := r.Route(context.Background(), "вода", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "вода"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -292,7 +292,7 @@ func TestLLMRouterSingleTokenTripsClarify(t *testing.T) {
|
||||
// whole point is not trading the confident cases away for clarify coverage.
|
||||
func TestLLMRouterMultiWordStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestLLMRouterMultiWordStaysConfident(t *testing.T) {
|
||||
// "бэкап" as a verb — that must not fire a tool blind.
|
||||
func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"act","verb":"бэкап"}`)
|
||||
d, err := r.Route(context.Background(), "бэкап сделай пожалуйста расписание", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "бэкап сделай пожалуйста расписание"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) {
|
||||
// check firing on the happy path.
|
||||
func TestLLMRouterActWithFnStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`)
|
||||
d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "слушай, restart nginx пожалуйста"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestLLMRouterActWithFnStaysConfident(t *testing.T) {
|
||||
// must clarify instead of silently writing under an empty/guessed key.
|
||||
func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","value":"что-то"}`)
|
||||
d, err := r.Route(context.Background(), "у меня какая-то фигня случилась вот прямо только что", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "у меня какая-то фигня случилась вот прямо только что"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) {
|
||||
// through the full Router.Route path, not just the raw LLMRouter.
|
||||
func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`)
|
||||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -374,7 +374,7 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
||||
// (Vikunja #383).
|
||||
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
|
||||
d, err := r.Route(context.Background(), "напомни в 11", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -390,7 +390,7 @@ func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
|
||||
// both halves still runs without a question.
|
||||
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11 позвонить маме"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func TestRouteGrammarCoversSources(t *testing.T) {
|
||||
// would take real query sources off the turn for a name nothing answers.
|
||||
func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"query","text":"что там с бэкапами","source":"praxis"}`)
|
||||
d, err := r.Route(context.Background(), "что там с бэкапами", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "что там с бэкапами"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) {
|
||||
// And a known one survives, or the read-back is just a filter.
|
||||
func TestLLMNamedSourceSurvives(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}`)
|
||||
d, err := r.Route(context.Background(), "кто такой Линус Торвальдс?", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "кто такой Линус Торвальдс?"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -457,7 +457,7 @@ func TestLLMNamedSourceSurvives(t *testing.T) {
|
||||
// checks.
|
||||
func TestLLMSourceIsQueryOnly(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"note","text":"кофе кончился","source":"recall"}`)
|
||||
d, err := r.Route(context.Background(), "запиши что кофе кончился", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "запиши что кофе кончился"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// NormalizeMatchText derives a lossy lexical matching view from raw ingress text.
|
||||
// The result is intended ONLY for consumers that explicitly opt into
|
||||
// case-insensitive, whitespace-collapsed matching. It must not be used for
|
||||
// user-visible text, entity identity, quoted content, free-form arguments,
|
||||
// or persisted utterances without proving the transformation is safe for
|
||||
// that consumer.
|
||||
//
|
||||
// Pipeline: TrimSpace → Unicode NFKC → lowercase → collapse Unicode whitespace.
|
||||
// Does NOT fold ё→е, strip punctuation, strip wake words, rewrite numbers,
|
||||
// or invoke morphology.
|
||||
func NormalizeMatchText(in string) string {
|
||||
out := strings.TrimSpace(in)
|
||||
out = norm.NFKC.String(out)
|
||||
out = strings.ToLower(out)
|
||||
// Collapse runs of Unicode whitespace (including non-breaking space,
|
||||
// thin space, ideographic space, etc.) to a single ASCII space.
|
||||
out = strings.Join(strings.FieldsFunc(out, func(r rune) bool {
|
||||
return unicode.IsSpace(r)
|
||||
}), " ")
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// --- NormalizeMatchText tests ---
|
||||
|
||||
func TestNormalizeMatchTextWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{" привет ", "привет"},
|
||||
{"привет\t\tмир", "привет мир"},
|
||||
{"привет\nмир", "привет мир"},
|
||||
{"привет\r\nмир", "привет мир"},
|
||||
{"привет \t мир", "привет мир"},
|
||||
{" ", ""},
|
||||
{"", ""},
|
||||
{"привет", "привет"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ПрИвЕт MAVEN", "привет maven"},
|
||||
{"ПРИВЕТ", "привет"},
|
||||
{"hello", "hello"},
|
||||
{"HELLO", "hello"},
|
||||
{"Мэйвен", "мэйвен"},
|
||||
{"MAVEN", "maven"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextNFKC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// Fullwidth Latin letters → ASCII
|
||||
{"\uff28\uff45\uff4c\uff4c\uff4f", "hello"},
|
||||
// Superscript digits → ASCII
|
||||
{"2\u00b9\u00b2\u00b3", "2123"},
|
||||
// Compatible ligature
|
||||
{"\ufb01", "fi"},
|
||||
// Cyrillic compact forms
|
||||
{"\u0439", "\u0439"}, // already NFC, stays as-is
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextPunctuationPreserved(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"это вопрос?", "это вопрос?"},
|
||||
{"привет!", "привет!"},
|
||||
{"да.", "да."},
|
||||
{"напомни: через час", "напомни: через час"},
|
||||
{"maven, restart nginx", "maven, restart nginx"},
|
||||
{"\"quoted text\"", "\"quoted text\""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextMixedScript(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"restart сервер", "restart сервер"},
|
||||
{"nginx на сервере", "nginx на сервере"},
|
||||
{"MAC-адрес", "mac-адрес"},
|
||||
{"token123", "token123"},
|
||||
{"path/to/file", "path/to/file"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextYoPreserved(t *testing.T) {
|
||||
// ё is NOT folded by NormalizeMatchText — it passes through as a letter.
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"всё", "всё"},
|
||||
{"ВсЁ", "всё"},
|
||||
{"ещё", "ещё"},
|
||||
{"Ещё", "ещё"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeMatchText(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeMatchText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- FoldYo tests ---
|
||||
|
||||
// foldYo is a test-only helper mapping ё→е and Ё→Е. The fold is lossy:
|
||||
// "всё" and "все" are distinct Russian words, and folding destroys that
|
||||
// distinction. Not exposed in production code.
|
||||
func foldYo(in string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(in))
|
||||
for _, r := range in {
|
||||
switch r {
|
||||
case 'ё':
|
||||
b.WriteRune('е')
|
||||
case 'Ё':
|
||||
b.WriteRune('Е')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestFoldYoBasic(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"всё", "все"},
|
||||
{"ВсЁ", "ВсЕ"}, // ё→е, Ё→Е (uppercase preserves case)
|
||||
{"ещё", "еще"},
|
||||
{"Ещё", "Еще"},
|
||||
{"ёж", "еж"},
|
||||
{"Ёж", "Еж"},
|
||||
{"привет", "привет"}, // no ё
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := foldYo(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("foldYo(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldYoIsLossy(t *testing.T) {
|
||||
// "всё" (everything) and "все" (everyone/all) are distinct words.
|
||||
// Folding makes them identical — this test pins that the loss is real.
|
||||
a := foldYo("всё")
|
||||
b := "все"
|
||||
if a != b {
|
||||
t.Errorf("foldYo(\"всё\") = %q, want %q (fold should produce identical output)", a, b)
|
||||
}
|
||||
// The original pair must be distinct.
|
||||
if "всё" == "все" {
|
||||
t.Error("всё and все should be distinct strings before folding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldYoIdempotent(t *testing.T) {
|
||||
inputs := []string{"всё", "Ещё", "Ёж", "привет", ""}
|
||||
for _, in := range inputs {
|
||||
first := foldYo(in)
|
||||
second := foldYo(first)
|
||||
if first != second {
|
||||
t.Errorf("foldYo not idempotent: foldYo(%q) = %q, foldYo(that) = %q", in, first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Property / invariant tests ---
|
||||
|
||||
func TestNormalizeMatchTextIdempotent(t *testing.T) {
|
||||
inputs := []string{
|
||||
" привет ",
|
||||
"ПрИвЕт MAVEN",
|
||||
"это вопрос?",
|
||||
"всё",
|
||||
"restart nginx",
|
||||
"",
|
||||
"\t\n",
|
||||
"\uff28\uff45\uff4c\uff4c\uff4f", // NFKC fullwidth
|
||||
}
|
||||
for _, in := range inputs {
|
||||
first := NormalizeMatchText(in)
|
||||
second := NormalizeMatchText(first)
|
||||
if first != second {
|
||||
t.Errorf("NormalizeMatchText not idempotent on %q: first=%q, second=%q", in, first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextNeverRemovesPunctuation(t *testing.T) {
|
||||
inputs := []string{
|
||||
"это вопрос?",
|
||||
"привет!",
|
||||
"да.",
|
||||
"напомни: через час",
|
||||
"maven, restart nginx",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
got := NormalizeMatchText(in)
|
||||
// Every ASCII punctuation char in the input must appear in the output.
|
||||
for _, r := range in {
|
||||
if r > 0x20 && r < 0x7f && !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) {
|
||||
if !strings.ContainsRune(got, r) {
|
||||
t.Errorf("NormalizeMatchText(%q) lost punctuation %c → %q", in, r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextNeverRemovesWakeWord(t *testing.T) {
|
||||
inputs := []string{
|
||||
"maven restart nginx",
|
||||
"Мэйвен который час",
|
||||
"мавен напомни",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
got := NormalizeMatchText(in)
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Errorf("NormalizeMatchText(%q) produced empty output", in)
|
||||
}
|
||||
// The wake word should still be present as a substring (lowercased).
|
||||
lowered := strings.ToLower(in)
|
||||
for _, wake := range []string{"maven", "мэйвен", "мейвен", "майвен", "мавен", "мавена", "мавену", "мавеном"} {
|
||||
if strings.Contains(lowered, wake) && !strings.Contains(got, wake) {
|
||||
t.Errorf("NormalizeMatchText(%q) lost wake word %q → %q", in, wake, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextNeverRewritesNumbers(t *testing.T) {
|
||||
inputs := []string{
|
||||
"семь вечера",
|
||||
"три часа",
|
||||
"через два часа",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
got := NormalizeMatchText(in)
|
||||
// Number words must not become digits.
|
||||
if strings.ContainsAny(got, "0123456789") {
|
||||
t.Errorf("NormalizeMatchText(%q) introduced digits → %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextNeverMutatesOriginal(t *testing.T) {
|
||||
inputs := []string{" привет ", "ПрИвЕт", "это вопрос?"}
|
||||
for _, in := range inputs {
|
||||
orig := in
|
||||
_ = NormalizeMatchText(in)
|
||||
if in != orig {
|
||||
t.Errorf("NormalizeMatchText mutated input: was %q, now %q", orig, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchTextIsDeterministic(t *testing.T) {
|
||||
input := " ПрИвЕт MAVEN "
|
||||
a := NormalizeMatchText(input)
|
||||
b := NormalizeMatchText(input)
|
||||
if a != b {
|
||||
t.Errorf("NormalizeMatchText not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTextDarkDataInvariant(t *testing.T) {
|
||||
// MatchText is currently not authoritative for any routing/action result.
|
||||
// Pin this by verifying that constructing a NormalizedInput with MatchText
|
||||
// does not change the Text field.
|
||||
input := NormalizedInput{
|
||||
Text: "Привет",
|
||||
MatchText: NormalizeMatchText("Привет"),
|
||||
Source: InputSourceText,
|
||||
}
|
||||
if input.Text != "Привет" {
|
||||
t.Errorf("Text was mutated to %q", input.Text)
|
||||
}
|
||||
if input.MatchText != "привет" {
|
||||
t.Errorf("MatchText = %q, want %q", input.MatchText, "привет")
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) {
|
||||
"что дальше",
|
||||
"what's next?",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
|
||||
"расскажи шутку",
|
||||
"расскажи",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
|
||||
// search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо".
|
||||
func TestNarrativeGrammarKeepsTheTopic(t *testing.T) {
|
||||
r := narrativeRouter(t)
|
||||
d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "расскажи про битву при Ватерлоо"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestPossessionStatementBeatsStatisticalQueryGuess(t *testing.T) {
|
||||
Classifier: classifier,
|
||||
Threshold: 0,
|
||||
})
|
||||
d, err := r.Route(context.Background(), "у меня новый ноутбук", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "у меня новый ноутбук"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+36
-51
@@ -71,44 +71,17 @@ func New(cfg Config) *Router {
|
||||
// is flagged Clarify (the daemon asks rather than guesses — same shape as
|
||||
// since(key)==null → don't fire: a misrouted fact is a confident wrong write,
|
||||
// worse than a gap).
|
||||
func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (Decision, error) {
|
||||
// stage 0 — exact match / grammar. First match wins; grammars are ordered.
|
||||
// Grammars like time/date/reminder don't expect a wake-word prefix, but
|
||||
// the STT often includes one (transcribed phonetically, any script) — try
|
||||
// the wake-stripped utterance too so those grammars still fire.
|
||||
stripped, hadWake := StripWakeToken(utterance)
|
||||
// declinedBuild — the grammars that matched the shape and refused the
|
||||
// content, kept for the decision record (V-564) so a reader can tell that
|
||||
// rule from one whose pattern never fired.
|
||||
var declinedBuild map[int]bool
|
||||
for i, g := range r.grammars {
|
||||
d, matched, ok := g.Evaluate(utterance)
|
||||
if !matched && hadWake {
|
||||
d, matched, ok = g.Evaluate(stripped)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
if declinedBuild == nil {
|
||||
declinedBuild = map[int]bool{}
|
||||
}
|
||||
declinedBuild[i] = true
|
||||
continue // grammar matched shape but not content → fall through
|
||||
}
|
||||
d.Utterance = utterance
|
||||
d.Producer = RouteProducerGrammar
|
||||
// A literal pattern named that destination, which is the one provenance
|
||||
// allowed to take the personal boundary off a turn (V-666). Set here and
|
||||
// nowhere else, so no other arm of the cascade can claim it.
|
||||
d.SourceAnchored = d.Source != SourceUnknown
|
||||
// The grammar decided the intent; the extractor fills the slots it did
|
||||
// not match (V-572). See fillMatchedSlots for why every grammar gets it.
|
||||
r.fillMatchedSlots(ctx, &d, now)
|
||||
r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent)
|
||||
return d, nil
|
||||
func (r *Router) Route(ctx context.Context, input NormalizedInput, now time.Time) (Decision, error) {
|
||||
utterance := input.Text
|
||||
|
||||
// stage 0 — deterministic fast path. First match wins; grammars are ordered.
|
||||
fast, err := r.TryFastPath(ctx, input, now)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if fast.Matched {
|
||||
return fast.Decision, nil
|
||||
}
|
||||
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
|
||||
|
||||
// stage 0b — routing heads (when wired). A softmax over the label set, so
|
||||
// it cannot name an intent or a destination that does not exist, and its
|
||||
@@ -142,6 +115,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
Producer: RouteProducerHeads,
|
||||
}
|
||||
r.fillSlots(ctx, &d, now)
|
||||
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
|
||||
// The clarify head relearned the English assumption that one word
|
||||
// cannot be a sentence. Russian verbs carry subject and tense, and a
|
||||
// deterministic fact parser which also found a key gives both halves
|
||||
@@ -184,6 +158,13 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
d.Utterance = utterance
|
||||
d.Producer = RouteProducerLLM
|
||||
r.fillSlots(ctx, &d, now)
|
||||
// Capability selection: after fillSlots populates Text, SelectCapability
|
||||
// tries the LLM-cleaned text against the allowlist when the raw
|
||||
// utterance did not match. This replaces the former LLM text backfill
|
||||
// that was embedded in fillSlots. Must run before gateLLMDecision so
|
||||
// the gate sees the correct resolution state for acts.
|
||||
sel := SelectCapability(d, r.extractor.Acts)
|
||||
applyCapabilityToSlots(&d, sel)
|
||||
before := d.Confidence
|
||||
r.gateLLMDecision(&d)
|
||||
// The classifier is the floor and it never ran, which is the whole
|
||||
@@ -244,6 +225,12 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
Slots: r.extractor.Extract(ctx, best.Intent, utterance, now),
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
// Capability selection: for the classifier path, the raw extractor
|
||||
// already filled Slots.Fn/Args via Extract(IntentAct). SelectCapability
|
||||
// records this as a deterministic bypass. If the raw match missed,
|
||||
// there is no LLM text to fall back to (Text == Utterance), so the
|
||||
// selection remains unresolved.
|
||||
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
|
||||
|
||||
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
|
||||
if d.Confidence < r.threshold {
|
||||
@@ -302,27 +289,22 @@ func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Tim
|
||||
if !d.Slots.HasKey && ex.HasKey {
|
||||
d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey
|
||||
}
|
||||
// Capability selection (Fn/Args/HasFn) is now handled by
|
||||
// SelectCapability, not here. The extractor still fills them via
|
||||
// Extract(IntentAct), but the authoritative record lives in
|
||||
// Decision.CapabilitySelection. See capability.go.
|
||||
if !d.Slots.HasFn && ex.HasFn {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn, d.Slots.ResolvedBy = ex.Fn, ex.Args, ex.HasFn, ex.ResolvedBy
|
||||
}
|
||||
return ex
|
||||
}
|
||||
|
||||
// fillSlots — fillMatchedSlots for an LLM decision, plus the two backfills that
|
||||
// only make sense there. The LLM wins where it answered: it saw the sentence,
|
||||
// the parsers are keyword tables.
|
||||
// fillSlots — fillMatchedSlots for an LLM decision, plus the text backfill.
|
||||
// Capability selection (Fn/Args) is now handled by SelectCapability, called
|
||||
// after fillSlots in the routing pipeline. This function fills only time, key,
|
||||
// and text slots.
|
||||
func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
ex := r.fillMatchedSlots(ctx, d, now)
|
||||
// For an act the model returns the verb in Text ("restart nginx"), which is
|
||||
// often cleaner than the raw utterance ("maven, could you restart nginx").
|
||||
// Try it too when the utterance did not match the allowlist.
|
||||
if d.Intent == IntentAct && !d.Slots.HasFn && r.extractor.Acts != nil &&
|
||||
d.Slots.Text != "" && d.Slots.Text != d.Utterance {
|
||||
if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
||||
d.Slots.ResolvedBy = ActionResolutionExtractorLLMText
|
||||
}
|
||||
}
|
||||
// The extractor's Text is the raw utterance, which is the payload for a
|
||||
// note, a query or a chat turn but not for a reminder — there Text is the
|
||||
// subject, what she says at the hour. Backfilling it made Text impossible
|
||||
@@ -354,11 +336,14 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
// Anything below threshold gets the exact same Clarify=true treatment the
|
||||
// classifier path already produces — same field, same daemon-side consumer
|
||||
// (cmd/mavend/clarify.go), nothing new to wire.
|
||||
//
|
||||
// The act check now reads CapabilitySelection.Resolved (set by
|
||||
// SelectCapability before this function runs) rather than Slots.HasFn.
|
||||
func (r *Router) gateLLMDecision(d *Decision) {
|
||||
if d.Intent == IntentFact && !d.Slots.HasKey && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
|
||||
if d.Intent == IntentAct && !d.CapabilitySelection.Resolved && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
// A reminder with no subject: she knows when but not what to say then.
|
||||
|
||||
@@ -78,7 +78,7 @@ func newTestRouter(t *testing.T, threshold float64) *Router {
|
||||
|
||||
func TestStage0WakeWordAct(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "maven, restart nginx", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) {
|
||||
// wakeword prefix alone doesn't guarantee a known command. "maven, i'm tired"
|
||||
// is a fact-ish utterance → falls through to the classifier.
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "maven, i drank water", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, i drank water"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestStage0GrammarFiresThroughCyrillicWakeWord(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
|
||||
d, err := r.Route(context.Background(), "Мэйвен который час", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "Мэйвен который час"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, ReminderGrammar())
|
||||
|
||||
d, err := r.Route(context.Background(), "напомни в 11:00 позвонить маме", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11:00 позвонить маме"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) {
|
||||
// be able to replace it.
|
||||
func TestStage0MatchedSlotBeatsTheExtractor(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "maven, restart nginx", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestStage0QueryKeepsAnEmptyText(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||
|
||||
d, err := r.Route(context.Background(), "что у меня сегодня", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "что у меня сегодня"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func TestStage0QueryKeepsAnEmptyText(t *testing.T) {
|
||||
|
||||
func TestStage1ClassifiesAct(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "restart the backup now", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "restart the backup now"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func TestStage1ClassifiesAct(t *testing.T) {
|
||||
|
||||
func TestStage1ClassifiesFact(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "i drank water", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func TestStage1ClassifiesNoteAndQuery(t *testing.T) {
|
||||
{"when did i last eat", IntentQuery},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d, err := r.Route(context.Background(), c.in, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: c.in}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route %q: %v", c.in, err)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestStage1ClassifiesNoteAndQuery(t *testing.T) {
|
||||
func TestStage2ReminderSlotExtraction(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
now := refNow()
|
||||
d, err := r.Route(context.Background(), "remind me in four hours", now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "remind me in four hours"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) {
|
||||
// "wake me at 7" said at 12:00 → fires tomorrow 07:00 (already past today).
|
||||
r := newTestRouter(t, 0.0)
|
||||
now := refNow()
|
||||
d, err := r.Route(context.Background(), "wake me at 7", now)
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "wake me at 7"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) {
|
||||
|
||||
func TestStage2FactSleptDuration(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0)
|
||||
d, err := r.Route(context.Background(), "slept 6h", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "slept 6h"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func TestStage3ClarifyBelowThreshold(t *testing.T) {
|
||||
// high threshold ⇒ even a well-classified utterance is gated to clarify.
|
||||
// "shuts up when uncertain": a misrouted fact is a confident wrong write.
|
||||
r := newTestRouter(t, 0.99)
|
||||
d, err := r.Route(context.Background(), "i drank water", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -314,7 +314,7 @@ func TestStage3ClarifyBelowThreshold(t *testing.T) {
|
||||
|
||||
func TestStage3PassesAboveThreshold(t *testing.T) {
|
||||
r := newTestRouter(t, 0.0) // threshold 0 ⇒ nothing gated
|
||||
d, err := r.Route(context.Background(), "i drank water", refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func TestColdBootNoIntents(t *testing.T) {
|
||||
emb := NewHashEmbedder(128)
|
||||
c := NewClassifier(emb)
|
||||
r := New(Config{Classifier: c, Threshold: 0})
|
||||
if _, err := r.Route(context.Background(), "something freeform", refNow()); err != ErrNoIntents {
|
||||
if _, err := r.Route(context.Background(), NormalizedInput{Text: "something freeform"}, refNow()); err != ErrNoIntents {
|
||||
t.Fatalf("cold boot: want ErrNoIntents, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func TestCorrectMisrouteGrowsClassifier(t *testing.T) {
|
||||
r := newTestRouter(t, 0.4)
|
||||
// "note the backup is broken" looks note-ish but the user meant a fact
|
||||
// (loop should know the backup is down). Without correction it routes note.
|
||||
before, err := r.Route(context.Background(), "backup is broken", refNow())
|
||||
before, err := r.Route(context.Background(), NormalizedInput{Text: "backup is broken"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func TestCorrectMisrouteGrowsClassifier(t *testing.T) {
|
||||
t.Fatalf("correct: %v", err)
|
||||
}
|
||||
}
|
||||
after, err := r.Route(context.Background(), "backup is broken", refNow())
|
||||
after, err := r.Route(context.Background(), NormalizedInput{Text: "backup is broken"}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func TestStage1ClassifiesChat(t *testing.T) {
|
||||
{"расскажи что-нибудь", IntentChat},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d, err := r.Route(context.Background(), c.in, refNow())
|
||||
d, err := r.Route(context.Background(), NormalizedInput{Text: c.in}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route %q: %v", c.in, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// ScoreLegacy runs the actual router against the corpus and produces the
|
||||
// baseline report. The now parameter is the reference clock for relative
|
||||
// time expressions.
|
||||
func ScoreLegacy(ctx context.Context, r LegacyRouter, exs []RouteExample, now time.Time) LegacyReport {
|
||||
stats := CorpusStatsFrom(exs)
|
||||
rep := LegacyReport{
|
||||
Stats: stats,
|
||||
Total: len(exs),
|
||||
ByRoute: make(map[SemanticRoute]RouteMetrics),
|
||||
Confusion: make(map[SemanticRoute]map[SemanticRoute]int),
|
||||
}
|
||||
for _, rt := range AllRoutes {
|
||||
rep.Confusion[rt] = make(map[SemanticRoute]int)
|
||||
}
|
||||
|
||||
for _, e := range exs {
|
||||
input := router.NormalizedInput{Text: e.Text}
|
||||
d, err := r.Route(ctx, input, now)
|
||||
var predicted SemanticRoute
|
||||
var fpHit bool
|
||||
var preroute bool
|
||||
if err != nil {
|
||||
predicted = RouteUncertain
|
||||
} else {
|
||||
// Determine if fast-path resolved this.
|
||||
fpHit = d.Stage == 0 && d.Producer == router.RouteProducerGrammar
|
||||
predicted = IntentToRoute(d.Intent)
|
||||
// Pre-route consumption: command-prohibition grammar emits
|
||||
// IntentAct with Fn=prohibited_act at stage 0. These cases
|
||||
// never reach the general cascade.
|
||||
preroute = d.Stage == 0 && d.Slots.Fn == "prohibited_act"
|
||||
}
|
||||
agree := predicted == e.Route
|
||||
c := LegacyCase{
|
||||
Example: e,
|
||||
Decision: d,
|
||||
Predicted: predicted,
|
||||
Agree: agree,
|
||||
FastPathHit: fpHit,
|
||||
PrerouteConsumed: preroute,
|
||||
Error: err,
|
||||
}
|
||||
rep.Cases = append(rep.Cases, c)
|
||||
|
||||
rep.Confusion[e.Route][predicted]++
|
||||
if agree {
|
||||
rep.Passed++
|
||||
}
|
||||
if e.Route != RouteAction && predicted == RouteAction {
|
||||
rep.FalseAction++
|
||||
rep.FalseActionCases = append(rep.FalseActionCases, c)
|
||||
}
|
||||
if e.FastPathResolved || fpHit {
|
||||
if preroute {
|
||||
// Command-prohibition grammar: consumed before cascade.
|
||||
rep.PreRouteTotal++
|
||||
if agree {
|
||||
rep.PreRoutePassed++
|
||||
}
|
||||
} else {
|
||||
rep.FastPathTotal++
|
||||
if agree {
|
||||
rep.FastPathPassed++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rep.ResidualTotal++
|
||||
if agree {
|
||||
rep.ResidualPassed++
|
||||
}
|
||||
// All residual cases are router-residual (the learned
|
||||
// router would see all of them).
|
||||
rep.RouterResidualTotal++
|
||||
if agree {
|
||||
rep.RouterResidualPassed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rep.Total > 0 {
|
||||
rep.FalseActionRate = float64(rep.FalseAction) / float64(rep.Total)
|
||||
}
|
||||
|
||||
// Per-route P/R/F1.
|
||||
for _, route := range AllRoutes {
|
||||
tp, fp, fn := 0, 0, 0
|
||||
for _, got := range AllRoutes {
|
||||
count := rep.Confusion[route][got]
|
||||
if got == route {
|
||||
tp = count
|
||||
} else {
|
||||
fn += count
|
||||
fp += rep.Confusion[got][route]
|
||||
}
|
||||
}
|
||||
rm := RouteMetrics{TP: tp, FP: fp, FN: fn}
|
||||
if tp+fp > 0 {
|
||||
rm.Precision = float64(tp) / float64(tp+fp)
|
||||
}
|
||||
if tp+fn > 0 {
|
||||
rm.Recall = float64(tp) / float64(tp+fn)
|
||||
}
|
||||
if rm.Precision+rm.Recall > 0 {
|
||||
rm.F1 = 2 * rm.Precision * rm.Recall / (rm.Precision + rm.Recall)
|
||||
}
|
||||
rm.F1 = math.Round(rm.F1*1000) / 1000
|
||||
rm.Precision = math.Round(rm.Precision*1000) / 1000
|
||||
rm.Recall = math.Round(rm.Recall*1000) / 1000
|
||||
rep.ByRoute[route] = rm
|
||||
}
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
// MacroF1 returns the macro-averaged F1 across all routes.
|
||||
func (r LegacyReport) MacroF1() float64 {
|
||||
if len(r.ByRoute) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum float64
|
||||
for _, rm := range r.ByRoute {
|
||||
sum += rm.F1
|
||||
}
|
||||
return math.Round(sum/float64(len(r.ByRoute))*1000) / 1000
|
||||
}
|
||||
|
||||
// String renders the legacy baseline report.
|
||||
func (r LegacyReport) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "legacy baseline: %d/%d (%.1f%%)\n", r.Passed, r.Total,
|
||||
100*float64(r.Passed)/math.Max(float64(r.Total), 1))
|
||||
fmt.Fprintf(&b, " macro F1: %.3f\n", r.MacroF1())
|
||||
fmt.Fprintf(&b, " false-action: %d/%d (%.3f)\n", r.FalseAction, r.Total, r.FalseActionRate)
|
||||
fmt.Fprintf(&b, " fast-path: %d/%d residual: %d/%d\n",
|
||||
r.FastPathPassed, r.FastPathTotal,
|
||||
r.ResidualPassed, r.ResidualTotal)
|
||||
if r.RouterResidualTotal > 0 {
|
||||
fmt.Fprintf(&b, " router-residual: %d/%d (pre-route consumed: %d)\n",
|
||||
r.RouterResidualPassed, r.RouterResidualTotal, r.PreRouteTotal)
|
||||
}
|
||||
fmt.Fprintf(&b, " per-route:\n")
|
||||
routes := make([]SemanticRoute, 0, len(r.ByRoute))
|
||||
for route := range r.ByRoute {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i] < routes[j] })
|
||||
for _, route := range routes {
|
||||
rm := r.ByRoute[route]
|
||||
fmt.Fprintf(&b, " %-15s P=%.3f R=%.3f F1=%.3f (tp=%d fp=%d fn=%d)\n",
|
||||
string(route), rm.Precision, rm.Recall, rm.F1, rm.TP, rm.FP, rm.FN)
|
||||
}
|
||||
fmt.Fprintf(&b, " confusion matrix:\n")
|
||||
fmt.Fprintf(&b, " %-15s", "")
|
||||
for _, g := range routes {
|
||||
fmt.Fprintf(&b, " %12s", string(g))
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
for _, w := range routes {
|
||||
fmt.Fprintf(&b, " %-15s", string(w))
|
||||
for _, g := range routes {
|
||||
fmt.Fprintf(&b, " %12d", r.Confusion[w][g])
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
}
|
||||
if len(r.FalseActionCases) > 0 {
|
||||
fmt.Fprintf(&b, " false-action cases:\n")
|
||||
for _, c := range r.FalseActionCases {
|
||||
decided := "(error)"
|
||||
if c.Error == nil {
|
||||
decided = fmt.Sprintf("%s (%.3f)", c.Decision.Intent, c.Decision.Confidence)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %q: expected %s, got action (decided %s)\n",
|
||||
c.Example.SourceID, c.Example.Text, c.Example.Route, decided)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ContrastFamilies splits the corpus rows by their transform tag and reports
|
||||
// per-family performance against the legacy router.
|
||||
func ContrastFamilies(rep LegacyReport) []ContrastFamilyReport {
|
||||
// Collect transform tags → cases.
|
||||
tagCases := map[string][]LegacyCase{}
|
||||
for _, c := range rep.Cases {
|
||||
for _, tag := range c.Example.Tags {
|
||||
switch tag {
|
||||
case "negation", "question", "reported_speech", "quotation",
|
||||
"hypothetical", "capability_question":
|
||||
tagCases[tag] = append(tagCases[tag], c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reports []ContrastFamilyReport
|
||||
for _, tag := range []string{
|
||||
"negation", "question", "reported_speech", "quotation",
|
||||
"hypothetical", "capability_question",
|
||||
} {
|
||||
cases := tagCases[tag]
|
||||
if len(cases) == 0 {
|
||||
continue
|
||||
}
|
||||
cr := ContrastFamilyReport{Transform: tag, Examples: len(cases), Cases: cases}
|
||||
for _, c := range cases {
|
||||
if c.Agree {
|
||||
cr.Correct++
|
||||
}
|
||||
if c.Example.Route != RouteAction && c.Predicted == RouteAction {
|
||||
cr.FalseAction++
|
||||
} else if !c.Agree {
|
||||
cr.OtherErrors++
|
||||
}
|
||||
}
|
||||
reports = append(reports, cr)
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
// String renders the contrast family report.
|
||||
func ContrastFamilyReportString(r []ContrastFamilyReport) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%-20s %8s %8s %8s %8s\n", "transform", "examples", "correct", "false-act", "other")
|
||||
for _, cr := range r {
|
||||
fmt.Fprintf(&b, "%-20s %8d %8d %8d %8d\n",
|
||||
cr.Transform, cr.Examples, cr.Correct, cr.FalseAction, cr.OtherErrors)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// LegacyRouter — the interface for scoring the current routing machinery.
|
||||
// *router.Router satisfies this directly. Tests can substitute a stub.
|
||||
type LegacyRouter interface {
|
||||
Route(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error)
|
||||
}
|
||||
|
||||
// LegacyCase — one scored row from the legacy baseline.
|
||||
type LegacyCase struct {
|
||||
Example RouteExample
|
||||
Decision router.Decision
|
||||
Predicted SemanticRoute
|
||||
Agree bool
|
||||
FastPathHit bool
|
||||
// PrerouteConsumed is true when a pre-route resolver (e.g.
|
||||
// command-prohibition grammar) consumed this turn before the general
|
||||
// cascade. These cases never reach the classifier or the LLM.
|
||||
PrerouteConsumed bool
|
||||
Error error
|
||||
}
|
||||
|
||||
// LegacyReport — aggregate metrics from running the real router against the
|
||||
// coarse-route corpus.
|
||||
type LegacyReport struct {
|
||||
Stats CorpusStats
|
||||
Total int
|
||||
Passed int
|
||||
ByRoute map[SemanticRoute]RouteMetrics
|
||||
// FalseAction — cases expected non-action but the router predicted action.
|
||||
FalseAction int
|
||||
FalseActionRate float64
|
||||
// FalseActionCases — the individual cases, for inspection.
|
||||
FalseActionCases []LegacyCase
|
||||
// Confusion[want][got]
|
||||
Confusion map[SemanticRoute]map[SemanticRoute]int
|
||||
// Fast-path breakdown
|
||||
FastPathTotal int
|
||||
FastPathPassed int
|
||||
ResidualTotal int
|
||||
ResidualPassed int
|
||||
// Router-residual breakdown: cases that reach the general cascade
|
||||
// (not consumed by pre-route resolvers like command-prohibition).
|
||||
RouterResidualTotal int
|
||||
RouterResidualPassed int
|
||||
PreRouteTotal int // cases consumed by command-prohibition grammar
|
||||
PreRoutePassed int
|
||||
// Cases by individual outcome
|
||||
Cases []LegacyCase
|
||||
}
|
||||
|
||||
// ContrastFamilyReport — per-transform-family breakdown.
|
||||
type ContrastFamilyReport struct {
|
||||
Transform string
|
||||
Examples int
|
||||
Correct int
|
||||
FalseAction int // predicted action when expected non-action
|
||||
OtherErrors int
|
||||
Cases []LegacyCase
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package semantic defines the coarse-route contract for the learned-router
|
||||
// experiment. The existing seven-intent router remains authoritative; this
|
||||
// package provides a six-class semantic layer that runs only in eval/shadow
|
||||
// mode until it earns promotion.
|
||||
//
|
||||
// The six coarse routes are:
|
||||
//
|
||||
// - conversation — social turns, greetings, mood, jokes, open-ended chat
|
||||
// - knowledge — questions seeking an answer (world, recall, calendar)
|
||||
// - action — executable commands and reminders (act, reminder)
|
||||
// - memory_write — structured or unstructured writes (fact, note)
|
||||
// - system — time, date, quiet mode, self-management
|
||||
// - uncertain — too little signal to decide; the authoritative router clarifies
|
||||
//
|
||||
// No capability, function, slot, or source information lives in this artifact.
|
||||
// That is a later stage. The contract here is purely coarse-route + confidence.
|
||||
package semantic
|
||||
|
||||
// SemanticRoute — one of six coarse semantic classes.
|
||||
type SemanticRoute string
|
||||
|
||||
const (
|
||||
RouteConversation SemanticRoute = "conversation"
|
||||
RouteKnowledge SemanticRoute = "knowledge"
|
||||
RouteAction SemanticRoute = "action"
|
||||
RouteMemoryWrite SemanticRoute = "memory_write"
|
||||
RouteSystem SemanticRoute = "system"
|
||||
RouteUncertain SemanticRoute = "uncertain"
|
||||
)
|
||||
|
||||
// AllRoutes is the ordered set of valid routes, for iteration and confusion
|
||||
// matrix layout.
|
||||
var AllRoutes = []SemanticRoute{
|
||||
RouteConversation,
|
||||
RouteKnowledge,
|
||||
RouteAction,
|
||||
RouteMemoryWrite,
|
||||
RouteSystem,
|
||||
RouteUncertain,
|
||||
}
|
||||
|
||||
// SemanticRouteDecision — the output of a coarse router. No slots, no
|
||||
// capability, no source — just the route, confidence, and whether the model
|
||||
// chose to abstain.
|
||||
type SemanticRouteDecision struct {
|
||||
Route SemanticRoute
|
||||
Confidence float64
|
||||
Abstain bool
|
||||
ModelID string
|
||||
}
|
||||
|
||||
// ValidRoute reports whether r is one of the six defined routes.
|
||||
func ValidRoute(r SemanticRoute) bool {
|
||||
switch r {
|
||||
case RouteConversation, RouteKnowledge, RouteAction,
|
||||
RouteMemoryWrite, RouteSystem, RouteUncertain:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestValidRoute(t *testing.T) {
|
||||
for _, r := range AllRoutes {
|
||||
if !ValidRoute(r) {
|
||||
t.Errorf("ValidRoute(%q) = false, want true", r)
|
||||
}
|
||||
}
|
||||
if ValidRoute("bogus") {
|
||||
t.Error("ValidRoute(\"bogus\") = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentToRouteMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
intent router.Intent
|
||||
route SemanticRoute
|
||||
}{
|
||||
{router.IntentChat, RouteConversation},
|
||||
{router.IntentQuery, RouteKnowledge},
|
||||
{router.IntentAct, RouteAction},
|
||||
{router.IntentReminder, RouteAction},
|
||||
{router.IntentFact, RouteMemoryWrite},
|
||||
{router.IntentNote, RouteMemoryWrite},
|
||||
{router.IntentSystem, RouteSystem},
|
||||
{router.Intent("unknown"), RouteUncertain},
|
||||
{router.Intent(""), RouteUncertain},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := IntentToRoute(c.intent)
|
||||
if got != c.route {
|
||||
t.Errorf("IntentToRoute(%q) = %q, want %q", c.intent, got, c.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCorpus(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exs) == 0 {
|
||||
t.Fatal("corpus is empty")
|
||||
}
|
||||
for _, e := range exs {
|
||||
if !ValidRoute(e.Route) {
|
||||
t.Errorf("example %q has invalid route %q", e.SourceID, e.Route)
|
||||
}
|
||||
if e.Source == "" {
|
||||
t.Errorf("example %q has empty source", e.SourceID)
|
||||
}
|
||||
if e.SplitGroup == "" {
|
||||
t.Errorf("example %q has empty split_group", e.SourceID)
|
||||
}
|
||||
}
|
||||
fastPath, residual := SplitCounts(exs)
|
||||
if fastPath == 0 {
|
||||
t.Error("no fast_path_resolved examples in corpus")
|
||||
}
|
||||
if residual == 0 {
|
||||
t.Error("no residual examples in corpus")
|
||||
}
|
||||
t.Logf("corpus: %d examples (%d fast-path, %d residual)", len(exs), fastPath, residual)
|
||||
}
|
||||
|
||||
func TestCorpusValidation(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := CorpusStatsFrom(exs)
|
||||
|
||||
// Total matches.
|
||||
if stats.Total != len(exs) {
|
||||
t.Errorf("stats.Total=%d, want %d", stats.Total, len(exs))
|
||||
}
|
||||
|
||||
// Route counts sum to total.
|
||||
routeSum := 0
|
||||
for _, c := range stats.RouteCounts {
|
||||
routeSum += c
|
||||
}
|
||||
if routeSum != stats.Total {
|
||||
t.Errorf("route counts sum to %d, want %d", routeSum, stats.Total)
|
||||
}
|
||||
|
||||
// Source counts sum to total.
|
||||
sourceSum := 0
|
||||
for _, c := range stats.SourceCounts {
|
||||
sourceSum += c
|
||||
}
|
||||
if sourceSum != stats.Total {
|
||||
t.Errorf("source counts sum to %d, want %d", sourceSum, stats.Total)
|
||||
}
|
||||
|
||||
// Fast-path + residual == total.
|
||||
if stats.FastPath+stats.Residual != stats.Total {
|
||||
t.Errorf("fast_path(%d) + residual(%d) = %d, want %d",
|
||||
stats.FastPath, stats.Residual, stats.FastPath+stats.Residual, stats.Total)
|
||||
}
|
||||
|
||||
// All SourceIDs are valid (non-empty, checked by ValidateCorpus).
|
||||
// All SplitGroups are non-empty (checked by ValidateCorpus).
|
||||
// No duplicate corpus identity (checked by ValidateCorpus).
|
||||
// No identical Text with conflicting labels (checked by ValidateCorpus).
|
||||
|
||||
t.Logf("validation passed: %d rows, route_hash=%s", stats.Total, stats.DatasetHash)
|
||||
|
||||
// Verify minimum corpus size (expanded v2 corpus).
|
||||
if stats.Total < 1000 {
|
||||
t.Errorf("total = %d, want >= 1000 (expanded corpus)", stats.Total)
|
||||
}
|
||||
// Verify source counts exist.
|
||||
if stats.SourceCounts["ru_routing_v1"] < 15 {
|
||||
t.Errorf("ru_routing_v1 count = %d, want >= 15", stats.SourceCounts["ru_routing_v1"])
|
||||
}
|
||||
if stats.SourceCounts["contrastive"] < 6 {
|
||||
t.Errorf("contrastive count = %d, want >= 6", stats.SourceCounts["contrastive"])
|
||||
}
|
||||
if stats.SourceCounts["corpus_factory_v2"] < 1000 {
|
||||
t.Errorf("corpus_factory_v2 count = %d, want >= 1000", stats.SourceCounts["corpus_factory_v2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorpusByRoute(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byRoute := ByRoute(exs)
|
||||
for _, route := range AllRoutes {
|
||||
count := len(byRoute[route])
|
||||
if count == 0 {
|
||||
t.Errorf("no examples for route %q", route)
|
||||
}
|
||||
t.Logf(" %s: %d examples", route, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByFamily(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
train, eval := SplitByFamily(exs, 0.8)
|
||||
total := len(train) + len(eval)
|
||||
if total != len(exs) {
|
||||
t.Errorf("split lost examples: train=%d eval=%d total=%d, want %d",
|
||||
len(train), len(eval), total, len(exs))
|
||||
}
|
||||
trainGroups := map[string]bool{}
|
||||
for _, e := range train {
|
||||
trainGroups[e.SplitGroup] = true
|
||||
}
|
||||
for _, e := range eval {
|
||||
if trainGroups[e.SplitGroup] {
|
||||
t.Errorf("split_group %q leaked across train/eval (example %q)",
|
||||
e.SplitGroup, e.SourceID)
|
||||
}
|
||||
}
|
||||
t.Logf("split: %d train, %d eval", len(train), len(eval))
|
||||
}
|
||||
|
||||
func TestFrozenHoldoutSplit(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frozen, dev, hash := FrozenHoldoutSplit(exs)
|
||||
total := len(frozen) + len(dev)
|
||||
if total != len(exs) {
|
||||
t.Errorf("split lost examples: frozen=%d dev=%d total=%d, want %d",
|
||||
len(frozen), len(dev), total, len(exs))
|
||||
}
|
||||
if len(frozen) == 0 {
|
||||
t.Error("frozen holdout is empty")
|
||||
}
|
||||
if len(dev) == 0 {
|
||||
t.Error("development pool is empty")
|
||||
}
|
||||
|
||||
// No split_group leakage.
|
||||
frozenGroups := map[string]bool{}
|
||||
for _, e := range frozen {
|
||||
frozenGroups[e.SplitGroup] = true
|
||||
}
|
||||
for _, e := range dev {
|
||||
if frozenGroups[e.SplitGroup] {
|
||||
t.Errorf("split_group %q leaked across frozen/dev (example %q)",
|
||||
e.SplitGroup, e.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
// Determinism: same split produces same hash.
|
||||
frozen2, dev2, hash2 := FrozenHoldoutSplit(exs)
|
||||
if hash != hash2 {
|
||||
t.Errorf("non-deterministic: hash=%s vs %s", hash, hash2)
|
||||
}
|
||||
if len(frozen) != len(frozen2) || len(dev) != len(dev2) {
|
||||
t.Errorf("non-deterministic: frozen=%d/%d dev=%d/%d",
|
||||
len(frozen), len(frozen2), len(dev), len(dev2))
|
||||
}
|
||||
|
||||
// Log route coverage in both splits (not all routes need to appear
|
||||
// in the frozen holdout — at 15% of 136 rows, small routes may miss).
|
||||
frozenRoutes := map[SemanticRoute]bool{}
|
||||
for _, e := range frozen {
|
||||
frozenRoutes[e.Route] = true
|
||||
}
|
||||
devRoutes := map[SemanticRoute]bool{}
|
||||
for _, e := range dev {
|
||||
devRoutes[e.Route] = true
|
||||
}
|
||||
for _, r := range AllRoutes {
|
||||
if !frozenRoutes[r] {
|
||||
t.Logf("note: route %q missing from frozen holdout (expected at 15%%)", r)
|
||||
}
|
||||
if !devRoutes[r] {
|
||||
t.Errorf("route %q missing from development pool", r)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("frozen: %d examples, dev: %d examples, holdout_hash=%s",
|
||||
len(frozen), len(dev), hash)
|
||||
}
|
||||
|
||||
func TestGroupedCVFolds(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, dev, _ := FrozenHoldoutSplit(exs)
|
||||
folds := GroupedCVFolds(dev, 5)
|
||||
if len(folds) != 5 {
|
||||
t.Fatalf("expected 5 folds, got %d", len(folds))
|
||||
}
|
||||
|
||||
for i, f := range folds {
|
||||
total := len(f.Train) + len(f.Eval)
|
||||
if total != len(dev) {
|
||||
t.Errorf("fold %d: train=%d + eval=%d = %d, want %d",
|
||||
i, len(f.Train), len(f.Eval), total, len(dev))
|
||||
}
|
||||
if len(f.Eval) == 0 {
|
||||
t.Errorf("fold %d: empty eval set", i)
|
||||
}
|
||||
|
||||
// No split_group leakage within a fold.
|
||||
evalGroups := map[string]bool{}
|
||||
for _, e := range f.Eval {
|
||||
evalGroups[e.SplitGroup] = true
|
||||
}
|
||||
for _, e := range f.Train {
|
||||
if evalGroups[e.SplitGroup] {
|
||||
t.Errorf("fold %d: split_group %q leaked (example %q)",
|
||||
i, e.SplitGroup, e.SourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every example appears in exactly one eval set across all folds.
|
||||
evalCount := 0
|
||||
for _, f := range folds {
|
||||
evalCount += len(f.Eval)
|
||||
}
|
||||
if evalCount != len(dev) {
|
||||
t.Errorf("total eval examples across folds: %d, want %d", evalCount, len(dev))
|
||||
}
|
||||
|
||||
t.Logf("grouped CV: %d folds on %d development examples", len(folds), len(dev))
|
||||
}
|
||||
|
||||
func TestFamilyID(t *testing.T) {
|
||||
id1 := FamilyID("ru-act-002", "negation")
|
||||
id2 := FamilyID("ru-act-002", "question")
|
||||
id3 := FamilyID("ru-act-002", "")
|
||||
id4 := FamilyID("ru-act-001", "negation")
|
||||
if id1 == id2 {
|
||||
t.Error("different transforms produced same FamilyID")
|
||||
}
|
||||
if id1 == id3 {
|
||||
t.Error("transform and base produced same FamilyID")
|
||||
}
|
||||
if id1 == id4 {
|
||||
t.Error("different base IDs produced same FamilyID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContrastFamiliesShareSplitGroup(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Group contrastive examples by their base source_id.
|
||||
baseGroups := map[string]map[string]bool{} // source_id → {split_group: true}
|
||||
for _, e := range exs {
|
||||
if e.Source != "contrastive" {
|
||||
continue
|
||||
}
|
||||
if baseGroups[e.SourceID] == nil {
|
||||
baseGroups[e.SourceID] = map[string]bool{}
|
||||
}
|
||||
baseGroups[e.SourceID][e.SplitGroup] = true
|
||||
}
|
||||
|
||||
// Every base seed's contrastive variants must share exactly one
|
||||
// split_group (the base seed's own group).
|
||||
for sourceID, groups := range baseGroups {
|
||||
if len(groups) != 1 {
|
||||
t.Errorf("base %q has contrastive variants in %d split groups: %v",
|
||||
sourceID, len(groups), groups)
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify that contrastive variants share the split_group with
|
||||
// their base seed from ru_routing_v1.
|
||||
ruGroups := map[string]string{} // source_id → split_group
|
||||
for _, e := range exs {
|
||||
if e.Source == "ru_routing_v1" {
|
||||
ruGroups[e.SourceID] = e.SplitGroup
|
||||
}
|
||||
}
|
||||
for sourceID, groups := range baseGroups {
|
||||
var contrastGroup string
|
||||
for g := range groups {
|
||||
contrastGroup = g
|
||||
}
|
||||
if ruGroup, ok := ruGroups[sourceID]; ok && contrastGroup != ruGroup {
|
||||
t.Errorf("base %q: ru_routing_v1 split_group=%q, contrastive split_group=%q",
|
||||
sourceID, ruGroup, contrastGroup)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("contrast family leakage check passed: %d base seeds, all groups consistent",
|
||||
len(baseGroups))
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed corpus_v1.json
|
||||
var corpusV1JSON []byte
|
||||
|
||||
// RouteExample — one labeled utterance in the coarse-route experiment corpus.
|
||||
// Every row carries provenance so no row can masquerade as independent when
|
||||
// related paraphrases exist.
|
||||
type RouteExample struct {
|
||||
Text string `json:"text"`
|
||||
Route SemanticRoute `json:"route"`
|
||||
Source string `json:"source"` // where this row came from
|
||||
SourceID string `json:"source_id"` // case ID in the source corpus
|
||||
SplitGroup string `json:"split_group"` // family/seed ID for split discipline
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// FastPathResolved is true when a stage-0 grammar already handles this
|
||||
// utterance. The learned router should not be measured on these unless
|
||||
// explicitly desired; they are tagged, not removed.
|
||||
FastPathResolved bool `json:"fast_path_resolved"`
|
||||
// RouterResidual marks rows that would actually reach the general routing
|
||||
// cascade after pre-route resolvers (command-prohibition, etc.) have had
|
||||
// first refusal. Static corpus evaluation cannot determine this for all
|
||||
// cases (some need dialogue state), so this is an explicit annotation
|
||||
// rather than a derived field.
|
||||
RouterResidual *bool `json:"router_residual,omitempty"`
|
||||
}
|
||||
|
||||
// CorpusEnvelope — the versioned JSON envelope with reproducibility metadata.
|
||||
type CorpusEnvelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
// Reproducibility metadata — informational, not validated against at
|
||||
// load time. The hash fields are precomputed from the source fixtures
|
||||
// at corpus-build time and recorded here so a later reader can verify
|
||||
// the corpus was built from the expected inputs.
|
||||
Reproducibility *ReproducibilityMeta `json:"reproducibility,omitempty"`
|
||||
Examples []RouteExample `json:"examples"`
|
||||
}
|
||||
|
||||
// ReproducibilityMeta — dataset identity for later experiment reproduction.
|
||||
type ReproducibilityMeta struct {
|
||||
// SourceFixtureHash is the SHA-256 of the concatenated source fixture
|
||||
// files used to build this corpus, hex-encoded, first 16 bytes.
|
||||
SourceFixtureHash string `json:"source_fixture_hash"`
|
||||
// ContrastGeneratorVersion identifies the transform code version.
|
||||
ContrastGeneratorVersion string `json:"contrast_generator_version"`
|
||||
// SplitAlgorithm identifies the split algorithm and version.
|
||||
SplitAlgorithm string `json:"split_algorithm"`
|
||||
// DatasetHash is the SHA-256 of the sorted example texts, hex-encoded,
|
||||
// first 16 bytes. Computed at validation time.
|
||||
DatasetHash string `json:"dataset_hash"`
|
||||
// FrozenHoldoutHash is the SHA-256 of the frozen holdout group IDs,
|
||||
// computed when the split is created.
|
||||
FrozenHoldoutHash string `json:"frozen_holdout_hash,omitempty"`
|
||||
}
|
||||
|
||||
// SchemaVersionV1 is the version this package understands.
|
||||
const SchemaVersionV1 = 1
|
||||
|
||||
// CorpusStats — computed from a validated corpus. Returned by ValidateCorpus
|
||||
// so callers get the numbers without recomputing.
|
||||
type CorpusStats struct {
|
||||
Total int
|
||||
RouteCounts map[SemanticRoute]int
|
||||
SourceCounts map[string]int
|
||||
FastPath int
|
||||
Residual int
|
||||
DatasetHash string
|
||||
}
|
||||
|
||||
// LoadCorpus returns the embedded corpus, rejecting unknown schema versions
|
||||
// and failing on structural validation errors.
|
||||
func LoadCorpus() ([]RouteExample, error) {
|
||||
var env CorpusEnvelope
|
||||
if err := json.Unmarshal(corpusV1JSON, &env); err != nil {
|
||||
return nil, fmt.Errorf("semantic corpus: parse: %w", err)
|
||||
}
|
||||
if env.SchemaVersion != SchemaVersionV1 {
|
||||
return nil, fmt.Errorf("semantic corpus: schema_version %d, want %d",
|
||||
env.SchemaVersion, SchemaVersionV1)
|
||||
}
|
||||
if err := ValidateCorpus(env.Examples); err != nil {
|
||||
return nil, fmt.Errorf("semantic corpus: %w", err)
|
||||
}
|
||||
return env.Examples, nil
|
||||
}
|
||||
|
||||
// ValidateCorpus checks structural invariants: total matches, route/source
|
||||
// sums, fast-path+residual, no duplicate identities, no conflicting labels.
|
||||
func ValidateCorpus(exs []RouteExample) error {
|
||||
if len(exs) == 0 {
|
||||
return fmt.Errorf("corpus is empty")
|
||||
}
|
||||
|
||||
routeCounts := map[SemanticRoute]int{}
|
||||
sourceCounts := map[string]int{}
|
||||
type idKey struct{ Source, SourceID, Text string }
|
||||
seen := map[idKey]bool{}
|
||||
textRoute := map[string]SemanticRoute{}
|
||||
|
||||
for i, e := range exs {
|
||||
if e.Source == "" {
|
||||
return fmt.Errorf("row %d: empty source (source_id=%q)", i, e.SourceID)
|
||||
}
|
||||
if e.SourceID == "" {
|
||||
return fmt.Errorf("row %d: empty source_id (source=%q)", i, e.Source)
|
||||
}
|
||||
if e.SplitGroup == "" {
|
||||
return fmt.Errorf("row %d: empty split_group (source_id=%q)", i, e.SourceID)
|
||||
}
|
||||
if !ValidRoute(e.Route) {
|
||||
return fmt.Errorf("row %d: invalid route %q (source_id=%q)", i, e.Route, e.SourceID)
|
||||
}
|
||||
|
||||
// Check for conflicting labels on identical text.
|
||||
norm := strings.TrimSpace(e.Text)
|
||||
|
||||
key := idKey{Source: e.Source, SourceID: e.SourceID, Text: norm}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("row %d: duplicate source+source_id+text %q:%q:%q", i, e.Source, e.SourceID, norm)
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
routeCounts[e.Route]++
|
||||
sourceCounts[e.Source]++
|
||||
|
||||
if prev, ok := textRoute[norm]; ok && prev != e.Route {
|
||||
return fmt.Errorf("row %d: text %q has route %q, but earlier row had %q",
|
||||
i, norm, e.Route, prev)
|
||||
}
|
||||
textRoute[norm] = e.Route
|
||||
}
|
||||
|
||||
// Verify fast-path + residual == total.
|
||||
fastPath, residual := SplitCounts(exs)
|
||||
if fastPath+residual != len(exs) {
|
||||
return fmt.Errorf("fast_path(%d) + residual(%d) = %d != total(%d)",
|
||||
fastPath, residual, fastPath+residual, len(exs))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CorpusStatsFrom computes the stats for a validated corpus.
|
||||
func CorpusStatsFrom(exs []RouteExample) CorpusStats {
|
||||
routeCounts := map[SemanticRoute]int{}
|
||||
sourceCounts := map[string]int{}
|
||||
for _, e := range exs {
|
||||
routeCounts[e.Route]++
|
||||
sourceCounts[e.Source]++
|
||||
}
|
||||
fastPath, residual := SplitCounts(exs)
|
||||
|
||||
// Deterministic dataset hash: sort texts, hash the concatenation.
|
||||
texts := make([]string, len(exs))
|
||||
for i, e := range exs {
|
||||
texts[i] = e.Text
|
||||
}
|
||||
sort.Strings(texts)
|
||||
h := sha256.Sum256([]byte(strings.Join(texts, "\n")))
|
||||
|
||||
return CorpusStats{
|
||||
Total: len(exs),
|
||||
RouteCounts: routeCounts,
|
||||
SourceCounts: sourceCounts,
|
||||
FastPath: fastPath,
|
||||
Residual: residual,
|
||||
DatasetHash: hex.EncodeToString(h[:16]),
|
||||
}
|
||||
}
|
||||
|
||||
// ByRoute groups examples by their semantic route, for per-route inspection.
|
||||
func ByRoute(exs []RouteExample) map[SemanticRoute][]RouteExample {
|
||||
m := make(map[SemanticRoute][]RouteExample)
|
||||
for _, e := range exs {
|
||||
m[e.Route] = append(m[e.Route], e)
|
||||
}
|
||||
for k := range m {
|
||||
sort.Slice(m[k], func(i, j int) bool { return m[k][i].Text < m[k][j].Text })
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SplitCounts returns the number of fast-path-resolved vs
|
||||
// general-route-required examples.
|
||||
func SplitCounts(exs []RouteExample) (fastPath, residual int) {
|
||||
for _, e := range exs {
|
||||
if e.FastPathResolved {
|
||||
fastPath++
|
||||
} else {
|
||||
residual++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EvalCase — one row in the frozen evaluation set. Carries both the expected
|
||||
// coarse route and metadata about how it was generated.
|
||||
type EvalCase struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
ExpectedRoute SemanticRoute `json:"expected_route"`
|
||||
Source string `json:"source"`
|
||||
SourceID string `json:"source_id"`
|
||||
SplitGroup string `json:"split_group"`
|
||||
FastPathResolved bool `json:"fast_path_resolved"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// EvalOutcome — one scored case.
|
||||
type EvalOutcome struct {
|
||||
Case EvalCase
|
||||
Got SemanticRoute
|
||||
OK bool
|
||||
FastOK bool // agreement with fast-path when applicable
|
||||
}
|
||||
|
||||
// EvalReport — aggregate metrics for a frozen eval run.
|
||||
type EvalReport struct {
|
||||
Total int
|
||||
Passed int
|
||||
ByRoute map[SemanticRoute]RouteMetrics
|
||||
// FalseAction — the primary safety metric: cases that should NOT be
|
||||
// action but were classified as action.
|
||||
FalseAction int
|
||||
FalseActionRate float64
|
||||
// Confusion[want][got] counts
|
||||
Confusion map[SemanticRoute]map[SemanticRoute]int
|
||||
// FastPathResolved vs residual split
|
||||
FastPathTotal int
|
||||
FastPathPassed int
|
||||
ResidualTotal int
|
||||
ResidualPassed int
|
||||
}
|
||||
|
||||
// RouteMetrics — per-route precision/recall/F1.
|
||||
type RouteMetrics struct {
|
||||
Precision float64
|
||||
Recall float64
|
||||
F1 float64
|
||||
TP int
|
||||
FP int
|
||||
FN int
|
||||
}
|
||||
|
||||
// ScoreEval runs a SemanticRouter against a frozen eval set and returns
|
||||
// aggregate metrics.
|
||||
func ScoreEval(router SemanticRouter, evalSet []EvalCase) EvalReport {
|
||||
rep := EvalReport{
|
||||
ByRoute: make(map[SemanticRoute]RouteMetrics),
|
||||
Confusion: make(map[SemanticRoute]map[SemanticRoute]int),
|
||||
}
|
||||
for _, r := range AllRoutes {
|
||||
rep.Confusion[r] = make(map[SemanticRoute]int)
|
||||
}
|
||||
|
||||
for _, c := range evalSet {
|
||||
decision, err := router.Route(nil, c.Text)
|
||||
var got SemanticRoute
|
||||
if err != nil {
|
||||
got = RouteUncertain
|
||||
} else {
|
||||
got = decision.Route
|
||||
}
|
||||
ok := got == c.ExpectedRoute
|
||||
rep.Total++
|
||||
if ok {
|
||||
rep.Passed++
|
||||
}
|
||||
rep.Confusion[c.ExpectedRoute][got]++
|
||||
|
||||
if c.ExpectedRoute != RouteAction && got == RouteAction {
|
||||
rep.FalseAction++
|
||||
}
|
||||
if c.FastPathResolved {
|
||||
rep.FastPathTotal++
|
||||
if ok {
|
||||
rep.FastPathPassed++
|
||||
}
|
||||
} else {
|
||||
rep.ResidualTotal++
|
||||
if ok {
|
||||
rep.ResidualPassed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rep.Total > 0 {
|
||||
rep.FalseActionRate = float64(rep.FalseAction) / float64(rep.Total)
|
||||
}
|
||||
|
||||
// Compute per-route precision/recall/F1.
|
||||
for _, route := range AllRoutes {
|
||||
tp, fp, fn := 0, 0, 0
|
||||
for _, got := range AllRoutes {
|
||||
count := rep.Confusion[route][got]
|
||||
if got == route {
|
||||
tp = count
|
||||
} else {
|
||||
fn += count
|
||||
// FP = rows where got==route but expected!=route
|
||||
fp += rep.Confusion[got][route]
|
||||
}
|
||||
}
|
||||
rm := RouteMetrics{TP: tp, FP: fp, FN: fn}
|
||||
if tp+fp > 0 {
|
||||
rm.Precision = float64(tp) / float64(tp+fp)
|
||||
}
|
||||
if tp+fn > 0 {
|
||||
rm.Recall = float64(tp) / float64(tp+fn)
|
||||
}
|
||||
if rm.Precision+rm.Recall > 0 {
|
||||
rm.F1 = 2 * rm.Precision * rm.Recall / (rm.Precision + rm.Recall)
|
||||
}
|
||||
rm.F1 = math.Round(rm.F1*1000) / 1000
|
||||
rm.Precision = math.Round(rm.Precision*1000) / 1000
|
||||
rm.Recall = math.Round(rm.Recall*1000) / 1000
|
||||
rep.ByRoute[route] = rm
|
||||
}
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
// MacroF1 returns the macro-averaged F1 across all routes.
|
||||
func (r EvalReport) MacroF1() float64 {
|
||||
if len(r.ByRoute) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum float64
|
||||
for _, rm := range r.ByRoute {
|
||||
sum += rm.F1
|
||||
}
|
||||
return math.Round(sum/float64(len(r.ByRoute))*1000) / 1000
|
||||
}
|
||||
|
||||
// String renders the report as a compact table.
|
||||
func (r EvalReport) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "semantic eval: %d/%d (%.1f%%)\n", r.Passed, r.Total,
|
||||
100*float64(r.Passed)/math.Max(float64(r.Total), 1))
|
||||
fmt.Fprintf(&b, " macro F1: %.3f\n", r.MacroF1())
|
||||
fmt.Fprintf(&b, " false-action: %d/%d (%.3f)\n", r.FalseAction, r.Total, r.FalseActionRate)
|
||||
if r.FastPathTotal+r.ResidualTotal > 0 {
|
||||
fmt.Fprintf(&b, " fast-path: %d/%d residual: %d/%d\n",
|
||||
r.FastPathPassed, r.FastPathTotal,
|
||||
r.ResidualPassed, r.ResidualTotal)
|
||||
}
|
||||
fmt.Fprintf(&b, " per-route:\n")
|
||||
routes := make([]SemanticRoute, 0, len(r.ByRoute))
|
||||
for route := range r.ByRoute {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i] < routes[j] })
|
||||
for _, route := range routes {
|
||||
rm := r.ByRoute[route]
|
||||
fmt.Fprintf(&b, " %-15s P=%.3f R=%.3f F1=%.3f (tp=%d fp=%d fn=%d)\n",
|
||||
string(route), rm.Precision, rm.Recall, rm.F1, rm.TP, rm.FP, rm.FN)
|
||||
}
|
||||
fmt.Fprintf(&b, " confusion matrix:\n")
|
||||
fmt.Fprintf(&b, " %-15s", "")
|
||||
for _, g := range routes {
|
||||
fmt.Fprintf(&b, " %10s", string(g))
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
for _, w := range routes {
|
||||
fmt.Fprintf(&b, " %-15s", string(w))
|
||||
for _, g := range routes {
|
||||
fmt.Fprintf(&b, " %10d", r.Confusion[w][g])
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// SemanticRouterFunc adapts a bare function to SemanticRouter.
|
||||
type SemanticRouterFunc func(ctx context.Context, text string) (SemanticRouteDecision, error)
|
||||
|
||||
func (f SemanticRouterFunc) Route(ctx context.Context, text string) (SemanticRouteDecision, error) {
|
||||
return f(ctx, text)
|
||||
}
|
||||
|
||||
func TestEvalScoring(t *testing.T) {
|
||||
model := SemanticRouterFunc(func(ctx context.Context, text string) (SemanticRouteDecision, error) {
|
||||
return SemanticRouteDecision{Route: RouteKnowledge, Confidence: 0.9}, nil
|
||||
})
|
||||
|
||||
evalSet := []EvalCase{
|
||||
{ID: "a1", Text: "привет", ExpectedRoute: RouteConversation},
|
||||
{ID: "a2", Text: "сколько воды", ExpectedRoute: RouteKnowledge},
|
||||
{ID: "a3", Text: "выключи свет", ExpectedRoute: RouteAction},
|
||||
{ID: "a4", Text: "запиши заметку", ExpectedRoute: RouteMemoryWrite},
|
||||
}
|
||||
|
||||
rep := ScoreEval(model, evalSet)
|
||||
if rep.Total != 4 {
|
||||
t.Errorf("Total = %d, want 4", rep.Total)
|
||||
}
|
||||
if rep.Passed != 1 {
|
||||
t.Errorf("Passed = %d, want 1 (only knowledge)", rep.Passed)
|
||||
}
|
||||
if rep.FalseAction != 0 {
|
||||
t.Errorf("FalseAction = %d, want 0", rep.FalseAction)
|
||||
}
|
||||
km := rep.ByRoute[RouteKnowledge]
|
||||
if km.Precision != 0.25 {
|
||||
t.Errorf("knowledge precision = %.3f, want 0.250", km.Precision)
|
||||
}
|
||||
if km.Recall != 1.0 {
|
||||
t.Errorf("knowledge recall = %.3f, want 1.000", km.Recall)
|
||||
}
|
||||
t.Logf("eval report:\n%s", rep.String())
|
||||
}
|
||||
|
||||
func TestShadowHarness(t *testing.T) {
|
||||
model := SemanticRouterFunc(func(ctx context.Context, text string) (SemanticRouteDecision, error) {
|
||||
if text == "выключи свет" {
|
||||
return SemanticRouteDecision{Route: RouteAction, Confidence: 0.9}, nil
|
||||
}
|
||||
return SemanticRouteDecision{Route: RouteConversation, Confidence: 0.7}, nil
|
||||
})
|
||||
|
||||
h := NewShadowHarness(model)
|
||||
|
||||
h.Observe(context.Background(), "выключи свет",
|
||||
router.Decision{Intent: router.IntentAct}, false)
|
||||
h.Observe(context.Background(), "привет",
|
||||
router.Decision{Intent: router.IntentChat}, false)
|
||||
h.Observe(context.Background(), "перезапусти докер",
|
||||
router.Decision{Intent: router.IntentAct}, true)
|
||||
|
||||
rep := h.Summarize()
|
||||
if rep.Total != 3 {
|
||||
t.Errorf("Total = %d, want 3", rep.Total)
|
||||
}
|
||||
if rep.Agree != 2 {
|
||||
t.Errorf("Agree = %d, want 2", rep.Agree)
|
||||
}
|
||||
if rep.Disagree != 1 {
|
||||
t.Errorf("Disagree = %d, want 1", rep.Disagree)
|
||||
}
|
||||
if rep.FastPathTotal != 1 {
|
||||
t.Errorf("FastPathTotal = %d, want 1", rep.FastPathTotal)
|
||||
}
|
||||
if rep.ResidualTotal != 2 {
|
||||
t.Errorf("ResidualTotal = %d, want 2", rep.ResidualTotal)
|
||||
}
|
||||
t.Logf("shadow report:\n%s", rep.String())
|
||||
}
|
||||
|
||||
func TestShadowHarnessNilModel(t *testing.T) {
|
||||
h := NewShadowHarness(nil)
|
||||
h.Observe(context.Background(), "привет",
|
||||
router.Decision{Intent: router.IntentChat}, false)
|
||||
rep := h.Summarize()
|
||||
if rep.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", rep.Total)
|
||||
}
|
||||
if rep.Agree != 0 {
|
||||
t.Errorf("Agree = %d, want 0 (nil model → uncertain)", rep.Agree)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyRouterAdapter wraps a *router.Router to satisfy LegacyRouter.
|
||||
type legacyRouterAdapter struct {
|
||||
router *router.Router
|
||||
}
|
||||
|
||||
func (a *legacyRouterAdapter) Route(ctx context.Context, input router.NormalizedInput, now time.Time) (router.Decision, error) {
|
||||
return a.router.Route(ctx, input, now)
|
||||
}
|
||||
|
||||
// TestLegacyBaseline runs the actual router cascade against the corpus and
|
||||
// reports the real baseline. This test builds a minimal but complete router:
|
||||
// stage-0 grammars (the full daemon set), a hash-embedder classifier seeded
|
||||
// from models/seeds/*.txt, and the deployed confidence threshold.
|
||||
//
|
||||
// The hash embedder is deterministic, so this baseline is reproducible. The
|
||||
// ONNX embedder would score higher; measure both before drawing conclusions.
|
||||
func TestLegacyBaseline(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := buildMinimalRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
rep := ScoreLegacy(context.Background(), r, exs, now)
|
||||
t.Log("\n" + rep.String())
|
||||
|
||||
// Print contrast family breakdown.
|
||||
families := ContrastFamilies(rep)
|
||||
if len(families) > 0 {
|
||||
t.Logf("contrast families:\n%s", ContrastFamilyReportString(families))
|
||||
}
|
||||
|
||||
// Log residual-only metrics.
|
||||
t.Logf("residual: %d/%d (%.1f%%)",
|
||||
rep.ResidualPassed, rep.ResidualTotal,
|
||||
100*float64(rep.ResidualPassed)/maxf(float64(rep.ResidualTotal), 1))
|
||||
}
|
||||
|
||||
// buildMinimalRouter creates a router with the daemon's grammar set, a
|
||||
// hash-embedder classifier seeded from models/seeds, and the deployed
|
||||
// threshold. This is the minimal real router that can score the corpus.
|
||||
func buildMinimalRouter(t *testing.T) LegacyRouter {
|
||||
t.Helper()
|
||||
// Use the same act matcher and seed loading as the eval package.
|
||||
acts := router.DefaultActMatcher{Fns: ExperimentActVerbs()}
|
||||
cls := buildSeededClassifier(t, router.NewHashEmbedder(1024))
|
||||
r := router.New(router.Config{
|
||||
Grammars: router.StageZeroGrammars(acts),
|
||||
Classifier: cls,
|
||||
Extractor: router.Extractor{
|
||||
Time: router.StubDateTimeParser{},
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
return &legacyRouterAdapter{router: r}
|
||||
}
|
||||
|
||||
func maxf(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// ExperimentActVerbs is the act allowlist shared by the experiment's legacy
|
||||
// baseline (the eval fixture and the slice-22 harness), the routed-heads
|
||||
// harness, and the corpus fast-path derivation. One non-test list so the
|
||||
// corpus derivation can never drift from what the measurement router accepts.
|
||||
func ExperimentActVerbs() []string {
|
||||
return []string{
|
||||
"перезапусти", "перезагрузи", "выключи", "включи", "останови", "запусти",
|
||||
"закрой", "открой", "сделай", "поставь",
|
||||
"restart", "reboot", "stop", "start", "turn off", "turn on", "open", "close",
|
||||
}
|
||||
}
|
||||
|
||||
func experimentActMatcher() router.ActMatcher {
|
||||
return router.DefaultActMatcher{Fns: ExperimentActVerbs()}
|
||||
}
|
||||
|
||||
// FastPathOutcome is the derived fast-path classification for one surface.
|
||||
// Matched mirrors TryFastPath; Grammar names the winning stage-0 grammar for
|
||||
// attribution, or the empty string when nothing resolved.
|
||||
type FastPathOutcome struct {
|
||||
Matched bool
|
||||
Grammar string
|
||||
}
|
||||
|
||||
var (
|
||||
fastPathOnce sync.Once
|
||||
fastPathRouter *router.Router
|
||||
fastPathGrammars []router.Grammar
|
||||
)
|
||||
|
||||
// DeriveFastPath mirrors the production fast path for a surface: it runs
|
||||
// TryFastPath over the daemon's ordered stage-0 grammar list with the
|
||||
// experiment's act allowlist — the exact router the legacy baseline measures —
|
||||
// and reports whether a grammar resolved the utterance. This is the
|
||||
// authoritative derivation for corpus fast_path_resolved metadata. The
|
||||
// classifier, threshold and LLM never run on the fast path, so they are not
|
||||
// wired here.
|
||||
func DeriveFastPath(text string) FastPathOutcome {
|
||||
fastPathOnce.Do(func() {
|
||||
acts := experimentActMatcher()
|
||||
fastPathGrammars = router.StageZeroGrammars(acts)
|
||||
fastPathRouter = router.New(router.Config{
|
||||
Grammars: fastPathGrammars,
|
||||
Extractor: router.Extractor{
|
||||
Time: router.StubDateTimeParser{},
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
})
|
||||
})
|
||||
res, err := fastPathRouter.TryFastPath(context.Background(), router.NormalizedInput{
|
||||
Text: text,
|
||||
MatchText: router.NormalizeMatchText(text),
|
||||
}, time.Now())
|
||||
if err != nil || !res.Matched {
|
||||
return FastPathOutcome{}
|
||||
}
|
||||
// Attribute the winner by replaying TryFastPath's ordered first-accept
|
||||
// walk, including the wake-stripped alternate. A matcher that matched the
|
||||
// shape but declined the content falls through, exactly as the router does.
|
||||
stripped, hadWake := router.StripWakeToken(text)
|
||||
for _, g := range fastPathGrammars {
|
||||
_, matched, ok := g.Evaluate(text)
|
||||
if !matched && hadWake {
|
||||
_, matched, ok = g.Evaluate(stripped)
|
||||
}
|
||||
if matched && ok {
|
||||
return FastPathOutcome{Matched: true, Grammar: g.Name}
|
||||
}
|
||||
}
|
||||
return FastPathOutcome{Matched: true}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package semantic
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFastPathDerivationInvariant asserts that every development-pool row's
|
||||
// stored fast-path flag exactly matches what the production fast path derives
|
||||
// today (DeriveFastPath runs TryFastPath over the stage-0 grammars with the
|
||||
// experiment's act allowlist). Frozen holdout rows are preserved verbatim
|
||||
// across merges and are exempt — their drift is reported, never silently
|
||||
// rewritten. A stale development value is a corpus-build error.
|
||||
func TestFastPathDerivationInvariant(t *testing.T) {
|
||||
exs, err := LoadCorpus()
|
||||
if err != nil {
|
||||
t.Fatalf("load corpus: %v", err)
|
||||
}
|
||||
_, dev, _ := FrozenHoldoutSplit(exs)
|
||||
devSet := make(map[string]bool, len(dev))
|
||||
for _, e := range dev {
|
||||
devSet[e.SourceID] = true
|
||||
}
|
||||
|
||||
stale := 0
|
||||
for _, e := range exs {
|
||||
if !devSet[e.SourceID] {
|
||||
continue
|
||||
}
|
||||
if got := DeriveFastPath(e.Text).Matched; got != e.FastPathResolved {
|
||||
t.Errorf("dev row %s (route=%s) fast_path_resolved=%v but router derives %v: %q",
|
||||
e.SourceID, e.Route, e.FastPathResolved, got, e.Text)
|
||||
stale++
|
||||
}
|
||||
}
|
||||
if stale > 0 {
|
||||
t.Fatalf("%d development rows have stale fast-path metadata", stale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
const seedDir = "../../../models/seeds"
|
||||
|
||||
var seedIntents = []router.Intent{
|
||||
router.IntentAct, router.IntentReminder, router.IntentFact,
|
||||
router.IntentNote, router.IntentQuery, router.IntentChat, router.IntentSystem,
|
||||
}
|
||||
|
||||
func buildSeededClassifier(t *testing.T, emb router.Embedder) *router.Classifier {
|
||||
t.Helper()
|
||||
cls := router.NewClassifier(emb)
|
||||
ctx := context.Background()
|
||||
seeds := seedsWithIntent(t)
|
||||
texts := make([]string, 0, len(seeds))
|
||||
for text := range seeds {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
sort.Strings(texts)
|
||||
for _, text := range texts {
|
||||
if err := cls.AddExample(ctx, seeds[text], text); err != nil {
|
||||
t.Fatalf("seed %q: %v", text, err)
|
||||
}
|
||||
}
|
||||
return cls
|
||||
}
|
||||
|
||||
func seedsWithIntent(t *testing.T) map[string]router.Intent {
|
||||
t.Helper()
|
||||
files := readSeedFiles(t)
|
||||
out := map[string]router.Intent{}
|
||||
for _, intent := range seedIntents {
|
||||
for _, l := range files[intent] {
|
||||
if _, dup := out[l]; dup {
|
||||
continue
|
||||
}
|
||||
out[l] = intent
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readSeedFiles(t *testing.T) map[router.Intent][]string {
|
||||
t.Helper()
|
||||
out := map[router.Intent][]string{}
|
||||
for _, intent := range seedIntents {
|
||||
path := filepath.Join(seedDir, string(intent)+".txt")
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
sc := bufio.NewScanner(fh)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[intent] = append(out[intent], line)
|
||||
}
|
||||
err = sc.Err()
|
||||
fh.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package semantic
|
||||
|
||||
import "context"
|
||||
|
||||
// SemanticRouter — the interface behind which experiment models run. The
|
||||
// implementation decides whether to consume MatchText; the contract here is
|
||||
// that Route returns a coarse decision without touching the authoritative
|
||||
// router's Intent, slots, capability, or source.
|
||||
//
|
||||
// A nil implementation is a valid floor — the shadow harness records
|
||||
// "no model" and continues.
|
||||
type SemanticRouter interface {
|
||||
Route(ctx context.Context, text string) (SemanticRouteDecision, error)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package semantic
|
||||
|
||||
import "github.com/kami/maven/internal/router"
|
||||
|
||||
// IntentToRoute maps the current seven-intent Decision.Intent to a coarse
|
||||
// semantic route. The mapping is deterministic and exists only for
|
||||
// baseline/evaluation — it does not change current routing behaviour.
|
||||
//
|
||||
// Mapping:
|
||||
//
|
||||
// chat → conversation
|
||||
// query → knowledge
|
||||
// act → action
|
||||
// reminder → action
|
||||
// fact → memory_write
|
||||
// note → memory_write
|
||||
// system → system
|
||||
// unknown → uncertain
|
||||
func IntentToRoute(intent router.Intent) SemanticRoute {
|
||||
switch intent {
|
||||
case router.IntentChat:
|
||||
return RouteConversation
|
||||
case router.IntentQuery:
|
||||
return RouteKnowledge
|
||||
case router.IntentAct:
|
||||
return RouteAction
|
||||
case router.IntentReminder:
|
||||
return RouteAction
|
||||
case router.IntentFact:
|
||||
return RouteMemoryWrite
|
||||
case router.IntentNote:
|
||||
return RouteMemoryWrite
|
||||
case router.IntentSystem:
|
||||
return RouteSystem
|
||||
default:
|
||||
return RouteUncertain
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package semantic
|
||||
|
||||
// SemanticSeed is an intermediate specification whose label is known before
|
||||
// any surface text is generated. The important invariant is:
|
||||
//
|
||||
// semantic identity → deterministic surface generation
|
||||
//
|
||||
// NOT:
|
||||
//
|
||||
// sentence → model guesses label
|
||||
type SemanticSeed struct {
|
||||
// ID uniquely identifies this seed within the corpus factory.
|
||||
ID string `json:"id"`
|
||||
|
||||
// Route is the coarse semantic route this seed maps to.
|
||||
Route SemanticRoute `json:"route"`
|
||||
|
||||
// Family groups seeds that share the same semantic operation.
|
||||
// All surfaces derived from one seed share a SplitGroup.
|
||||
Family string `json:"family"`
|
||||
|
||||
// OperationID identifies the concrete operation (tool, capability, etc.)
|
||||
// that this seed represents. Empty for seeds without a concrete operation.
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
|
||||
// SplitGroup is the group ID for train/eval splitting.
|
||||
// All surfaces from one seed must share this.
|
||||
SplitGroup string `json:"split_group"`
|
||||
|
||||
// VerbForms are the imperative/stative verbs that express this seed's
|
||||
// action or query. For action seeds, these are the command verbs.
|
||||
// For knowledge seeds, these are the query-opening words.
|
||||
VerbForms []string `json:"verb_forms,omitempty"`
|
||||
|
||||
// Subjects are the targets/objects of the action or query.
|
||||
// For HA actions: entity names. For tools: service names.
|
||||
Subjects []string `json:"subjects,omitempty"`
|
||||
|
||||
// Objects are additional objects or arguments.
|
||||
Objects []string `json:"objects,omitempty"`
|
||||
|
||||
// Values are quantified values (durations, amounts, etc.)
|
||||
Values []string `json:"values,omitempty"`
|
||||
|
||||
// Tags are metadata tags applied to all generated surfaces from this seed.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceGenerator generates surface forms from a seed. Each generator
|
||||
// produces meaning-preserving forms that are less tied to the deterministic
|
||||
// command grammar, tagged by template category.
|
||||
type SurfaceGenerator struct {
|
||||
// Name identifies this generator for provenance tracking.
|
||||
Name string
|
||||
|
||||
// Fn generates surface forms from a seed.
|
||||
Fn func(seed SemanticSeed) []GeneratedSurface
|
||||
}
|
||||
|
||||
// GeneratedSurface is one surface form derived from a seed.
|
||||
type GeneratedSurface struct {
|
||||
Text string `json:"text"`
|
||||
// TemplateCategory identifies which template produced this surface.
|
||||
TemplateCategory string `json:"template_category"`
|
||||
// FastPathMatched is set after fast-path classification.
|
||||
FastPathMatched bool `json:"-"`
|
||||
// ExpectedRoute is the route this surface should map to.
|
||||
ExpectedRoute SemanticRoute `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// ShadowOutcome — one turn observed in shadow mode. Carries both the
|
||||
// authoritative legacy decision and the coarse-route experiment decision
|
||||
// for later analysis.
|
||||
type ShadowOutcome struct {
|
||||
Text string
|
||||
LegacyIntent router.Intent
|
||||
LegacyRoute SemanticRoute
|
||||
Experiment SemanticRouteDecision
|
||||
FastPathMatch bool
|
||||
Agree bool
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// ShadowHarness records turns where both the legacy router and the experiment
|
||||
// model produce a decision. No action, clarification, capability selection,
|
||||
// or reply depends on the shadow result.
|
||||
type ShadowHarness struct {
|
||||
model SemanticRouter
|
||||
outcome []ShadowOutcome
|
||||
}
|
||||
|
||||
// NewShadowHarness creates a harness that observes both paths without
|
||||
// affecting the authoritative route.
|
||||
func NewShadowHarness(model SemanticRouter) *ShadowHarness {
|
||||
return &ShadowHarness{model: model}
|
||||
}
|
||||
|
||||
// Observe records one turn. The authoritative decision comes from the
|
||||
// existing router; the experiment decision comes from the shadow model.
|
||||
// fastPathMatch is true when TryFastPath already resolved this turn.
|
||||
//
|
||||
// Observe never fails the turn — if the shadow model errors, the outcome
|
||||
// records RouteUncertain with the error in Experiment.
|
||||
func (h *ShadowHarness) Observe(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
legacy router.Decision,
|
||||
fastPathMatch bool,
|
||||
) ShadowOutcome {
|
||||
legacyRoute := IntentToRoute(legacy.Intent)
|
||||
|
||||
var exp SemanticRouteDecision
|
||||
if h.model != nil {
|
||||
d, err := h.model.Route(ctx, text)
|
||||
if err != nil {
|
||||
exp = SemanticRouteDecision{
|
||||
Route: RouteUncertain,
|
||||
Abstain: true,
|
||||
}
|
||||
} else {
|
||||
exp = d
|
||||
}
|
||||
}
|
||||
|
||||
o := ShadowOutcome{
|
||||
Text: text,
|
||||
LegacyIntent: legacy.Intent,
|
||||
LegacyRoute: legacyRoute,
|
||||
Experiment: exp,
|
||||
FastPathMatch: fastPathMatch,
|
||||
Agree: legacyRoute == exp.Route,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
h.outcome = append(h.outcome, o)
|
||||
return o
|
||||
}
|
||||
|
||||
// Outcomes returns a copy of all recorded shadow outcomes.
|
||||
func (h *ShadowHarness) Outcomes() []ShadowOutcome {
|
||||
out := make([]ShadowOutcome, len(h.outcome))
|
||||
copy(out, h.outcome)
|
||||
return out
|
||||
}
|
||||
|
||||
// ShadowReport — aggregate agreement/disagreement statistics.
|
||||
type ShadowReport struct {
|
||||
Total int
|
||||
Agree int
|
||||
Disagree int
|
||||
FastPathTotal int
|
||||
FastPathAgree int
|
||||
ResidualTotal int
|
||||
ResidualAgree int
|
||||
// DisagreeByRoute[want][got] counts disagreements by legacy route
|
||||
DisagreeByRoute map[SemanticRoute]map[SemanticRoute]int
|
||||
}
|
||||
|
||||
// Summarize produces aggregate stats from collected outcomes.
|
||||
func (h *ShadowHarness) Summarize() ShadowReport {
|
||||
rep := ShadowReport{
|
||||
DisagreeByRoute: make(map[SemanticRoute]map[SemanticRoute]int),
|
||||
}
|
||||
for _, r := range AllRoutes {
|
||||
rep.DisagreeByRoute[r] = make(map[SemanticRoute]int)
|
||||
}
|
||||
|
||||
for _, o := range h.outcome {
|
||||
rep.Total++
|
||||
if o.Agree {
|
||||
rep.Agree++
|
||||
} else {
|
||||
rep.Disagree++
|
||||
rep.DisagreeByRoute[o.LegacyRoute][o.Experiment.Route]++
|
||||
}
|
||||
if o.FastPathMatch {
|
||||
rep.FastPathTotal++
|
||||
if o.Agree {
|
||||
rep.FastPathAgree++
|
||||
}
|
||||
} else {
|
||||
rep.ResidualTotal++
|
||||
if o.Agree {
|
||||
rep.ResidualAgree++
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// String renders the shadow report.
|
||||
func (r ShadowReport) String() string {
|
||||
agreeRate := 0.0
|
||||
if r.Total > 0 {
|
||||
agreeRate = float64(r.Agree) / float64(r.Total)
|
||||
}
|
||||
resAgreeRate := 0.0
|
||||
if r.ResidualTotal > 0 {
|
||||
resAgreeRate = float64(r.ResidualAgree) / float64(r.ResidualTotal)
|
||||
}
|
||||
s := fmt.Sprintf("shadow: %d turns, %d agree (%.1f%%), %d disagree\n",
|
||||
r.Total, r.Agree, 100*agreeRate, r.Disagree)
|
||||
s += fmt.Sprintf(" fast-path: %d turns residual: %d turns (%.1f%% agree)\n",
|
||||
r.FastPathTotal, r.ResidualTotal, 100*resAgreeRate)
|
||||
if r.Disagree > 0 {
|
||||
s += " disagreements (legacy→experiment):\n"
|
||||
routes := make([]SemanticRoute, 0, len(r.DisagreeByRoute))
|
||||
for route := range r.DisagreeByRoute {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
for _, w := range routes {
|
||||
for _, g := range routes {
|
||||
if c := r.DisagreeByRoute[w][g]; c > 0 {
|
||||
s += fmt.Sprintf(" %s → %s ×%d\n", w, g, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package semantic
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SplitRatio controls the frozen holdout fraction of the total corpus.
|
||||
// 0.15 means 15% of families go to the frozen holdout, 85% to the
|
||||
// development pool.
|
||||
const DefaultFrozenRatio = 0.15
|
||||
|
||||
// SplitByFamily divides examples into train/eval splits such that all
|
||||
// paraphrases or contrastive variants of one seed stay in the same split.
|
||||
//
|
||||
// The algorithm is deterministic: each SplitGroup gets a hash, and the hash
|
||||
// bucket determines the split. This prevents the failure mode where
|
||||
// "выключи свет" lands in train and "пожалуйста выключи свет" lands in eval.
|
||||
//
|
||||
// splitRatio controls the train fraction (0.0–1.0). 0.8 means 80% train.
|
||||
func SplitByFamily(exs []RouteExample, splitRatio float64) (train, eval []RouteExample) {
|
||||
if splitRatio <= 0 || splitRatio >= 1 {
|
||||
splitRatio = 0.8
|
||||
}
|
||||
|
||||
families := groupByFamily(exs)
|
||||
|
||||
famKeys := sortedFamilyKeys(families)
|
||||
|
||||
for _, k := range famKeys {
|
||||
members := families[k]
|
||||
h := sha256.Sum256([]byte(k))
|
||||
bucket := float64(h[0]) / 256.0
|
||||
if bucket < splitRatio {
|
||||
train = append(train, members...)
|
||||
} else {
|
||||
eval = append(eval, members...)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// FrozenHoldoutSplit splits the corpus into a frozen holdout and a
|
||||
// development pool. The frozen holdout must never be used for weight fitting,
|
||||
// hyperparameter selection, threshold tuning, or generating near-duplicate
|
||||
// training examples.
|
||||
//
|
||||
// The split is deterministic and identified by FrozenHoldoutHash.
|
||||
func FrozenHoldoutSplit(exs []RouteExample) (frozen, development []RouteExample, holdoutHash string) {
|
||||
families := groupByFamily(exs)
|
||||
famKeys := sortedFamilyKeys(families)
|
||||
|
||||
// Use a dedicated hash seed for the frozen split so changing the
|
||||
// train/eval ratio does not move the holdout.
|
||||
const splitSeed = "semantic-router-frozen-v1"
|
||||
for _, k := range famKeys {
|
||||
members := families[k]
|
||||
h := sha256.Sum256([]byte(splitSeed + ":" + k))
|
||||
bucket := float64(h[0]) / 256.0
|
||||
if bucket < DefaultFrozenRatio {
|
||||
frozen = append(frozen, members...)
|
||||
} else {
|
||||
development = append(development, members...)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute holdout hash from the group IDs in the frozen set.
|
||||
groupIDs := make(map[string]bool)
|
||||
for _, e := range frozen {
|
||||
groupIDs[e.SplitGroup] = true
|
||||
}
|
||||
ids := make([]string, 0, len(groupIDs))
|
||||
for id := range groupIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
h := sha256.Sum256([]byte(hex.EncodeToString([]byte(strings.Join(ids, "|")))))
|
||||
holdoutHash = hex.EncodeToString(h[:16])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GroupedCVFolds splits a development pool into k folds, keeping all
|
||||
// examples with the same SplitGroup in one fold. Returns k folds; each fold
|
||||
// is the eval set, the rest are the training set.
|
||||
func GroupedCVFolds(exs []RouteExample, k int) []CVFold {
|
||||
if k <= 1 {
|
||||
k = 5
|
||||
}
|
||||
families := groupByFamily(exs)
|
||||
famKeys := sortedFamilyKeys(families)
|
||||
|
||||
// Assign families to folds round-robin for balance.
|
||||
foldFamilies := make([][]string, k)
|
||||
for i, key := range famKeys {
|
||||
foldFamilies[i%k] = append(foldFamilies[i%k], key)
|
||||
}
|
||||
|
||||
folds := make([]CVFold, k)
|
||||
for i := 0; i < k; i++ {
|
||||
evalGroups := make(map[string]bool)
|
||||
for _, g := range foldFamilies[i] {
|
||||
evalGroups[g] = true
|
||||
}
|
||||
var train, eval []RouteExample
|
||||
for _, e := range exs {
|
||||
if evalGroups[e.SplitGroup] {
|
||||
eval = append(eval, e)
|
||||
} else {
|
||||
train = append(train, e)
|
||||
}
|
||||
}
|
||||
folds[i] = CVFold{Fold: i, Train: train, Eval: eval}
|
||||
}
|
||||
return folds
|
||||
}
|
||||
|
||||
// CVFold — one fold of a grouped cross-validation split.
|
||||
type CVFold struct {
|
||||
Fold int
|
||||
Train []RouteExample
|
||||
Eval []RouteExample
|
||||
}
|
||||
|
||||
// FamilyID derives a stable family ID from a base source ID and a transform
|
||||
// name, ensuring contrastive variants share the base's family.
|
||||
func FamilyID(baseSourceID, transform string) string {
|
||||
if transform == "" {
|
||||
return baseSourceID
|
||||
}
|
||||
h := sha256.Sum256([]byte(baseSourceID + ":" + transform))
|
||||
return "family:" + hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// groupByFamily groups examples by SplitGroup.
|
||||
func groupByFamily(exs []RouteExample) map[string][]RouteExample {
|
||||
families := make(map[string][]RouteExample)
|
||||
for _, e := range exs {
|
||||
key := e.SplitGroup
|
||||
if key == "" {
|
||||
key = e.SourceID
|
||||
}
|
||||
families[key] = append(families[key], e)
|
||||
}
|
||||
return families
|
||||
}
|
||||
|
||||
// sortedFamilyKeys returns the sorted keys of a families map.
|
||||
func sortedFamilyKeys(families map[string][]RouteExample) []string {
|
||||
keys := make([]string, 0, len(families))
|
||||
for k := range families {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package semantic
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ContrastivePair — one base utterance and a derived transformation that
|
||||
// should land in a different (or same) coarse route. The transform tag
|
||||
// identifies the operation so the eval can report which transformations are
|
||||
// easy vs hard.
|
||||
type ContrastivePair struct {
|
||||
BaseID string `json:"base_id"`
|
||||
BaseText string `json:"base_text"`
|
||||
BaseRoute SemanticRoute `json:"base_route"`
|
||||
Transform string `json:"transform"`
|
||||
Text string `json:"text"`
|
||||
Route SemanticRoute `json:"route"`
|
||||
}
|
||||
|
||||
// ContrastiveTransform — a function that takes a base utterance and returns
|
||||
// a list of (text, expected_route) pairs. The mapping is deterministic and
|
||||
// does not consult any model.
|
||||
type ContrastiveTransform struct {
|
||||
Name string
|
||||
Fn func(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair
|
||||
}
|
||||
|
||||
// StandardTransforms is the ordered set of contrastive safety transformations
|
||||
// applied to the action-route seeds. Each base utterance gets all transforms;
|
||||
// the expected route depends on the transformation semantics and the current
|
||||
// routing contract.
|
||||
var StandardTransforms = []ContrastiveTransform{
|
||||
{Name: "negation", Fn: negationTransform},
|
||||
{Name: "question", Fn: questionTransform},
|
||||
{Name: "reported_speech", Fn: reportedSpeechTransform},
|
||||
{Name: "quotation", Fn: quotationTransform},
|
||||
{Name: "hypothetical", Fn: hypotheticalTransform},
|
||||
{Name: "capability_question", Fn: capabilityQuestionTransform},
|
||||
}
|
||||
|
||||
// negationTransform — turns an imperative into a negated one. A negated
|
||||
// command is not an executable action; current semantics route it to
|
||||
// uncertain.
|
||||
//
|
||||
// "выключи свет" → "не выключай свет" → uncertain
|
||||
func negationTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
// Only transform action-route examples: other routes are unaffected.
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
ruNegations := []struct{ prefix, suffix string }{
|
||||
{"не ", ""},
|
||||
{"ни ", ""},
|
||||
}
|
||||
var pairs []ContrastivePair
|
||||
for _, n := range ruNegations {
|
||||
text := n.prefix + baseText + n.suffix
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "negation",
|
||||
Text: text,
|
||||
Route: RouteUncertain,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// questionTransform — turns a statement into a question. A question about an
|
||||
// action is not the action itself; it becomes knowledge.
|
||||
//
|
||||
// "выключи свет" → "ты выключила свет?" → knowledge
|
||||
func questionTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
// Russian question forms for actions
|
||||
suffixes := []string{
|
||||
"?", // direct question
|
||||
", верно?", // tag question
|
||||
}
|
||||
var pairs []ContrastivePair
|
||||
for _, s := range suffixes {
|
||||
text := baseText + s
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "question",
|
||||
Text: text,
|
||||
Route: RouteKnowledge,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// reportedSpeechTransform — wraps the utterance in reported speech. An action
|
||||
// reported as speech is not an action.
|
||||
//
|
||||
// "выключи свет" → "он сказал выключи свет" → uncertain
|
||||
func reportedSpeechTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
prefixes := []string{
|
||||
"он сказал: ",
|
||||
"она сказала: ",
|
||||
"он сказал «",
|
||||
}
|
||||
suffixes := []string{
|
||||
"",
|
||||
"",
|
||||
"»",
|
||||
}
|
||||
var pairs []ContrastivePair
|
||||
for i, p := range prefixes {
|
||||
text := p + baseText + suffixes[i]
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "reported_speech",
|
||||
Text: text,
|
||||
Route: RouteUncertain,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// quotationTransform — mentions the utterance as a phrase/quote, not as a
|
||||
// command. Quoting an action is not doing it.
|
||||
//
|
||||
// "выключи свет" → "фраза «выключи свет»" → uncertain
|
||||
func quotationTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
patterns := []struct{ pre, post string }{
|
||||
{"фраза «", "»"},
|
||||
{"«", "»"},
|
||||
{"цитата: \"", "\""},
|
||||
}
|
||||
var pairs []ContrastivePair
|
||||
for _, p := range patterns {
|
||||
text := p.pre + baseText + p.post
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "quotation",
|
||||
Text: text,
|
||||
Route: RouteUncertain,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// hypotheticalTransform — puts the action in a hypothetical frame. An
|
||||
// "if..." clause is not an executable command.
|
||||
//
|
||||
// "выключи свет" → "если выключить свет..." → uncertain
|
||||
func hypotheticalTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
frames := []struct{ pre, post string }{
|
||||
{"если ", "..."},
|
||||
{"когда ", ", будет проще"},
|
||||
{"если бы я сказал: ", ", что бы ты сделала?"},
|
||||
}
|
||||
var pairs []ContrastivePair
|
||||
for _, f := range frames {
|
||||
text := f.pre + baseText + f.post
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "hypothetical",
|
||||
Text: text,
|
||||
Route: RouteUncertain,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// capabilityQuestionTransform — asks whether the system CAN do the action.
|
||||
// A capability question is knowledge, not a direct executable command.
|
||||
//
|
||||
// "выключи свет" → "ты можешь выключить свет?" → knowledge
|
||||
func capabilityQuestionTransform(baseID, baseText string, baseRoute SemanticRoute) []ContrastivePair {
|
||||
if baseRoute != RouteAction {
|
||||
return nil
|
||||
}
|
||||
templates := []string{
|
||||
"ты можешь %s?",
|
||||
"умеешь ли %s?",
|
||||
"способен ли ты %s?",
|
||||
}
|
||||
// Extract the verb phrase for templates that need infinitive.
|
||||
// For Russian, we use the base text as-is since the template
|
||||
// handles the grammar.
|
||||
var pairs []ContrastivePair
|
||||
for _, t := range templates {
|
||||
text := fmt.Sprintf(t, baseText)
|
||||
pairs = append(pairs, ContrastivePair{
|
||||
BaseID: baseID,
|
||||
BaseText: baseText,
|
||||
BaseRoute: baseRoute,
|
||||
Transform: "capability_question",
|
||||
Text: text,
|
||||
Route: RouteKnowledge,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// GenerateContrastivePairs applies all standard transforms to a slice of
|
||||
// base examples and returns the full set of contrastive pairs.
|
||||
func GenerateContrastivePairs(bases []RouteExample) []ContrastivePair {
|
||||
var all []ContrastivePair
|
||||
for _, b := range bases {
|
||||
for _, t := range StandardTransforms {
|
||||
all = append(all, t.Fn(b.SourceID, b.Text, b.Route)...)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package semantic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNegationTransform(t *testing.T) {
|
||||
pairs := negationTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("negationTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteUncertain {
|
||||
t.Errorf("negation of %q: got route %q, want uncertain", p.BaseText, p.Route)
|
||||
}
|
||||
if p.Transform != "negation" {
|
||||
t.Errorf("transform name = %q, want negation", p.Transform)
|
||||
}
|
||||
}
|
||||
pairs = negationTransform("ru-chat-001", "привет", RouteConversation)
|
||||
if len(pairs) != 0 {
|
||||
t.Error("negationTransform produced pairs for non-action route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionTransform(t *testing.T) {
|
||||
pairs := questionTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("questionTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteKnowledge {
|
||||
t.Errorf("question of %q: got route %q, want knowledge", p.BaseText, p.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportedSpeechTransform(t *testing.T) {
|
||||
pairs := reportedSpeechTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("reportedSpeechTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteUncertain {
|
||||
t.Errorf("reported speech of %q: got route %q, want uncertain", p.BaseText, p.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotationTransform(t *testing.T) {
|
||||
pairs := quotationTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("quotationTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteUncertain {
|
||||
t.Errorf("quotation of %q: got route %q, want uncertain", p.BaseText, p.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHypotheticalTransform(t *testing.T) {
|
||||
pairs := hypotheticalTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("hypotheticalTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteUncertain {
|
||||
t.Errorf("hypothetical of %q: got route %q, want uncertain", p.BaseText, p.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityQuestionTransform(t *testing.T) {
|
||||
pairs := capabilityQuestionTransform("ru-act-002", "выключи свет в спальне", RouteAction)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("capabilityQuestionTransform returned no pairs")
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Route != RouteKnowledge {
|
||||
t.Errorf("capability question of %q: got route %q, want knowledge", p.BaseText, p.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContrastivePairs(t *testing.T) {
|
||||
bases := []RouteExample{
|
||||
{SourceID: "ru-act-002", Text: "выключи свет в спальне", Route: RouteAction},
|
||||
{SourceID: "ru-act-001", Text: "перезапусти докер", Route: RouteAction},
|
||||
}
|
||||
pairs := GenerateContrastivePairs(bases)
|
||||
if len(pairs) == 0 {
|
||||
t.Fatal("GenerateContrastivePairs returned no pairs")
|
||||
}
|
||||
transformCounts := map[string]int{}
|
||||
for _, p := range pairs {
|
||||
transformCounts[p.Transform]++
|
||||
}
|
||||
for _, tr := range StandardTransforms {
|
||||
if c := transformCounts[tr.Name]; c == 0 {
|
||||
t.Errorf("no pairs for transform %q", tr.Name)
|
||||
}
|
||||
}
|
||||
t.Logf("generated %d contrastive pairs from %d bases", len(pairs), len(bases))
|
||||
}
|
||||
@@ -92,10 +92,12 @@ const (
|
||||
)
|
||||
|
||||
// NormalizedInput — the typed ingress boundary for a turn. Text is the raw
|
||||
// utterance after STT (voice) or as typed (text). Source identifies the
|
||||
// channel. This slice performs no new linguistic normalization: text and voice
|
||||
// paths continue to converge onto the same turn path as they did before.
|
||||
// utterance after STT (voice) or as typed (text). MatchText is a lossy
|
||||
// lexical matching view derived from Text: whitespace-collapsed, NFKC-
|
||||
// normalized, lowercased. Consumers must opt into MatchText individually;
|
||||
// nothing reads it by default in this slice. Source identifies the channel.
|
||||
type NormalizedInput struct {
|
||||
Text string
|
||||
Source InputSource
|
||||
Text string // original ingress text — byte-for-byte the value current consumers receive
|
||||
MatchText string // derived lossy representation for case-insensitive lexical matching
|
||||
Source InputSource
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user