☰
Sabaki 引擎黄金转录(Golden Transcript)机制:用真实引擎的 GTP `info` 输出构建确定性分析测试
2026/10/13 13:37:26 网站建设 项目流程
  • 桌面应用

【免费下载链接】Sabaki

An elegant Go board and SGF editor for a more civilized age.

项目地址:https://gitcode.com/gh_mirrors/sa/Sabaki
点击查看免费下载

本文以 Sabaki 仓库中 scripts/engine-transcripts/README.md 为核心,完整讲解这套「引擎转录捕获与回放」测试体系的设计动机、分层验证策略、配置矩阵、捕获脚本(capture.mjs)的工作流程,以及黄金转录(golden transcripts)如何被单测与 e2e 测试回放,从而在无引擎、无 GPU 的环境下确定性验证 Sabaki 的 GTP 分析解析管线。读完后,你既能理解如何用一份「真实引擎输出的字节级快照」替代昂贵且不确定的活引擎测试,也能掌握如何扩展引擎版本矩阵与测试棋谱来覆盖新的输出方言。

背景:为什么需要引擎黄金转录

引擎分析(engine analysis)是 Sabaki 的核心功能之一:Sabaki 通过 GTP 协议向 KataGo / Leela Zero 等围棋引擎发送kata-analyze/lz-analyze命令,引擎持续流式输出info ...分析行,由 src/modules/analysis.js 中的parseAnalysis解析,最终写入节点的SBKV(胜率)与SBKS(比分优势)两个 SGF 属性。

但在引入这套工具之前,该解析路径缺乏测试覆盖。原 README 指出了两个关键缺口:

  • e2e/engine.spec.js使用的假引擎只会「下棋」(只实现genmove),从不产生分析输出,parseAnalysis此前从未被真实info行验证过;
  • 解析逻辑最可能「悄悄坏掉」的场景,是上游引擎厂商修改其info行的字段格式——这种回归只有拿真实引擎输出做基准才能捕获。

因此这套工具的策略是:在维护者机器上跑一次真实引擎,把它的原始info ...输出逐行录制成 fixture(即「黄金转录」)并提交进仓库;之后所有测试都在测试时回放这些转录,做到确定性、零引擎依赖。

分层验证策略

原 README 给出了一张清晰的分层表,各层职责与是否需要真实引擎如下(下表完整继承自原文档,路径已转为仓库根目录相对路径):

层位置验证内容测试时是否需要引擎
解析正确性test/analysisTests.jsparseAnalysis对照录制的真实info行(KataGo 与 Leela-Zero 两种方言)否 — 回放转录
管线 e2ee2e/engine-analysis.spec.jsattach 引擎 → GTP analyze →parseAnalysis→ 写入节点SBKV,经由 test/engines/replayEngine.js否 — 回放转录
真实引擎契约本目录脚本(npm run gen:engine-transcripts)确认真实引擎仍在输出我们所解析的格式是— 真的运行引擎

只有第三层(捕获层)需要安装引擎,并且它是有意排除在npm test与 CI 之外的手动步骤。这一点在 package.json 中可以得到印证:"test": "mocha --require tsx"只跑 mocha 单测(含analysisTests.js的回放测试),而"gen:engine-transcripts": "node ./scripts/engine-transcripts/capture.mjs"是独立脚本,需要维护者手工执行。

目录与文件职责

原 README 的 Files 一节列出的组件(路径转为仓库根目录起点):

  • scripts/engine-transcripts/engines.config.mjs — 引擎版本矩阵 + SGF 棋谱矩阵;
  • scripts/engine-transcripts/capture.mjs — 驱动每个引擎通过 GTP 逐谱分析,并把转录写入test/resources/engine-transcripts/<engine-id>/;
  • test/resources/engine-transcripts/ — 已提交的黄金转录 +manifest.json(测试回放的 fixture 目录);
  • test/resources/engine-transcripts/sgf/ — 被分析的 SGF 局面;
  • test/engines/replayEngine.js — 回放一份转录的假 GTP 引擎,供 e2e 测试使用。

当前仓库中已提交的实际矩阵只有一个引擎条目(KataGo 1.16.4),对应 manifest.json 里 8 个转录单元(4 个 SGF × 2 种 analyze 命令),每个单元 8 行info。例如 endgame-9.kata-analyze.txt 中可见 KataGo 方言的完整字段:utility、winrate(0–1 浮点)、scoreMean、scoreStdev、scoreLead、prior、lcb、weight等;而 endgame-9.lz-analyze.txt 则是 Leela-Zero 兼容方言:winrate为万分位整数(如9562表示 95.62%),且没有scoreLead字段。

捕获配置矩阵:engines.config.mjs

捕获行为完全由 engines.config.mjs 驱动,它导出三部分:engines(引擎矩阵)、sgfs(棋谱列表)与capture(捕获调优参数)。

引擎条目字段

当前提交的矩阵内容如下(Leela Zero 条目以注释形式保留,供后续启用):

export const engines = [ { id: 'katago-1.16.4', kind: 'katago', version: '1.16.4', bin: 'katago', net: { url: 'https://katagoarchive.org/g170/neuralnets/g170-b6c96-s175395328-d26788732.bin.gz', file: 'g170-b6c96.bin.gz', }, // kata-analyze -> KataGo dialect (float winrate + scoreLead). // lz-analyze -> Leela-Zero dialect (integer winrate, no scoreLead). analyzeCommands: ['kata-analyze', 'lz-analyze'], overrides: 'numSearchThreads=1', }, ]

各字段含义(依据该文件头部注释):

字段说明
idfixture 子目录名,如'katago-1.16.4'
kind'katago'|'leelaz',决定启动参数拼装方式
version人类可读的引擎版本(仅用于来源标注)
bin引擎二进制(PATH 解析或绝对路径)
net{ url, file }权重文件;缺失时下载到.engine-cache/nets/(只下小网络——测试的是协议,不是棋力)
analyzeCommands要捕获的 GTP 分析命令,每个命令产出一份转录;捕获多种变体正是为了把每种输出方言都文档化
config(KataGo 可选)GTP 配置路径;省略时通过brew --prefix katago自动发现gtp_example.cfg
overrides(KataGo 可选)额外-override-config k=v。numSearchThreads=1尽量让单线程搜索可复现。注意kata-analyze/lz-analyze会持续搜索并忽略maxVisits,因此捕获时长由capture.settleMs限定而非访问数上限
args(可选)额外二进制参数(如 Leela Zero 的 playout 上限)

棋谱矩阵与捕获调优

export const sgfs = [ 'empty-19.sgf', 'opening-19.sgf', 'corner-9.sgf', // A decisive late 9x9 endgame (Black clearly winning) — gives a firm, // directional winrate the e2e can assert against, unlike the ~50% openings. 'endgame-9.sgf', ] export const capture = { interval: 20, // GTP 分析报告间隔(厘秒) settleMs: 1500, // 收到第一条 info 后允许继续分析多久再停止 maxMs: 60000, // 每个「单元」的硬超时 }

棋谱刻意保持少手数、混合棋盘尺寸(9 路与 19 路)以加快捕获。其中 endgame-9.sgf 是一局黑方明显领先的 9 路残局——它给 e2e 测试提供了「方向明确」的胜率(约 97%),可以用来断言,而开局类局面约 50% 的评估会随引擎/网络变化,断言会失去意义。

关于非确定性的重要说明

kata-analyze/lz-analyze在被中断前会持续搜索,因此捕获由墙钟时间(capture.settleMs)限定,而不是固定的访问次数。这意味着重新生成产生的是「新鲜、有效但访问数/胜率不同」的转录,不是字节级一致的。注释中的原话把它们定位为「代表性真实引擎输出,而非确定性工件」。因此矩阵变更后需要重新生成并提交刷新后的 fixture 树,而不能 diff 期望。

捕获脚本 capture.mjs 的实现细节

capture.mjs 是维护者工具(文件头注释明确:「It is a MAINTAINER tool. It is intentionally NOT wired intonpm testor CI」)。其核心由四部分组成。

1. SGF 主线路读取

parseSgf用@sabaki/sgf(与应用同一解析器,注释说明这是为了让坐标/属性处理不会与应用漂移)读出SZ、KM与主线的B/W手数;sgfToGtpVertex借助@sabaki/go-board的规范标注(跳过字母 I、行翻转)把 SGF 坐标(如pd)换算为 GTP 顶点(如Q16),''表示pass。

2. 引擎供给(provisioning)

launchArgs(capture.mjs#L91-L105)按kind拼装启动命令:

  • KataGo:先ensureNet把权重下载到.engine-cache/nets/(若缺失则用fetch拉取并打印体积),再经findKataGoConfig定位gtp_example.cfg(支持engine.config显式指定或brew --prefix katago自动发现,找不到直接抛错提示设置config),最终形如katago gtp -model <net> -config <cfg> -override-config numSearchThreads=1;
  • Leela Zero:leelaz --gtp -w <net>加可选engine.args(注释中给出示例['-p', '16', '--noponder'])。

ensureNet的缓存目录.engine-cache/已在 .gitignore 中声明(# Engine integration test cache (downloaded nets, gtp configs)),与 README 的描述一致。

3. GTP 驱动与单元格捕获

captureCell(capture.mjs#L111-L177)对每个「引擎 × SGF × 分析命令」单元格执行一次完整会话:

  1. spawn引擎进程,用readline逐行读 stdout;
  2. 一次性写入全部设置命令 + 一条分析命令:boardsize <n>、clear_board、komi <k>、逐手play <color> <vertex>,最后是<command> <color> <interval>。buildSetup中有个讲究:komi放在clear_board之后,因为有些引擎在 clear 时重置 komi,这样引擎才能保留 SGF 中的值。分析颜色取「轮到的一方」(最后一手黑则白方分析,无手数则黑方);
  3. 以第一条info行为「引擎已就绪并开始分析」的信号(而不是固定启动延时),从而对首次运行的着色器编译等慢启动稳健;
  4. 收到第一条info后启动settleMs计时器,到点后发stop\nquit\n正常退出;maxMs为硬超时兜底;
  5. 进程关闭时收尾:若一条info都没有则报错(「engine produced no analysis」),否则返回全部infoLines;finish还会先stdin.end()再SIGKILL兜底,确保不留僵尸进程。

4. 主流程与 manifest

main(capture.mjs#L193-L267)三重循环遍历engines × sgfs × analyzeCommands,每次成功捕获后把infoLines写入test/resources/engine-transcripts/<engine-id>/<sgf 主名>.<command>.txt(如katago-1.16.4/endgame-9.kata-analyze.txt),并向manifest.json追加一条单元记录,字段包括engineId、kind、version、net、command、sgf、boardSize、color、interval、infoLineCount、file。manifest 顶部写有「Generated by scripts/engine-transcripts/capture.mjs — do not edit by hand.」。任何单元格失败都会打印 stderr 尾 6 行并计入failures,最终以非零process.exitCode退出;零成功捕获同样视为失败。

如何重新生成转录

原文档给出的操作方式就是单条命令:

npm run gen:engine-transcripts

前提条件:engines.config.mjs列出的引擎二进制需在PATH中可用;小网络会在首次运行时下载到 gitignored 的.engine-cache/nets/。之所以用小网络 + 短时间盒式捕获,是因为这里测试的是「协议/解析集成」,而不是棋力。执行完成后,需要把刷新后的test/resources/engine-transcripts目录树提交进仓库,测试侧的回放才跟着矩阵一起更新。

回放侧一:parseAnalysis 单测(test/analysisTests.js)

test/analysisTests.js 分两部分消费黄金转录:

1)黄金回放断言。它读取manifest.json,为每个单元格生成一个describe块,取该转录最后一行info(注释说明这是「最已收敛」的更新——即最终被写入节点 SBKV/SBKS 的那一条),用before内调用parseAnalysis(lastInfoLine(cell.file), board, winrateFormat)解析(特意放在before而非收集期,使某个 fixture 引发的解析抛错只失败该块而不炸掉整个套件)。方言由命令名决定:kata-analyze传'float',lz-analyze传'integer'。随后断言:

  • 至少解析出一个变着(variation);
  • winrate是[0, 100]内有限数(正是SBKV的值域);
  • visits是非负整数(注释指出引擎合法地报告visits 0,即只有策略先验、无 playout 的棋;在真实lz-analyze输出中确实观察到);
  • 顶点合法(长度为 2 的数组)且pv是数组;
  • 方言相关断言:kata-analyze单元格要求scoreLead为有限数(对应SBKS);lz-analyze单元格要求scoreLead === null(该方言没有比分)。

若manifest.json缺失,套件会显式失败并提示运行npm run gen:engine-transcripts,而不是静默跳过。

2)解析器自身逻辑的聚焦单测。这部分用手工最小行(不依赖引擎格式假设)钉住黄金快照无法确定性覆盖的分支,例如:

  • 整数万分位归一化:winrate 5213→52.13;浮点归一化:winrate 0.5213→52.13,并读取scoreLead 1.5;
  • 无小数的浮点陷阱:KataGo 可能输出整数值浮点(必胜局的winrate 1),启发式(按有无小数点判断方言)会误读为 0.01%,显式传'float'方言则正确归一化为 100%——这正是运行期传入winrateFormat的原因;
  • 主变化(pv)在pass处截断;缺少move或winrate字段的畸形info片段被跳过而不是抛错丢弃整行。

这与 src/modules/analysis.js 的实现严格对应:parseAnalysis(analysis.js#L21-L67)按info分段、用正则提取尾部 pv、把键值对归约成对象,并「跳过畸形片段而不是解引用缺失字段」;normalizeWinrate(analysis.js#L73-L76)信任调用方传入的方言,否则按小数点启发式判断。该模块刻意不含 Electron 与应用状态依赖(唯一协作者是@sabaki/go-board的纯坐标助手),因此能直接对着录制的引擎转录做单测——这是整个分层策略能够成立的前提。

回放侧二:e2e 管线测试与 replayEngine

第二层测试 e2e/engine-analysis.spec.js 验证完整管线:attach 引擎 → GTP analyze →parseAnalysis→ 节点SBKV。它加载 endgame-9.sgf 并走到末手,然后把 replayEngine.js 作为引擎「附加」到 Sabaki(用process.execPath即 Node 本身执行,参数为--transcript ... --analyze-command kata-analyze),随后断言:

  • 同步器(EngineSyncer)的commands包含kata-analyze(Sabaki 据此选择分析命令);
  • 启动分析后,当前节点的SBKV[0]解析为黑方视角的高胜率(> 85且<= 100)——黑方在这局残局中决定性领先;「钉住视角」意味着如果代码把符号翻转写错,写入的会是约 2 而不是 98,断言即失败;
  • SBKS[0]为有限、明显为正(> 1且< 50)的比分优势,同样钉住scoreLead的提取与符号。

测试用try/finally保证断言失败时也stopAnalysis+ 拆除引擎,不向后续测试泄漏子进程。这与生产写入逻辑一致:src/modules/sabaki.js 中在sign < 0(白方视角)时做winrate = 100 - winrate; scoreLead = -scoreLead,再Math.round(x * 100) / 100后以字符串写入SBKV/SBKS(sabaki.js#L1895-L1915);而SBKV/SBKS也在 src/modules/utils.js 的markupCleanupProperties.winrate中登记,供 Clean Markup 功能按类清理。

replayEngine 为什么直接裸写 GTP

test/engines/replayEngine.js 的头部注释解释了它不复用@sabaki/gtp的Engine助手的原因:分析协议是「一边流式输出info行、一边要并发处理stop」,而该助手按严格顺序处理命令,会死锁。其响应框架刻意模仿 KataGo 的真实行为:

  • 普通命令:"= <content>\n\n";
  • 分析命令:先输出"=\n",随后流式输出info ...\n,空行终止符被扣住,直到下一条命令到达才发出——这正是真实引擎「任何新输入都会停止分析并终结响应」的行为。这一点很关键:Sabaki 中止分析用的不是 GTPstop,而是经EngineSyncer.sendAbort发送protocol_version(见注释),因此如果回放引擎只在stop时关闭响应,会让控制器失步。

实现上,startStreaming每 100ms 循环发射录制的info行,stopStreaming在任何新输入到达时先关闭仍打开的分析响应(补一个空行)再处理命令;对boardsize/clear_board/komi/play/undo等局面同步命令一律宽容应答,保证位置同步永不失败。启动时若--transcript缺失、文件不可读或不含任何info行,立即以退出码 2 大声失败——否则引擎只是「什么都不流」,只会表现为一个不透明的 e2e 超时(SBKV 永不写入),而真正的病因被掩盖。

当前捕获的输出方言

原 README 的「Dialects captured today」一节指出:从同一个 KataGo 构建即可捕获两种输出方言:

命令方言winrate形态scoreLead
kata-analyzeKataGo 方言0–1 浮点有
lz-analyzeLeela-Zero 兼容方言万分位整数无

两者合起来恰好覆盖parseAnalysis的两条胜率归一化分支(×100与/100)以及「有比分 / 无比分」两条路径。原文档还留了一个明确的后续项:最终应向矩阵加入一个真正的 Leela Zero 构建(在engines.config.mjs中已有注释掉的leela-zero-0.17示例条目,analyzeCommands: ['lz-analyze'],示例参数['-p', '16', '--noponder']),以确认 KataGo 的lz-analyze仿真与真机输出一致。需要说明的是:Sabaki 对引擎开发者公开的analyze协议规范见 docs/guides/engine-analysis-integration.md(move/visits/winrate必填,scoreLead可选,响应在任意 stdin 输入时终止),黄金转录正是对这套规范的「真实引擎侧」回归基准。

扩展覆盖范围

原 README 给出的三类扩展路径,结合源码可落地为:

  1. 新引擎版本 / 新引擎:在 engines.config.mjs 的engines中增加条目(二进制、权重 URL、分析命令),然后重新生成。README 特别提到:在 Linux/CI 上,KataGo 还提供纯 CPU 的eigen构建,适合放进一个「独立、定时调度」的真实引擎作业。
  2. 新局面:把一个 SGF 放进 test/resources/engine-transcripts/sgf/,在sgfs中列出,再重新生成。
  3. 新分析特性(例如SBKS之类的新属性):KataGo 的kata-analyze转录中已经包含scoreLead字段,可先在 test/analysisTests.js 中对其断言,待功能真正把属性写入节点后再补到 e2e 层。

小结

这套工具的核心思路可以概括为一句话:把「真实引擎输出」从测试时移到了构建时。capture.mjs负责在维护者机器上录制真实 KataGo/Leela-Zero 方言的info字节流并提交为黄金转录;analysisTests.js用它们钉住parseAnalysis的双方言归一化与比分提取;replayEngine.js则用相同的字节流驱动整个 Electron 应用走通 attach → analyze →SBKV/SBKS写入的 e2e 管线。代价(需要真实引擎与权重的第三层)被隔离成一次性的手动命令npm run gen:engine-transcripts,从而让确定性与真实性兼得:日常npm test/ CI 零引擎依赖,而上游引擎格式变化导致的解析回归,则会在下一次手动捕获 + 提交刷新 fixture 时被立刻暴露。

  • 桌面应用

【免费下载链接】Sabaki

An elegant Go board and SGF editor for a more civilized age.

项目地址:https://gitcode.com/gh_mirrors/sa/Sabaki
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询