ci / vet-and-test (pull_request) Successful in 42s
Why: Q3 WP5 requires a same-batch dual-run harness (direct vs squad compiled graph) that records differences without demanding byte-identical output, feeding DecideSwitch evidence for the runtime.entry default-switch decision. Evidence: eval/ab_test.go mechanics (theme-based scoring, status/theme/ latency diff notes, P50, DecideSwitch wiring, RunFunc error tolerance, empty batch); wfcompile TestABDemoBriefDualRun runs the demo.brief scenario on both entries (direct = scripted single-agent answer; squad = real RunCompiled with stub backend and judge) and logs the report: both sides 100/100, summary-form difference recorded, decision '切换条件已满足…合入前 需要产品确认'. Full backend suite green (SELF); CI authoritative (GATE). Impact: new eval/ab.go harness (pure mechanics, no runtime dependency); no product default is flipped - runtime.entry stays direct until product confirmation per the DecideSwitch report semantics (evidence recorded in the task card and PR). Refs: Q3 plan WP5 (2026-10-07-agent-stack-q2q3-plans.md); task card .task/feature-agent-stack-q3.md
125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
package eval
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ab_test.go — Q3 WP5:A/B 双跑 harness 机制面(差异记录 / 打分 / P50 /
|
||
// DecideSwitch 联动)。真实入口的集成对照在 internal/wfcompile 的
|
||
// demo.brief 双跑测试。
|
||
|
||
func stubRun(completed bool, summary string, ft time.Duration, errText string) RunFunc {
|
||
return func(context.Context, ABTask) (ABOutcome, error) {
|
||
return ABOutcome{Completed: completed, Summary: summary, FirstToken: ft, Err: errText}, nil
|
||
}
|
||
}
|
||
|
||
func TestRunABRecordsDiffsAndScores(t *testing.T) {
|
||
tasks := []ABTask{
|
||
{ID: "t1", Goal: "写简报", Themes: []string{"太空电梯", "核对标记"}},
|
||
{ID: "t2", Goal: "查资料", Themes: []string{"三来源"}},
|
||
}
|
||
direct := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||
switch task.ID {
|
||
case "t1":
|
||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报,核对标记:P4", FirstToken: 100 * time.Millisecond}, nil
|
||
default:
|
||
return ABOutcome{Completed: true, Summary: "对照完成,三来源齐", FirstToken: 200 * time.Millisecond}, nil
|
||
}
|
||
}
|
||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||
switch task.ID {
|
||
case "t1":
|
||
// 完成但缺一个要点 → 扣分 + 差异注记。
|
||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报", FirstToken: 100 * time.Millisecond}, nil
|
||
default:
|
||
return ABOutcome{Completed: false, Err: "编译图步骤失败", FirstToken: 0}, nil
|
||
}
|
||
}
|
||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||
if len(rep.Records) != 2 {
|
||
t.Fatalf("records = %d", len(rep.Records))
|
||
}
|
||
r1 := rep.Records[0]
|
||
if r1.DirectPoints != 100 || r1.SquadPoints != 50 {
|
||
t.Fatalf("t1 points = %d/%d", r1.DirectPoints, r1.SquadPoints)
|
||
}
|
||
if !strings.Contains(strings.Join(r1.Diffs, ";"), "要点缺失") {
|
||
t.Fatalf("t1 diffs = %v", r1.Diffs)
|
||
}
|
||
r2 := rep.Records[1]
|
||
if r2.SquadPoints != 0 || !strings.Contains(strings.Join(r2.Diffs, ";"), "状态") {
|
||
t.Fatalf("t2 = %d %v", r2.SquadPoints, r2.Diffs)
|
||
}
|
||
// 分数汇总:direct 100,squad (50+0)/2=25 → 不达标,保持 direct。
|
||
if rep.Direct.Score != 100 || rep.Squad.Score != 25 {
|
||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||
}
|
||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "低于") {
|
||
t.Fatalf("decision = %+v", rep.Decision)
|
||
}
|
||
if !strings.Contains(rep.Text, "保持 direct") {
|
||
t.Fatalf("report = %s", rep.Text)
|
||
}
|
||
}
|
||
|
||
func TestRunABSwitchMetWhenEqual(t *testing.T) {
|
||
tasks := []ABTask{{ID: "t1", Goal: "g", Themes: []string{"要点"}}}
|
||
fn := stubRun(true, "覆盖要点的产出", 50*time.Millisecond, "")
|
||
rep := RunAB(context.Background(), tasks, fn, fn)
|
||
if rep.Direct.Score != 100 || rep.Squad.Score != 100 {
|
||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||
}
|
||
if !rep.Decision.Met {
|
||
t.Fatalf("同分同延迟应达标:%+v", rep.Decision)
|
||
}
|
||
if len(rep.Records[0].Diffs) != 0 {
|
||
t.Fatalf("同产出应无差异注记:%v", rep.Records[0].Diffs)
|
||
}
|
||
if rep.Direct.FirstTokenP50 != 50*time.Millisecond {
|
||
t.Fatalf("p50 = %s", rep.Direct.FirstTokenP50)
|
||
}
|
||
}
|
||
|
||
func TestRunABLatencyGate(t *testing.T) {
|
||
tasks := []ABTask{{ID: "t1", Goal: "g"}}
|
||
direct := stubRun(true, "ok", 100*time.Millisecond, "")
|
||
squad := stubRun(true, "ok", 4*time.Second, "") // 额外首字 > 3s
|
||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "超过 3 秒") {
|
||
t.Fatalf("decision = %+v", rep.Decision)
|
||
}
|
||
if !strings.Contains(strings.Join(rep.Records[0].Diffs, ";"), "首字延迟差") {
|
||
t.Fatalf("diffs = %v", rep.Records[0].Diffs)
|
||
}
|
||
}
|
||
|
||
// RunFunc 返回 error:记为该侧失败,不中断整批。
|
||
func TestRunABToleratesRunFuncError(t *testing.T) {
|
||
tasks := []ABTask{{ID: "t1"}, {ID: "t2"}}
|
||
direct := stubRun(true, "ok", 0, "")
|
||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||
if task.ID == "t1" {
|
||
return ABOutcome{}, context.DeadlineExceeded
|
||
}
|
||
return ABOutcome{Completed: true, Summary: "ok"}, nil
|
||
}
|
||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||
if rep.Records[0].SquadPoints != 0 || rep.Records[0].Squad.Err == "" {
|
||
t.Fatalf("t1 = %+v", rep.Records[0].Squad)
|
||
}
|
||
if rep.Records[1].SquadPoints != 100 {
|
||
t.Fatalf("t2 应不受 t1 失败影响:%+v", rep.Records[1])
|
||
}
|
||
}
|
||
|
||
func TestRunABEmptyBatch(t *testing.T) {
|
||
rep := RunAB(context.Background(), nil, nil, nil)
|
||
if rep.Decision.Met {
|
||
t.Fatal("空批不得达标(无实测分数)")
|
||
}
|
||
}
|