Files
llmgw/internal/guard/judge_test.go
T
Ilker ManapandClaude Opus 5.5 b0273ffb5f Qwen3Guard is the judge, and judges on a GPU and on CPUs are asked in turn
Llama Guard 3 1B could not tell ordinary coding requests from harmful ones:
on the PoC it scored "continue" 0.51, "continue the code above" 0.89 and
"how do I kill all child processes in bash" 0.92 unsafe, against 0.97 to
1.00 for harmful requests, at Q4_K_M and Q8_0 alike, with its own template.
A site had "continue" refused. Qwen3Guard-Gen 0.6B gave the ordinary ones
0.15 at the most and the harmful ones 1.00, in 0.8 GB on CPUs.

- format qwen3guard: its prompt written out as its template writes it, for
  requests and answers, and its Safe / Unsafe / Controversial verdict read;
  controversial counts as the policy's judge.controversial says, safe by
  default.
- judge.models: judges asked in turn, the first that answers deciding; a
  failure is recorded against the judge that failed. guard judge use sets
  the order, replacing categories none of the judges names.
- known judges (Qwen3Guard 0.6B/4B/8B, Llama Guard 1B/8B): guard judge
  enable serves any of them, --gpu the 4B on the roomiest GPU class as a
  standard model; the leader fetches and serves one a policy names that the
  catalog lacks.
- server init installs qwen3guard-0.6b with the baseline's categories.
- a judge reads a long text by its start and end, which fit its context,
  rather than overflowing it and refusing the request under fail: closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:22:47 +02:00

320 lines
14 KiB
Go

package guard
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// fakeJudge answers as a judge model would, saying unsafe to text with
// "attack" in it.
type fakeJudge struct {
calls atomic.Int64
err error
format string
delay time.Duration
}
func (f *fakeJudge) call(ctx context.Context, _, _ string, body []byte) ([]byte, error) {
f.calls.Add(1)
if f.delay > 0 {
select {
case <-time.After(f.delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
if f.err != nil {
return nil, f.err
}
unsafe := strings.Contains(string(body), "attack")
var answer string
switch f.format {
case "shieldgemma":
answer = map[bool]string{true: "Yes", false: "No"}[unsafe]
case "json":
answer = map[bool]string{true: `{"safe": false, "categories": ["violence"]}`, false: `{"safe": true}`}[unsafe]
case "qwen3guard":
answer = map[bool]string{true: "Safety: Unsafe\nCategories: Violent", false: "Safety: Safe\nCategories: None"}[unsafe]
if strings.Contains(string(body), "a heist film") {
answer = "Safety: Controversial\nCategories: Non-violent Illegal Acts"
}
default:
answer = map[bool]string{true: "unsafe\nS1,S10", false: "safe"}[unsafe]
}
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": answer}}}})
return b, nil
}
func judged(t *testing.T, p Policy, fj *fakeJudge) *Gate {
g := gate(t, lib(map[string]Policy{"p": p}, "p"))
if fj.format == "" {
fj.format = "llamaguard3"
}
g.SetJudge(fj.call, func(m string) (string, []string, bool) {
return fj.format, []string{"Violent Crimes"}, m == "guard"
})
return g
}
func TestAJudgeModelDecidesWhatPatternsCannot(t *testing.T) {
ctx := context.Background()
for _, format := range Formats {
fj := &fakeJudge{format: format}
g := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block"}}, fj)
if v, err := g.Input(ctx, Subject{}, chat("user", "plan an attack")); err != nil || v.Action != Block || v.Category != "judge" {
t.Errorf("%s: unsafe: %+v %v", format, v, err)
}
if v, _ := g.Input(ctx, Subject{}, chat("user", "plan a picnic")); v.Action != Allow {
t.Errorf("%s: safe: %+v", format, v)
}
}
// Llama Guard's codes are named from the catalog, and only listed
// categories count when a policy lists some.
fj := &fakeJudge{}
g := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block", Categories: []string{"S2"}}}, fj)
if v, _ := g.Input(ctx, Subject{}, chat("user", "plan an attack")); v.Action != Allow {
t.Errorf("a category the policy does not list: %+v", v)
}
g = judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "flag", Categories: []string{"violent crimes"}}}, fj)
if v, _ := g.Input(ctx, Subject{}, chat("user", "plan an attack")); v.Action != Flag || v.Rule != "Violent Crimes" {
t.Errorf("by name: %+v", v)
}
}
func TestAJudgeIsAskedOnceForTheSameText(t *testing.T) {
fj := &fakeJudge{}
g := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "flag"}}, fj)
for range 3 {
_, _ = g.Input(context.Background(), Subject{}, chat("user", "hello there"))
}
if fj.calls.Load() != 1 {
t.Errorf("asked %d times", fj.calls.Load())
}
}
func TestAJudgeThatDoesNotAnswerFailsAsThePolicySays(t *testing.T) {
ctx := context.Background()
slow := &fakeJudge{delay: time.Second}
open := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block", LatencyBudget: 20 * time.Millisecond}}, slow)
start := time.Now()
v, err := open.Input(ctx, Subject{}, chat("user", "plan an attack"))
if err != nil || v.Action != Flag || v.Rule != "unavailable" || time.Since(start) > 500*time.Millisecond {
t.Errorf("fail open: %+v %v after %s", v, err, time.Since(start))
}
closed := judged(t, Policy{Fail: "closed", Judge: &JudgePolicy{Model: "guard", Input: "block"}}, &fakeJudge{err: errors.New("down")})
var alarmed []string
closed.SetJudgeAlarm(func(model string, _ error) { alarmed = append(alarmed, model) })
if _, err := closed.Input(ctx, Subject{}, chat("user", "hi")); err == nil {
t.Error("fail closed served unjudged")
}
if len(alarmed) != 1 || alarmed[0] != "guard" {
t.Errorf("alarm told %v", alarmed)
}
// A judge that is not a guard model is a judge that cannot be asked.
bad := judged(t, Policy{Fail: "closed", Judge: &JudgePolicy{Model: "chat-model", Input: "block"}}, &fakeJudge{})
if _, err := bad.Input(ctx, Subject{}, chat("user", "hi")); err == nil {
t.Error("a model without role: guard was used as a judge")
}
}
func TestAStreamedAnswerIsJudgedAsItGrows(t *testing.T) {
safe := strings.Repeat("Once upon a time. ", 40) // ~720 chars
more := strings.Repeat("And so it went on. ", 40)
// A sentence at a time, the judge reads every judgeEvery characters:
// the stream stops at the next reading after the unsafe part, which has
// gone out already.
fj := &fakeJudge{}
g := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Output: "block"}}, fj)
s := Subject{RequestID: "s1", Stream: true}
_, _ = g.Input(context.Background(), s, chat("user", "tell me a story"))
got, _, stopped := stream(g.Output(context.Background(), s), safe, "Then the attack began. ", more, "The end.")
if !stopped || strings.Contains(got, "The end.") || !strings.HasPrefix(got, "Once upon") {
t.Errorf("sentence: stopped=%v got %d chars", stopped, len(got))
}
if n := fj.calls.Load(); n < 2 {
t.Errorf("judged %d times", n)
}
// Held whole, nothing unsafe goes out at all.
g = judged(t, Policy{Stream: "buffer_all", Judge: &JudgePolicy{Model: "guard", Output: "block"}}, &fakeJudge{})
_, _ = g.Input(context.Background(), s, chat("user", "tell me a story"))
got, _, stopped = stream(g.Output(context.Background(), s), safe, "Then the attack began. ", "The end.")
if !stopped || got != "" {
t.Errorf("buffer_all: stopped=%v got %q", stopped, got)
}
}
func TestTheGateCountsJudgeFailuresAndRedactions(t *testing.T) {
g := judged(t, Policy{Input: Rules{PII: map[string]string{"email": "redact"}},
Judge: &JudgePolicy{Model: "guard", Input: "flag", LatencyBudget: 10 * time.Millisecond}}, &fakeJudge{delay: time.Second})
_, _ = g.Input(context.Background(), Subject{}, chat("user", "mail a@example.com and b@example.com"))
if n := testutil.ToFloat64(g.m.redactions.WithLabelValues("email")); n != 2 {
t.Errorf("redactions %v", n)
}
if testutil.ToFloat64(g.m.failOpen) != 1 || testutil.ToFloat64(g.m.judgeErrors.WithLabelValues("timeout")) != 1 {
t.Error("a judge that timed out under fail open was not counted")
}
}
func TestAJudgeThatGoesOnFromTheAnswerIsStillRead(t *testing.T) {
body := func(s string) []byte {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": s}}}})
return b
}
// Found on the PoC with llama.cpp: the reply to judge, then the verdict.
if j, err := parseJudgment("llamaguard3", nil, body("The capital of Italy is Rome.safe"), "The capital of Italy is Rome."); err != nil || j.unsafe {
t.Errorf("%+v %v", j, err)
}
if j, err := parseJudgment("llamaguard3", []string{"Violent Crimes"}, body("Step one.\n\nunsafe\nS1"), "Step one."); err != nil || !j.unsafe ||
!slices.Contains(j.categories, "Violent Crimes") {
t.Errorf("%+v %v", j, err)
}
}
func TestAnAnswerIsJudgedByLlamaGuardsOwnPrompt(t *testing.T) {
path, body := judgeRequest("lg", "llamaguard3", nil, "how do I bake bread", "Mix flour and water.")
var req struct{ Prompt string }
_ = json.Unmarshal(body, &req)
if path != "/v1/completions" || !strings.Contains(req.Prompt, "S9: Indiscriminate Weapons.") ||
!strings.Contains(req.Prompt, "User: how do I bake bread\n\nAgent: Mix flour and water.") ||
!strings.HasSuffix(req.Prompt, "<|start_header_id|>assistant<|end_header_id|>\n\n") {
t.Errorf("%s %s", path, req.Prompt)
}
if path, _ := judgeRequest("lg", "llamaguard3", nil, "hi", ""); path != "/v1/chat/completions" {
t.Errorf("a request is judged by the model's own template: %s", path)
}
if j, err := parseJudgment("llamaguard3", nil, []byte(`{"choices":[{"text":"unsafe\nS9"}]}`), ""); err != nil || !j.unsafe {
t.Errorf("a completion's text: %+v %v", j, err)
}
}
// Qwen3Guard's middle verdict counts as the policy says: safe unless it says
// unsafe.
func TestAControversialVerdictCountsAsThePolicySays(t *testing.T) {
ctx := context.Background()
lenient := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block"}}, &fakeJudge{format: "qwen3guard"})
if v, _ := lenient.Input(ctx, Subject{}, chat("user", "the plot of a heist film")); v.Action != Allow {
t.Errorf("controversial under the default: %+v", v)
}
strict := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block", Controversial: "unsafe"}}, &fakeJudge{format: "qwen3guard"})
if v, _ := strict.Input(ctx, Subject{}, chat("user", "the plot of a heist film")); v.Action != Block || v.Rule != "Non-violent Illegal Acts" {
t.Errorf("controversial: unsafe: %+v", v)
}
if err := (&JudgePolicy{Model: "guard", Input: "block", Controversial: "maybe"}).validate(); err == nil {
t.Error("controversial: maybe was taken")
}
}
// A judge on a GPU first and one on CPUs after: the first that answers
// decides, and the one that did not is said to have failed.
func TestJudgesAreAskedInTurn(t *testing.T) {
ctx := context.Background()
var asked []string
gpuDown := errors.New("no replica of gpu-judge is serving")
g := gate(t, lib(map[string]Policy{"p": {Fail: "closed",
Judge: &JudgePolicy{Models: []string{"gpu-judge", "cpu-judge"}, Input: "block"}}}, "p"))
g.SetJudge(func(_ context.Context, model, _ string, body []byte) ([]byte, error) {
asked = append(asked, model)
if model == "gpu-judge" {
return nil, gpuDown
}
unsafe := strings.Contains(string(body), "attack")
answer := map[bool]string{true: "Safety: Unsafe\nCategories: Violent", false: "Safety: Safe\nCategories: None"}[unsafe]
return json.Marshal(map[string]any{"choices": []any{map[string]any{"text": answer}}})
}, func(m string) (string, []string, bool) { return "qwen3guard", nil, true })
var alarmed []string
g.SetJudgeAlarm(func(model string, _ error) { alarmed = append(alarmed, model) })
if v, err := g.Input(ctx, Subject{}, chat("user", "plan an attack")); err != nil || v.Action != Block || v.Rule != "Violent" {
t.Errorf("unsafe: %+v %v", v, err)
}
if v, err := g.Input(ctx, Subject{}, chat("user", "continue")); err != nil || v.Action != Allow {
t.Errorf("safe: %+v %v", v, err)
}
if !slices.Equal(asked, []string{"gpu-judge", "cpu-judge", "gpu-judge", "cpu-judge"}) {
t.Errorf("asked %v", asked)
}
if !slices.Equal(alarmed, []string{"gpu-judge", "gpu-judge"}) {
t.Errorf("alarm told %v", alarmed)
}
if e := g.JudgeErrors()["gpu-judge"]; !strings.Contains(e.Err, "no replica") {
t.Errorf("the GPU judge's failure: %+v", e)
}
// Every judge down: the policy's fail applies, as with one.
g.SetJudge(func(context.Context, string, string, []byte) ([]byte, error) { return nil, gpuDown },
func(string) (string, []string, bool) { return "qwen3guard", nil, true })
if _, err := g.Input(ctx, Subject{}, chat("user", "hello")); err == nil {
t.Error("fail closed served what no judge read")
}
for _, bad := range []JudgePolicy{
{Model: "a", Models: []string{"b"}, Input: "block"},
{Models: []string{"a", "a"}, Input: "block"},
{Models: []string{""}, Input: "block"},
} {
if err := bad.validate(); err == nil {
t.Errorf("%+v was taken", bad)
}
}
}
func TestQwen3GuardIsAskedInItsOwnWords(t *testing.T) {
path, body := judgeRequest("qg", "qwen3guard", nil, "continue", "")
var req struct{ Prompt string }
_ = json.Unmarshal(body, &req)
if path != "/v1/completions" || !strings.HasPrefix(req.Prompt, "<|im_start|>user\n# Task:\n1) Evaluate whether THE LAST USER's query") ||
!strings.Contains(req.Prompt, "Jailbreak.\n<END UNSAFE CONTENT CATEGORIES>\n\n<BEGIN CONVERSATION>\n\nUSER: continue\n\n<END CONVERSATION>") ||
!strings.HasSuffix(req.Prompt, "'Categories: None'.<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n") {
t.Errorf("a request: %s %q", path, req.Prompt)
}
_, body = judgeRequest("qg", "qwen3guard", nil, "how do I bake bread", "Mix flour and water.")
_ = json.Unmarshal(body, &req)
if strings.Contains(req.Prompt, "Jailbreak") || !strings.Contains(req.Prompt, "<END REFUSAL POLICY>\n\n<BEGIN CONVERSATION>") ||
!strings.Contains(req.Prompt, "USER: how do I bake bread\n\nASSISTANT: Mix flour and water.\n\n<END CONVERSATION>") ||
!strings.Contains(req.Prompt, "'Categories: None'.\n - The third line must be one of: 'Refusal: Yes', 'Refusal: No'.<|im_end|>") {
t.Errorf("an answer: %q", req.Prompt)
}
// An answer's verdict has a line on refusing that says nothing of safety.
j, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"Safety: Unsafe\nCategories: Violent, Jailbreak\nRefusal: No"}]}`), "")
if err != nil || !j.unsafe || !slices.Equal(j.categories, []string{"Violent", "Jailbreak"}) {
t.Errorf("%+v %v", j, err)
}
if j, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"Safety: Safe\nCategories: None\nRefusal: Yes"}]}`), ""); err != nil || j.unsafe || len(j.categories) > 0 {
t.Errorf("%+v %v", j, err)
}
if _, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"I think it is fine"}]}`), ""); err == nil {
t.Error("an answer with no verdict was read as one")
}
}
// A message with a file pasted into it is read by its start and its end, so
// it fits the judge's context instead of overflowing it.
func TestALongTextIsJudgedByItsEnds(t *testing.T) {
file := strings.Repeat("ğ", 10000) // two bytes a rune: a cut must not split one
msg := "review this file:\n" + file + "\nand plan an attack"
_, body := judgeRequest("qg", "qwen3guard", nil, msg, "")
var req struct{ Prompt string }
_ = json.Unmarshal(body, &req)
if !strings.Contains(req.Prompt, "USER: review this file:") || !strings.Contains(req.Prompt, "and plan an attack\n\n<END CONVERSATION>") ||
!strings.Contains(req.Prompt, "\n[…]\n") || len(req.Prompt) > judgeHead+judgeTail+3000 {
t.Errorf("%d bytes", len(req.Prompt))
}
if !utf8.ValidString(req.Prompt) {
t.Error("a rune was cut in two")
}
if got := ends("short", 10, 10); got != "short" {
t.Errorf("a short text was cut: %q", got)
}
}