Skip to content

fix(eval): 穷尽化 verdictForRun 的失败模式分类并补齐 aggregate 测试 - #108

Open
RXQ6 wants to merge 2 commits into
helsome:mainfrom
RXQ6:fix/eval-verdict-exhaustive-classification
Open

RXQ6 wants to merge 2 commits into
helsome:mainfrom
RXQ6:fix/eval-verdict-exhaustive-classification

Conversation

@RXQ6

@RXQ6 RXQ6 commented Sep 14, 2026

Copy link
Copy Markdown

改动说明

packages/shared/src/evaluation/aggregate.ts 是评测汇总与回归门禁的入口,却没有测试文件。本 PR 做三件事:把失败模式分类改成编译期穷尽、补齐门禁读取 baseline 时的字段级守卫、补上该模块缺失的测试。

1. verdictForRun 的失败模式分类改为编译期穷尽

原实现用两个手写 Set 表达严重度:FAILING_MODES(9 个)→ failPARTIAL_MODES(5 个)→ partial,共覆盖 14 个;不在任一集合里的模式会直接掉到函数末尾的 return 'pass'

EvaluationFailureMode 联合类型有 16 个成员,落在集合外的正是:

  • resource_unavailable —— 按语义应为 partial
  • judge_error —— 它同样不在集合里,但本来就该判 pass(裁判/打分器失败不是 agent 失败,见 evaluators/deterministic.ts)。所以这一处是"歪打正着"。

关于影响面,需要如实说明resource_unavailable 当前在全仓库没有任何赋值点grep 只命中类型定义、i18n 文案、UI 格式化表和 docs/EVALUATION.md 的分类清单)。因此本改动今天不改变任何运行时结果,也没有回归风险。它的价值在两处:

  • 消除未来的静默 fail-open:手写 Set 与联合类型之间没有任何强制关系,以后往 EvaluationFailureMode 加一个成员而忘记分类,它会静默pass;换成 Record<EvaluationFailureMode, VerdictSeverity> 之后这是编译错误。
  • 用测试锁定严重度:编译期只能保证"每个模式都有分类",不能保证"分类是对的"(为通过编译随手写个错的值同样合法),所以另需运行时断言,见第 3 节。

严重度沿用 evaluators/deterministic.ts 的既有语义,没有改变任何既有分类judge_error 仍为 pass,其余 15 项与原两个集合逐一对应。

const MODE_SEVERITY: Record<EvaluationFailureMode, VerdictSeverity> = { /* 16 项,缺一即编译失败 */ };

export function verdictForRun(run: EvaluationRun): EvaluationResultRecord['verdict'] {
  if (run.status === 'skipped') return 'not-applicable';
  if (run.status !== 'completed') return 'fail';
  let worst: VerdictSeverity = 'pass';
  for (const mode of run.failureModes) {
    const severity: VerdictSeverity | undefined = MODE_SEVERITY[mode];
    // 表里查不到 = 其他版本写入的记录带有本版本未知的模式,不得静默判 pass
    if (severity === undefined || severity === 'fail') return 'fail';
    if (severity === 'partial') worst = 'partial';
  }
  return worst;
}

2. compareToBaseline 补齐 baseline 字段级可选链

baseline 有两条来源,行为并不一致:

来源 是否做了形状归一化
提交在仓库的 scripts/eval/ci-baselines/*.json,经 scripts/eval/run.tsloadCommittedBaseline() 已归一化:缺 metrics 直接返回 undefinedthresholds 兜底为 {},并补齐 id/name/experimentId 等字段 → 这条路径没问题
EvaluationStore.listBaselines()experiment-service.tsbaselineId 解析) 未归一化EvaluationStore.load() 只校验 Array.isArray(raw.baselines),逐条不做形状校验(对比同一函数里的 settings 会过 sanitizeSettings

所以由旧版本写入的 store 条目可能既没有 metrics 也没有 thresholds。原代码只对 baseline 本身用了可选链,字段没有:

const baselineValue = baseline?.metrics[aggregate.metric] ?? null;   // baseline 不是 undefined、但没有 metrics → TypeError
const maxDelta = baseline?.thresholds[aggregate.metric] ?? definition?.defaultMaxDelta ?? 0.05;

改为 baseline?.metrics?.[m] / baseline?.thresholds?.[m],缺键时该指标按"没有基线值"跳过(沿用下游已有的 passed = true 语义),其余指标照常比较。门禁的失败应当是有信息量的信号(真回归),而不是读取外部持久化数据引发的异常。

这一条比第 1 条弱,属于防御性一致性修复(同一函数内一半做了守卫、一半没做)。如果认为它超出本 PR 的 scope,我可以拆成单独 PR 或直接去掉。

3. 补齐该模块缺失的测试

新增 aggregate.test.ts(20 个用例),覆盖 verdictForRunaggregateScorescountFailureModescompositeScoresummarizeExperimentcompareToBaseline / gatePassed。两条关键测试:

  • classifies every failure mode in the union —— 先用运行时列表断言 EXPECTED_SEVERITY 的键集合等于联合类型的成员集合,再逐一断言每个模式的具体严重度。前者防"漏分类",后者防"分类写错"。
  • never treats an unrecognised failure mode as a pass —— 锁定防御性分支:带未知模式的记录(跨版本持久化)必须判 fail

关联 Issue

Closes #107

Related to #15([Eval] Add Gold Case dataset and real end-to-end evaluation harness)。#15 已被 @xxstar-01 认领,本 PR 不与其重叠,也不关闭它。

测试报告(正式审核前必填)

环境

  • Bun:1.4.2
  • Node:v22.22.2
  • OS:Windows 11 (win32 x64),系统 locale zh-CN
  • 基线提交:upstream/main @ 3eee5fb(本 PR 的父提交)

实际执行命令与结果

bun run typecheck
→ @finagent/core / i18n / shared / ui / electron 全部 Exited with code 0(5/5)

bun test packages/shared/src/evaluation/aggregate.test.ts
→ 20 pass / 0 fail,47 expect() calls

bun test packages/shared --isolate        # pr.yml 中 focused-tests 对 packages/shared 用的同款命令
→ 925 pass / 0 fail,3602 expect() calls,86 files

bun test packages/shared/src/evaluation/langfuse/langfuse.test.ts --isolate   # 相邻模块对照
→ 13 pass / 0 fail,60 expect() calls

已知失败 / Baseline

无。 上述命令在 upstream/main @ 3eee5fb 上全部通过(packages/shared 925 pass / 0 fail),没有需要归因的失败项。

供参考:在本 PR 开发过程中,曾于较早的基线上观察到 langfuse backend > does not throw when Langfuse is down 偶发失败,定位为 Bun 1.4.2 在 Windows 上的 HTTP keep-alive 连接复用缺陷(同一 host 下第二次请求会复用第一条连接、忽略端口,可用不含本仓库代码的纯 Bun 脚本复现)。该现象在 3eee5fb 上未复现,故不作为已知失败列出,也不属于本 PR 的改动范围。

  • 已提供实际测试命令与 pass/fail 结果
  • 已说明测试环境
  • 本次运行无已知 baseline / 环境失败(此前的 Bun 客户端缺陷复现已在上面注明)
  • 核心改动已有对应 focused test / smoke / integration 验证

UI 截图(仅可见 UI 变化时必填)

  • 本 PR 无可见 UI 变化(无需截图)

Scope / 后续

  • 本 PR 只动 aggregate.ts 的判定与门禁读取,并补上该模块的测试;不改数据集、runner 或 Langfuse 集成。
  • 一个需要维护者定夺的口径问题(judge_error)docs/EVALUATION-METHODOLOGY.md:94-95 写的是 "judge_error runs are excluded and counted",而代码里 judge_error → verdict passsummarizeExperiment 又按 passRate = passed / (verdict !== 'not-applicable' 的数量) 计算 —— 即该 run 既进分母、也算通过,与文档的"excluded"不一致。本 PR 保持现状(沿用 deterministic.ts 的"裁判失败不是 agent 失败"语义),未改动 judge_error 的判定。如果文档才是意图,正确做法是把 judge_error 判为 not-applicable(与 skipped 一致,从分子分母同时排除);需要的话我可以另开 PR 或在本文中一并调整,请指示。
  • 顺带观察(不在本 PR 范围):resource_unavailable 在全仓库只有类型 / i18n / UI / 文档中的声明,没有任何赋值点,属于"声明但未使用"。是否要接入(例如把 provider 不可用映射到它)是另一个话题。
  • 顺带观察(不在本 PR 范围):EvaluationStore.load()baselines 只做集合级 Array.isArray 校验、逐条不归一化,而同一函数里的 settings 会过 sanitizeSettings。如果认为持久化读入应当统一做形状归一化,可另开 issue 讨论。

@helsome helsome left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体实现方向是对的:用 Record<EvaluationFailureMode, ...> 把新增 failure mode 变成编译期必分类,并补齐 aggregate/baseline 测试,测试报告也完整。当前只卡一个会直接污染评测口径的语义问题:请不要把 judge_error 固化为 pass

仓库方法论文档已经定义 judge_error 为 “excluded and counted”,而 summarizeExperiment 的 pass rate 会把 pass 同时计入分子和分母。于是 judge 本身失败会抬高 Agent pass rate;本 PR 新增的穷尽表和测试如果按当前写法落地,会把这个已有矛盾正式锁死。

建议在本 PR 一并收口:

  • judge_error(没有更严重的 Agent 自身 failure mode)→ not-applicable,从 pass-rate 分子/分母排除,但仍由 countFailureModes 计数;
  • 若同一 run 同时存在真实 Agent failure(如 missing_tool)与 judge_error,真实 Agent failure 仍应主导为 fail/partial,而不是被 judge error 抹掉;
  • 增加对应 aggregate/pass-rate 测试。

resource_unavailable -> partial、未知跨版本 mode fail-closed、baseline 字段 optional chaining 这几部分我认可,不需要拆 PR。改完上述口径并保持现有 focused/shared/typecheck 绿即可继续审核。

@RXQ6

RXQ6 commented Sep 14, 2026

Copy link
Copy Markdown
Author

已按 review 意见完成修改并推送(30fd9b7):仅有 judge_error 时返回 not-applicable,并从 pass-rate 分子/分母排除,但仍由 countFailureModes 计数;若同一 run 还存在真实 Agent failure,则 fail/partial 继续主导。对应的混合 failure mode、aggregate/pass-rate 测试也已补齐。

本地验证:

  • focused aggregate tests:22 pass / 0 fail
  • typecheck:core / i18n / shared / ui / electron 全部通过(5/5)
  • shared tests:926 pass;唯一未通过项是当前沙箱无法访问测试硬编码的 C:\tmp\pi,与本次改动无关

麻烦批准并运行 workflow,方便的话也请复审,谢谢。

@helsome
helsome dismissed their stale review September 14, 2026 09:32

judge_error 语义已按 review 修正:judge-only run 排除 pass rate,真实 Agent failure 仍主导,且补齐聚合/计数测试。原 blocker 已解决。

@helsome helsome left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

语义 blocker 已解决:judge-only run 现在排除 pass rate 但仍计入 failure mode,真实 Agent failure 与 judge_error 并存时仍由真实 failure 主导;resource_unavailable/unknown-mode fail-closed/baseline 防御也都合理。代码与测试可批准。当前 main 已继续前进、GitHub 显示不可直接合并,请只 rebase 最新 main 后重跑 aggregate focused test + shared/typecheck/basic CI,不需要扩大 scope。action_required 仅是 Actions 授权状态,不作为质量失败。

@RXQ6
RXQ6 force-pushed the fix/eval-verdict-exhaustive-classification branch from 30fd9b7 to cd466a1 Compare September 14, 2026 10:27
@RXQ6

RXQ6 commented Sep 15, 2026

Copy link
Copy Markdown
Author

@helsome rebase 已完成,现在基于最新 main:head cd466a16 / base 52b224cb,ahead 2 / behind 0,GitHub 显示 MERGEABLE,改动仍只有 aggregate.ts + aggregate.test.ts 两个文件。

唯一卡住的是 PR checks 停在 action_required,需要你授权后才会执行:https://github.com/helsome/folio/actions/runs/34833266216

麻烦授权跑一下 CI 就可以合了,谢谢!(本地 Windows 环境有点 symlink 问题,跑不了完整 typecheck —— 就是 #99 记录的那个,所以想借 CI 确认。)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Eval] verdictForRun 的失败模式分类缺少穷尽约束,漏分类会静默判 pass

3 participants