Files
weichen 196c8c09f7
ci / vet-and-test (pull_request) Successful in 42s
Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)
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
2026-10-08 03:46:54 +00:00

176 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eval
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// ab.go — 任务级 A/B 双跑 harness(Q3 WP5):同一批任务分别经两个入口
// (direct 对话循环 / squad 编译图)执行,逐任务记录差异(不要求字节级
// 相同),汇总为两份 EntryScore 交 DecideSwitch 裁决默认入口切换。
// 本包保持纯机制(不依赖 runtime/squad);两侧 RunFunc 由调用方装配。
// ABTask 是对照批中的一个任务。
type ABTask struct {
ID string
Goal string
// Themes 期望产出要点(短词,非整句照抄):打分用 MissingThemes,
// 差异记录用要点覆盖对比。
Themes []string
}
// ABOutcome 是一个入口对一个任务的执行结果(调用方 RunFunc 填写)。
type ABOutcome struct {
// Completed 任务是否正常收口(失败/取消 = false)。
Completed bool
// Summary 交付摘要(要点覆盖打分的对象;调用方可拼接工件正文)。
Summary string
// FirstToken 首字延迟(入口收到任务到首个可见输出)。
FirstToken time.Duration
// Err 失败原因(Completed=false 时填写)。
Err string
}
// RunFunc 是一个入口的执行侧(direct 或 squad 编译图)。
type RunFunc func(ctx context.Context, task ABTask) (ABOutcome, error)
// ABTaskRecord 是一个任务的双跑对照记录(差异记录单位)。
type ABTaskRecord struct {
Task ABTask
Direct ABOutcome
Squad ABOutcome
// Diffs 差异注记(状态 / 要点覆盖 / 首字延迟),空 = 两侧一致。
Diffs []string
// DirectPoints / SquadPoints 单任务得分(满分 100:完成 + 要点全覆盖;
// 未完成 0 分;完成但缺要点按缺失比例扣)。
DirectPoints int
SquadPoints int
}
// ABReport 是一批双跑的对照报告(切换裁决的证据形态)。
type ABReport struct {
Records []ABTaskRecord
Direct EntryScore
Squad EntryScore
Decision SwitchDecision
// Text 是可附在切换 PR / 任务卡上的短报告(Report(Decision) + 差异清单)。
Text string
}
// RunAB 双跑同一批任务并汇总对照报告。任一侧 RunFunc 返回 error 视为该侧
// 该任务失败(记录进 outcome.Err),不中断整批(对照要的是全批差异面)。
func RunAB(ctx context.Context, tasks []ABTask, direct, squad RunFunc) ABReport {
rep := ABReport{}
if len(tasks) == 0 {
rep.Decision = DecideSwitch(nil, nil)
rep.Text = Report(rep.Decision)
return rep
}
var directFT, squadFT []time.Duration
var directSum, squadSum int
for _, task := range tasks {
rec := ABTaskRecord{Task: task}
rec.Direct = runOne(ctx, task, direct)
rec.Squad = runOne(ctx, task, squad)
rec.DirectPoints = scoreOutcome(rec.Direct, task.Themes)
rec.SquadPoints = scoreOutcome(rec.Squad, task.Themes)
rec.Diffs = diffRecord(rec)
rep.Records = append(rep.Records, rec)
directSum += rec.DirectPoints
squadSum += rec.SquadPoints
directFT = append(directFT, rec.Direct.FirstToken)
squadFT = append(squadFT, rec.Squad.FirstToken)
}
rep.Direct = EntryScore{Name: "direct", Score: directSum / len(tasks), FirstTokenP50: p50(directFT)}
rep.Squad = EntryScore{Name: "squad", Score: squadSum / len(tasks), FirstTokenP50: p50(squadFT)}
rep.Decision = DecideSwitch(&rep.Direct, &rep.Squad)
rep.Text = renderReport(rep)
return rep
}
func runOne(ctx context.Context, task ABTask, fn RunFunc) ABOutcome {
if fn == nil {
return ABOutcome{Err: "入口未装配"}
}
out, err := fn(ctx, task)
if err != nil {
out.Completed = false
if out.Err == "" {
out.Err = err.Error()
}
}
return out
}
// scoreOutcome 单任务打分:未完成 0;完成 = 100 - 缺失要点比例 × 100。
func scoreOutcome(out ABOutcome, themes []string) int {
if !out.Completed {
return 0
}
if len(themes) == 0 {
return 100
}
missing := MissingThemes(out.Summary, themes)
return 100 - len(missing)*100/len(themes)
}
// diffRecord 逐任务差异注记(状态 / 要点 / 首字延迟;不要求字节级相同)。
func diffRecord(rec ABTaskRecord) []string {
var diffs []string
if rec.Direct.Completed != rec.Squad.Completed {
diffs = append(diffs, fmt.Sprintf("状态:direct=%v squad=%v(%s / %s)",
rec.Direct.Completed, rec.Squad.Completed, rec.Direct.Err, rec.Squad.Err))
}
if len(rec.Task.Themes) > 0 {
dm := MissingThemes(rec.Direct.Summary, rec.Task.Themes)
sm := MissingThemes(rec.Squad.Summary, rec.Task.Themes)
if strings.Join(dm, ",") != strings.Join(sm, ",") {
diffs = append(diffs, fmt.Sprintf("要点缺失:direct=%v squad=%v", dm, sm))
}
}
gap := rec.Squad.FirstToken - rec.Direct.FirstToken
if gap < 0 {
gap = -gap
}
if gap > time.Second {
diffs = append(diffs, fmt.Sprintf("首字延迟差:%s(direct=%s squad=%s)",
gap, rec.Direct.FirstToken, rec.Squad.FirstToken))
}
if rec.Direct.Completed && rec.Squad.Completed &&
strings.TrimSpace(rec.Direct.Summary) != strings.TrimSpace(rec.Squad.Summary) {
diffs = append(diffs, "摘要文本不同(记录在案,不要求字节级相同)")
}
return diffs
}
func p50(ds []time.Duration) time.Duration {
if len(ds) == 0 {
return 0
}
sorted := append([]time.Duration(nil), ds...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return sorted[len(sorted)/2]
}
// renderReport 报告全文:裁决 + 分数 + 逐任务差异清单。
func renderReport(rep ABReport) string {
var b strings.Builder
b.WriteString(Report(rep.Decision))
fmt.Fprintf(&b, "\n分数:direct=%d squad=%d;首字P50:direct=%s squad=%s\n",
rep.Direct.Score, rep.Squad.Score, rep.Direct.FirstTokenP50, rep.Squad.FirstTokenP50)
for _, rec := range rep.Records {
fmt.Fprintf(&b, "- %s:direct %d 分 / squad %d 分", rec.Task.ID, rec.DirectPoints, rec.SquadPoints)
if len(rec.Diffs) == 0 {
b.WriteString("(无差异注记)")
}
b.WriteString("\n")
for _, d := range rec.Diffs {
fmt.Fprintf(&b, " - %s\n", d)
}
}
return strings.TrimRight(b.String(), "\n")
}