576dfd8b4c
V-405 measured reach with the classifier only, and the LLM router is the deployed default, so 16/30 was the floor rather than the shipped behaviour. TestReachWithLLMRouter scores the same 30 cases with the model, gated on MAVEN_LLM_URL like TestLLMRouterBaseline. The open question was whether the model writes a literal Praxis capability into the fn slot and reaches a service the classifier structurally cannot. It does not. Praxis is 0/12 with the model alone, exactly what the classifier alone scores, and all twelve fail the same way: local, empty fn. Nothing in the router prompt names a Praxis capability, so there is no string for it to write. So V-516's stage-0 grammars are the only path to Praxis, not a determinism argument. Through the cascade the model scores 28/30 with praxis 11/12, one point above the classifier baseline. Hexis is 10/10 either way. Overreach is 1 in both configurations, under the 4 the harness asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
216 lines
8.0 KiB
Go
216 lines
8.0 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/llm"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// TestLLMRouterBaseline — the other half of Vikunja #319: the resident model as
|
|
// route decider, scored on the same 76 held-out cases as the classifier, so the
|
|
// #320 flip is a comparison and not a preference.
|
|
//
|
|
// Opt-in against a running llama-server:
|
|
//
|
|
// llama-server -m /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf \
|
|
// --host 127.0.0.1 --port 18099 -c 2048 -ngl 99
|
|
// MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-models
|
|
//
|
|
// Every report name carries the model llama-server reports over /v1/models, so
|
|
// a bake-off across checkpoints (#278, #250) produces tables you can tell
|
|
// apart. Point the variable at one server at a time.
|
|
//
|
|
// Two configurations, because "the LLM router" is ambiguous and the two numbers
|
|
// answer different questions:
|
|
//
|
|
// llm-only — the model alone. Measures the prompt + grammar contract.
|
|
// cascade+llm — what #320 would actually ship: stage-0 grammar, then the
|
|
// model, then the classifier as the failure floor.
|
|
//
|
|
// There used to be a third, "thinking off", which looked 6 points better. It is
|
|
// gone: it was measured with a hand-rolled HTTP client that quietly dropped
|
|
// repeat_penalty, so the gap was the missing penalty and not the thinking mode.
|
|
// Re-measured with everything else held equal, thinking off scores exactly the
|
|
// same, case for case — and a direct probe shows this llama-server build ignores
|
|
// enable_thinking / reasoning_budget for this model anyway, so there was nothing
|
|
// to turn off. Full write-up in docs/evals/2026-07-31-routing.md (Vikunja #376).
|
|
func TestLLMRouterBaseline(t *testing.T) {
|
|
base := os.Getenv("MAVEN_LLM_URL")
|
|
if base == "" {
|
|
t.Skip("MAVEN_LLM_URL unset — start llama-server and point it here (see doc comment)")
|
|
}
|
|
// A local llama-server must not go through an HTTP proxy. This box has
|
|
// http_proxy pointing at a SOCKS bridge that answers 503 for the loopback
|
|
// address, which would score every case as a route error and read as "the
|
|
// model can't route" — measure the model, not the proxy.
|
|
noProxyLoopback(t)
|
|
|
|
f, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
client := llm.New(base, 60*time.Second)
|
|
if err := ping(context.Background(), client); err != nil {
|
|
t.Skipf("llama-server at %s unreachable: %v", base, err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
model, err := llm.ModelID(ctx, base)
|
|
if err != nil {
|
|
// Not fatal: an unlabelled score is still a score. But say so loudly,
|
|
// because an unlabelled row in a bake-off table is worthless.
|
|
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
|
|
model = llm.UnknownModel
|
|
}
|
|
t.Logf("scoring model %s at %s", model, base)
|
|
lr := router.NewLLMRouter(client)
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return d, err
|
|
}
|
|
if !ok {
|
|
return d, fmt.Errorf("llm router declined without an error")
|
|
}
|
|
return d, nil
|
|
})
|
|
repLLM, err := Score(ctx, "llm-only ("+model+", as deployed)", llmOnly, f)
|
|
if err != nil {
|
|
t.Fatalf("Score llm-only: %v", err)
|
|
}
|
|
t.Log("\n" + repLLM.String() + repLLM.Failures())
|
|
|
|
// cascade+llm: stage-0 grammar → LLM → classifier fallback, the wiring #320
|
|
// proposes. Hash embedder for the fallback so the classifier contribution is
|
|
// the deterministic floor and any lift is attributable to the model.
|
|
repCascade, err := Score(ctx, "cascade+llm ("+model+") + hash fallback",
|
|
newBaselineRouter(t, router.NewHashEmbedder(1024), lr), f)
|
|
if err != nil {
|
|
t.Fatalf("Score cascade: %v", err)
|
|
}
|
|
t.Log("\n" + repCascade.String() + repCascade.Failures())
|
|
|
|
// Reports rather than asserts — the numbers are inputs to the #320
|
|
// decision, and an assertion here would be this test inventing the bar.
|
|
// The one thing worth failing on is a harness fault: if every single case
|
|
// errors, the run measured infrastructure, not routing, and the report
|
|
// must not be mistaken for a score.
|
|
for _, rep := range []Report{repLLM, repCascade} {
|
|
if rep.Errors == rep.Total {
|
|
t.Errorf("%s: all %d cases errored — harness fault, not a measurement", rep.Name, rep.Total)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReachWithLLMRouter — the reach fixture scored with the resident model as
|
|
// router (Vikunja #517). V-405 measured the classifier only, and the LLM router
|
|
// is the deployed default, so 16/30 with praxis 0/12 is the floor rather than
|
|
// the shipped behaviour.
|
|
//
|
|
// The question is specific. The route grammar lets the model write any string
|
|
// into the fn slot, so it *could* emit a literal Praxis capability name and
|
|
// reach a service the classifier structurally cannot. If it does, V-516's
|
|
// stage-0 Praxis grammars are a determinism argument. If it does not, they are
|
|
// the only path.
|
|
//
|
|
// Same gate and same two configurations as TestLLMRouterBaseline, for the same
|
|
// reason: "the LLM router" alone and the cascade that actually ships answer
|
|
// different questions.
|
|
func TestReachWithLLMRouter(t *testing.T) {
|
|
base := os.Getenv("MAVEN_LLM_URL")
|
|
if base == "" {
|
|
t.Skip("MAVEN_LLM_URL unset — start llama-server and point it here (see doc comment)")
|
|
}
|
|
noProxyLoopback(t)
|
|
|
|
f, err := LoadReach()
|
|
if err != nil {
|
|
t.Fatalf("LoadReach: %v", err)
|
|
}
|
|
client := llm.New(base, 60*time.Second)
|
|
if err := ping(context.Background(), client); err != nil {
|
|
t.Skipf("llama-server at %s unreachable: %v", base, err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
model, err := llm.ModelID(ctx, base)
|
|
if err != nil {
|
|
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
|
|
model = llm.UnknownModel
|
|
}
|
|
t.Logf("scoring reach with model %s at %s", model, base)
|
|
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)
|
|
if err != nil {
|
|
return d, err
|
|
}
|
|
if !ok {
|
|
return d, fmt.Errorf("llm router declined without an error")
|
|
}
|
|
return d, nil
|
|
})
|
|
repLLM, err := ScoreReach(ctx, "reach: llm-only ("+model+")", llmOnly, m, f)
|
|
if err != nil {
|
|
t.Fatalf("ScoreReach llm-only: %v", err)
|
|
}
|
|
t.Log("\n" + repLLM.String() + repLLM.Failures())
|
|
|
|
// Hash embedder for the classifier floor, so any lift is the model's and
|
|
// not the embedder's — the same control TestLLMRouterBaseline uses.
|
|
repCascade, err := ScoreReach(ctx, "reach: cascade+llm ("+model+") + hash fallback",
|
|
newBaselineRouter(t, router.NewHashEmbedder(1024), lr), m, f)
|
|
if err != nil {
|
|
t.Fatalf("ScoreReach cascade: %v", err)
|
|
}
|
|
t.Log("\n" + repCascade.String() + repCascade.Failures())
|
|
|
|
for _, rep := range []ReachReport{repLLM, repCascade} {
|
|
if rep.Errors == rep.Total {
|
|
t.Errorf("%s: all %d cases errored — harness fault, not a measurement", rep.Name, rep.Total)
|
|
}
|
|
}
|
|
// Overreach is the one direction worth failing on, for the reason
|
|
// TestReachBaselineHash gives: he never gets asked about it.
|
|
if repCascade.Overreach > 4 {
|
|
t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
|
|
repCascade.Overreach, repCascade.Failures())
|
|
}
|
|
}
|
|
|
|
func ping(ctx context.Context, c *llm.Client) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer cancel()
|
|
_, err := c.Complete(ctx, llm.Req{User: "ping", MaxTokens: 4})
|
|
return err
|
|
}
|
|
|
|
// noProxyLoopback appends the loopback host to no_proxy before any request, so
|
|
// http.ProxyFromEnvironment (which caches the environment on first use) sees it.
|
|
func noProxyLoopback(t *testing.T) {
|
|
t.Helper()
|
|
for _, key := range []string{"no_proxy", "NO_PROXY"} {
|
|
cur := os.Getenv(key)
|
|
if strings.Contains(cur, "127.0.0.1") {
|
|
continue
|
|
}
|
|
if cur == "" {
|
|
t.Setenv(key, "127.0.0.1,localhost")
|
|
continue
|
|
}
|
|
t.Setenv(key, cur+",127.0.0.1,localhost")
|
|
}
|
|
}
|