niceeval 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/dist/agents/types.d.ts +72 -6
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
  9. package/dist/report/built-ins/experiment-comparison.js +119 -0
  10. package/dist/report/built-ins/index.d.ts +2 -1
  11. package/dist/report/built-ins/index.js +2 -2
  12. package/dist/report/components.d.ts +10 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +68 -66
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -14
  19. package/dist/report/format.js +28 -30
  20. package/dist/report/index.d.ts +5 -4
  21. package/dist/report/index.js +6 -5
  22. package/dist/report/locale.d.ts +23 -3
  23. package/dist/report/locale.js +47 -6
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +66 -15
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +4 -4
  28. package/dist/report/react/AttemptList.js +8 -10
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +4 -2
  34. package/dist/report/react/ExperimentList.js +57 -7
  35. package/dist/report/react/MetricScatter.js +6 -16
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +30 -18
  40. package/dist/report/react/format.d.ts +1 -1
  41. package/dist/report/react/format.js +1 -1
  42. package/dist/report/react/index.d.ts +1 -1
  43. package/dist/report/report.d.ts +5 -1
  44. package/dist/report/report.js +6 -2
  45. package/dist/report/text/faces.d.ts +1 -1
  46. package/dist/report/text/faces.js +100 -61
  47. package/dist/report/text/table.js +36 -5
  48. package/dist/report/types.d.ts +40 -34
  49. package/dist/results/types.d.ts +11 -0
  50. package/dist/runner/feedback/sink.d.ts +110 -0
  51. package/dist/runner/types.d.ts +513 -22
  52. package/dist/sandbox/docker.d.ts +23 -2
  53. package/dist/sandbox/e2b.d.ts +15 -1
  54. package/dist/sandbox/errors.d.ts +30 -3
  55. package/dist/sandbox/io-retry.d.ts +17 -0
  56. package/dist/sandbox/registry.d.ts +2 -0
  57. package/dist/sandbox/resolve.d.ts +18 -5
  58. package/dist/sandbox/retry.d.ts +11 -1
  59. package/dist/sandbox/types.d.ts +39 -5
  60. package/dist/sandbox/vercel.d.ts +7 -1
  61. package/dist/scoring/coverage.d.ts +30 -0
  62. package/dist/scoring/display.d.ts +21 -0
  63. package/dist/scoring/display.js +120 -0
  64. package/dist/scoring/types.d.ts +103 -20
  65. package/dist/shared/aggregate.d.ts +1 -0
  66. package/dist/shared/aggregate.js +3 -3
  67. package/dist/shared/types.d.ts +28 -0
  68. package/dist/tty-line.d.ts +0 -4
  69. package/dist/util.d.ts +23 -0
  70. package/docs-site/zh/concepts/adapter.mdx +24 -6
  71. package/docs-site/zh/concepts/assert.mdx +11 -10
  72. package/docs-site/zh/concepts/evals.mdx +7 -6
  73. package/docs-site/zh/concepts/experiment.mdx +1 -1
  74. package/docs-site/zh/concepts/overview.mdx +7 -7
  75. package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
  76. package/docs-site/zh/guides/authoring.mdx +33 -0
  77. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  78. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  79. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  80. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  81. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  82. package/docs-site/zh/guides/debugging.mdx +210 -0
  83. package/docs-site/zh/guides/experiments.mdx +10 -3
  84. package/docs-site/zh/guides/fixtures.mdx +3 -1
  85. package/docs-site/zh/guides/official-adapters.mdx +27 -3
  86. package/docs-site/zh/guides/publish-report.mdx +30 -16
  87. package/docs-site/zh/guides/report-components.mdx +49 -37
  88. package/docs-site/zh/guides/reporters.mdx +2 -2
  89. package/docs-site/zh/guides/results-data.mdx +42 -8
  90. package/docs-site/zh/guides/runner.mdx +17 -7
  91. package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
  92. package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
  93. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  94. package/docs-site/zh/guides/viewing-results.mdx +85 -41
  95. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  96. package/docs-site/zh/guides/write-send.mdx +19 -2
  97. package/docs-site/zh/index.mdx +1 -1
  98. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  99. package/docs-site/zh/reference/capabilities.mdx +2 -2
  100. package/docs-site/zh/reference/cli.mdx +35 -9
  101. package/docs-site/zh/reference/define-agent.mdx +60 -5
  102. package/docs-site/zh/reference/define-config.mdx +1 -1
  103. package/docs-site/zh/reference/define-eval.mdx +42 -9
  104. package/docs-site/zh/reference/events.mdx +2 -2
  105. package/docs-site/zh/reference/expect.mdx +36 -6
  106. package/package.json +5 -1
  107. package/src/agents/ai-sdk-otel.test.ts +1 -0
  108. package/src/agents/ai-sdk.test.ts +3 -0
  109. package/src/agents/ai-sdk.ts +3 -0
  110. package/src/agents/bub-install-spec.test.ts +34 -0
  111. package/src/agents/bub-install-spec.ts +32 -0
  112. package/src/agents/bub.ts +31 -32
  113. package/src/agents/claude-code.test.ts +130 -9
  114. package/src/agents/claude-code.ts +76 -4
  115. package/src/agents/codex.test.ts +189 -40
  116. package/src/agents/codex.ts +155 -14
  117. package/src/agents/coding-cli-versions.test.ts +15 -0
  118. package/src/agents/coding-cli-versions.ts +3 -0
  119. package/src/agents/index.ts +11 -0
  120. package/src/agents/langgraph.test.ts +204 -0
  121. package/src/agents/langgraph.ts +495 -0
  122. package/src/agents/marketplace.ts +85 -0
  123. package/src/agents/native-config.test.ts +179 -0
  124. package/src/agents/native-config.ts +267 -0
  125. package/src/agents/openai-compat.test.ts +1 -0
  126. package/src/agents/openclaw.test.ts +31 -0
  127. package/src/agents/openclaw.ts +171 -0
  128. package/src/agents/plugin-config.test.ts +1 -0
  129. package/src/agents/sdk-streams.test.ts +79 -0
  130. package/src/agents/sdk-streams.ts +55 -10
  131. package/src/agents/skills.test.ts +1 -0
  132. package/src/agents/streaming.test.ts +3 -9
  133. package/src/agents/types.ts +73 -6
  134. package/src/agents/ui-message-stream.test.ts +3 -0
  135. package/src/cli.ts +411 -108
  136. package/src/context/context.test.ts +51 -12
  137. package/src/context/context.ts +161 -29
  138. package/src/context/session.test.ts +1 -0
  139. package/src/context/session.ts +114 -6
  140. package/src/context/types.ts +30 -12
  141. package/src/define.test.ts +13 -8
  142. package/src/define.ts +25 -4
  143. package/src/expect/index.ts +53 -23
  144. package/src/i18n/en.ts +65 -4
  145. package/src/i18n/zh-CN.ts +66 -4
  146. package/src/o11y/cost.test.ts +1 -0
  147. package/src/o11y/execution-tree.test.ts +1 -20
  148. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  149. package/src/o11y/otlp/parse.test.ts +1 -0
  150. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  151. package/src/o11y/parsers/bub.test.ts +1 -0
  152. package/src/o11y/parsers/claude-code.test.ts +1 -34
  153. package/src/o11y/parsers/openclaw.test.ts +154 -0
  154. package/src/o11y/parsers/openclaw.ts +310 -0
  155. package/src/o11y/prices.json +746 -311
  156. package/src/o11y/tool-names.test.ts +1 -0
  157. package/src/o11y/types.ts +16 -2
  158. package/src/report/aggregate.ts +34 -5
  159. package/src/report/built-in-user-parity.test.tsx +127 -173
  160. package/src/report/built-ins/experiment-comparison.tsx +179 -0
  161. package/src/report/built-ins/index.ts +7 -2
  162. package/src/report/components.tsx +11 -3
  163. package/src/report/compute.ts +80 -74
  164. package/src/report/dual-render.test.tsx +222 -91
  165. package/src/report/flag.ts +30 -2
  166. package/src/report/format.ts +36 -27
  167. package/src/report/index.ts +23 -6
  168. package/src/report/locale.ts +49 -6
  169. package/src/report/metrics.ts +68 -15
  170. package/src/report/primitives.tsx +6 -0
  171. package/src/report/react/AttemptList.tsx +9 -36
  172. package/src/report/react/EvalList.tsx +0 -0
  173. package/src/report/react/ExperimentComparison.tsx +68 -0
  174. package/src/report/react/ExperimentList.tsx +173 -55
  175. package/src/report/react/MetricScatter.tsx +13 -25
  176. package/src/report/react/chart-math.test.ts +85 -0
  177. package/src/report/react/chart-math.ts +101 -22
  178. package/src/report/react/enhance.js +72 -1
  179. package/src/report/react/fixtures.ts +34 -21
  180. package/src/report/react/format.ts +1 -1
  181. package/src/report/react/index.tsx +0 -1
  182. package/src/report/react/render.test.tsx +30 -69
  183. package/src/report/react/styles.css +112 -14
  184. package/src/report/report.test.ts +308 -105
  185. package/src/report/report.ts +6 -2
  186. package/src/report/text/faces.ts +111 -67
  187. package/src/report/text/table.ts +42 -5
  188. package/src/report/types.ts +42 -34
  189. package/src/results/annotated-source.test.ts +62 -9
  190. package/src/results/annotated-source.ts +64 -6
  191. package/src/results/attempt-evidence.test.ts +9 -7
  192. package/src/results/attempt-evidence.ts +15 -8
  193. package/src/results/attempt-source.ts +6 -3
  194. package/src/results/copy.ts +145 -55
  195. package/src/results/host-equivalence.test.ts +11 -9
  196. package/src/results/index.ts +2 -0
  197. package/src/results/locator.test.ts +1 -22
  198. package/src/results/open.ts +7 -1
  199. package/src/results/publish.ts +149 -0
  200. package/src/results/results.test.ts +85 -51
  201. package/src/results/truncate.ts +90 -0
  202. package/src/results/types.ts +7 -0
  203. package/src/results/writer.ts +31 -13
  204. package/src/runner/attempt.test.ts +138 -7
  205. package/src/runner/attempt.ts +603 -104
  206. package/src/runner/discover.test.ts +47 -0
  207. package/src/runner/discover.ts +36 -2
  208. package/src/runner/eval-source.test.ts +1 -27
  209. package/src/runner/feedback/agent.test.ts +504 -0
  210. package/src/runner/feedback/agent.ts +409 -0
  211. package/src/runner/feedback/ci.test.ts +562 -0
  212. package/src/runner/feedback/ci.ts +401 -0
  213. package/src/runner/feedback/coordinator.test.ts +317 -0
  214. package/src/runner/feedback/coordinator.ts +397 -0
  215. package/src/runner/feedback/failure.ts +40 -0
  216. package/src/runner/feedback/human.test.ts +616 -0
  217. package/src/runner/feedback/human.ts +535 -0
  218. package/src/runner/feedback/index.ts +66 -0
  219. package/src/runner/feedback/io.ts +78 -0
  220. package/src/runner/feedback/profile.test.ts +50 -0
  221. package/src/runner/feedback/profile.ts +58 -0
  222. package/src/runner/feedback/reducer.test.ts +395 -0
  223. package/src/runner/feedback/reducer.ts +260 -0
  224. package/src/runner/feedback/renderer.ts +82 -0
  225. package/src/runner/feedback/sink.ts +203 -0
  226. package/src/runner/feedback/testing.ts +106 -0
  227. package/src/runner/ledger.test.ts +230 -0
  228. package/src/runner/ledger.ts +329 -0
  229. package/src/runner/report.test.ts +128 -3
  230. package/src/runner/report.ts +33 -9
  231. package/src/runner/reporters/artifacts.ts +8 -2
  232. package/src/runner/reporters/braintrust.test.ts +8 -7
  233. package/src/runner/reporters/braintrust.ts +9 -2
  234. package/src/runner/reporters/index.ts +2 -2
  235. package/src/runner/reporters/json.test.ts +162 -0
  236. package/src/runner/reporters/json.ts +35 -8
  237. package/src/runner/reporters/shared.ts +1 -5
  238. package/src/runner/run.test.ts +760 -3
  239. package/src/runner/run.ts +242 -36
  240. package/src/runner/sandbox-prep.ts +3 -42
  241. package/src/runner/timing.ts +158 -0
  242. package/src/runner/types.ts +518 -22
  243. package/src/sandbox/checkpoint.test.ts +55 -0
  244. package/src/sandbox/checkpoint.ts +29 -8
  245. package/src/sandbox/cli-commands.ts +407 -0
  246. package/src/sandbox/docker.ts +115 -16
  247. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  248. package/src/sandbox/e2b-agent-template.ts +94 -0
  249. package/src/sandbox/e2b.ts +74 -9
  250. package/src/sandbox/errors.ts +111 -4
  251. package/src/sandbox/index.ts +2 -0
  252. package/src/sandbox/io-retry.test.ts +58 -0
  253. package/src/sandbox/io-retry.ts +45 -0
  254. package/src/sandbox/keep-registry.test.ts +86 -0
  255. package/src/sandbox/keep-registry.ts +142 -0
  256. package/src/sandbox/keep.ts +178 -0
  257. package/src/sandbox/paths.test.ts +1 -0
  258. package/src/sandbox/paths.ts +19 -8
  259. package/src/sandbox/registry.ts +20 -3
  260. package/src/sandbox/resolve.ts +76 -11
  261. package/src/sandbox/retry.test.ts +70 -0
  262. package/src/sandbox/retry.ts +46 -4
  263. package/src/sandbox/types.ts +44 -6
  264. package/src/sandbox/vercel.ts +43 -20
  265. package/src/scoring/collector.ts +60 -17
  266. package/src/scoring/coverage.ts +95 -0
  267. package/src/scoring/diff.ts +81 -0
  268. package/src/scoring/display.test.ts +121 -0
  269. package/src/scoring/display.ts +133 -0
  270. package/src/scoring/evidence.test.ts +189 -0
  271. package/src/scoring/judge.test.ts +142 -0
  272. package/src/scoring/judge.ts +15 -18
  273. package/src/scoring/scoped.ts +217 -50
  274. package/src/scoring/types.ts +117 -20
  275. package/src/scoring/verdict.ts +16 -4
  276. package/src/shared/aggregate.ts +3 -2
  277. package/src/shared/types.ts +31 -0
  278. package/src/show/compose.ts +2 -2
  279. package/src/show/index.ts +29 -16
  280. package/src/show/render.ts +626 -308
  281. package/src/show/show.test.ts +251 -36
  282. package/src/tty-line.ts +8 -26
  283. package/src/util.test.ts +1 -0
  284. package/src/util.ts +41 -0
  285. package/src/view/app/components/AttemptModal.tsx +153 -2
  286. package/src/view/app/components/CodeView.tsx +32 -11
  287. package/src/view/app/components/CopyControls.tsx +2 -2
  288. package/src/view/app/i18n.ts +6 -0
  289. package/src/view/app/lib/attempt-route.test.ts +1 -0
  290. package/src/view/app/lib/verdict.ts +7 -9
  291. package/src/view/artifact-serving.test.ts +2 -1
  292. package/src/view/client-dist/app.css +1 -1
  293. package/src/view/client-dist/app.js +17 -17
  294. package/src/view/data.test.ts +2 -1
  295. package/src/view/data.ts +17 -7
  296. package/src/view/index.ts +12 -1
  297. package/src/view/server.ts +2 -0
  298. package/src/view/shared/types.ts +1 -1
  299. package/src/view/styles.css +3 -0
  300. package/src/view/view-report.test.ts +11 -10
  301. package/dist/o11y/execution-tree.d.ts +0 -103
  302. package/dist/o11y/otlp/select.d.ts +0 -22
  303. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
  304. package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
  305. package/dist/results/annotated-source.d.ts +0 -61
  306. package/dist/results/attempt-evidence.d.ts +0 -69
  307. package/dist/results/attempt-source.d.ts +0 -15
  308. package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
  309. package/src/runner/reporters/console.ts +0 -70
  310. package/src/runner/reporters/live.test.ts +0 -56
  311. package/src/runner/reporters/live.ts +0 -247
  312. package/src/runner/reporters/quiet.test.ts +0 -66
  313. package/src/runner/reporters/quiet.ts +0 -49
  314. package/src/runner/reporters/table.ts +0 -277
@@ -0,0 +1,142 @@
1
+ // cases: docs/engineering/unit-tests/scoring/cases.md
2
+ // judge 解析与请求材料的单测:端点/凭据/模型解析结果必须进入真实请求,低分过不了 .gate()。
3
+ // fixture judge client = 截获 globalThis.fetch(autoevals 底层 openai client 走全局 fetch),
4
+ // 不起 HTTP server、不 spawn CLI。契约见 docs/feature/scoring/library/judge.md 与
5
+ // docs-site/zh/concepts/judge.mdx 的解析优先级表;用例登记在
6
+ // docs/engineering/unit-tests/scoring/cases.md 的 Judge 分区。
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { AssertionCollector } from "./collector.ts";
10
+ import { buildJudge } from "./judge.ts";
11
+ import { computeVerdict } from "./verdict.ts";
12
+ import { resolveAgentCoverage, completeCoverage } from "./coverage.ts";
13
+ import { emptyDiffData } from "./diff.ts";
14
+ import { deriveRunFacts } from "../o11y/derive.ts";
15
+ import type { JudgeConfig, ScoringContext } from "../types.ts";
16
+
17
+ function ctx(): ScoringContext {
18
+ return {
19
+ events: [],
20
+ facts: deriveRunFacts([]),
21
+ diff: emptyDiffData(),
22
+ scripts: {},
23
+ usage: { inputTokens: 0, outputTokens: 0 },
24
+ status: "completed",
25
+ coverage: resolveAgentCoverage(completeCoverage),
26
+ readFile: async () => undefined,
27
+ };
28
+ }
29
+
30
+ interface CapturedRequest {
31
+ url: string;
32
+ authorization: string | null;
33
+ body: { model?: string; messages?: Array<{ role: string; content: string }> };
34
+ }
35
+
36
+ /** 截获全局 fetch:记录请求,回一个 ClosedQA 选 "N"(score 0)的 chat completion。 */
37
+ function stubJudgeFetch(): CapturedRequest[] {
38
+ const captured: CapturedRequest[] = [];
39
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
40
+ const url = input instanceof Request ? input.url : String(input);
41
+ const headers = new Headers(input instanceof Request && !init?.headers ? input.headers : init?.headers);
42
+ const rawBody = init?.body ?? (input instanceof Request ? await input.text() : undefined);
43
+ captured.push({
44
+ url,
45
+ authorization: headers.get("authorization"),
46
+ body: typeof rawBody === "string" ? JSON.parse(rawBody) : {},
47
+ });
48
+ const payload = {
49
+ id: "chatcmpl-fixture",
50
+ object: "chat.completion",
51
+ created: 0,
52
+ model: "fixture",
53
+ choices: [
54
+ {
55
+ index: 0,
56
+ finish_reason: "tool_calls",
57
+ message: {
58
+ role: "assistant",
59
+ content: null,
60
+ tool_calls: [
61
+ {
62
+ id: "call_1",
63
+ type: "function",
64
+ function: {
65
+ name: "select_choice",
66
+ arguments: JSON.stringify({ choice: "N", reasons: "拒绝识图,答非所问" }),
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ },
72
+ ],
73
+ };
74
+ return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
75
+ });
76
+ return captured;
77
+ }
78
+
79
+ function judgeWith(judge: JudgeConfig | undefined) {
80
+ const collector = new AssertionCollector();
81
+ const ns = buildJudge({
82
+ record: (spec) => collector.record(spec),
83
+ judge,
84
+ getOutput: () => "很抱歉,我目前使用的模型不支持图像输入,无法查看你发送的图片。",
85
+ getInput: () => "这张图片里有什么?主要是什么颜色?",
86
+ });
87
+ return { collector, ns };
88
+ }
89
+
90
+ afterEach(() => {
91
+ vi.unstubAllGlobals();
92
+ vi.unstubAllEnvs();
93
+ });
94
+
95
+ describe("judge 端点/凭据/模型解析进入真实请求", () => {
96
+ it("NICEEVAL_JUDGE_BASE/KEY 落在请求 URL 与 Bearer 头;config model 压过 NICEEVAL_JUDGE_MODEL;score 0 过不了 .gate(0.7)", async () => {
97
+ vi.stubEnv("NICEEVAL_JUDGE_BASE", "http://judge.fixture.internal/v1");
98
+ vi.stubEnv("NICEEVAL_JUDGE_KEY", "fixture-key");
99
+ vi.stubEnv("NICEEVAL_JUDGE_MODEL", "env-model");
100
+ const captured = stubJudgeFetch();
101
+
102
+ const { collector, ns } = judgeWith({ model: "config-model" });
103
+ ns.autoevals.closedQA("助手是否描述了这张图片的内容,而不是答非所问?").gate(0.7);
104
+ const [result] = await collector.finalize(ctx());
105
+
106
+ expect(captured).toHaveLength(1);
107
+ expect(captured[0]!.url).toBe("http://judge.fixture.internal/v1/chat/completions");
108
+ expect(captured[0]!.authorization).toBe("Bearer fixture-key");
109
+ expect(captured[0]!.body.model).toBe("config-model");
110
+ // 请求材料:被评的 output 与 rubric 都要真的送到裁判面前。
111
+ const material = JSON.stringify(captured[0]!.body.messages ?? []);
112
+ expect(material).toContain("不支持图像输入");
113
+ expect(material).toContain("助手是否描述了这张图片的内容");
114
+
115
+ // 裁判给 0 分,.gate(0.7) 是硬要求:该条 failed,折叠后整个 attempt failed(与 --strict 无关)。
116
+ expect(result).toMatchObject({ severity: "gate", threshold: 0.7, outcome: "failed", score: 0 });
117
+ expect(computeVerdict({ assertions: [result!] })).toBe("failed");
118
+ });
119
+
120
+ it("单次 { model } 压过 judge config", async () => {
121
+ vi.stubEnv("NICEEVAL_JUDGE_KEY", "fixture-key");
122
+ const captured = stubJudgeFetch();
123
+
124
+ const { collector, ns } = judgeWith({ model: "config-model" });
125
+ ns.autoevals.closedQA("是否切题?", { model: "call-model" });
126
+ await collector.finalize(ctx());
127
+
128
+ expect(captured[0]!.body.model).toBe("call-model");
129
+ });
130
+
131
+ it("config 缺席时回落到 NICEEVAL_JUDGE_MODEL", async () => {
132
+ vi.stubEnv("NICEEVAL_JUDGE_KEY", "fixture-key");
133
+ vi.stubEnv("NICEEVAL_JUDGE_MODEL", "env-model");
134
+ const captured = stubJudgeFetch();
135
+
136
+ const { collector, ns } = judgeWith(undefined);
137
+ ns.autoevals.closedQA("是否切题?");
138
+ await collector.finalize(ctx());
139
+
140
+ expect(captured[0]!.body.model).toBe("env-model");
141
+ });
142
+ });
@@ -6,7 +6,7 @@
6
6
  // closedQA / factuality / summarizes 直接用 autoevals 库(braintrust)。
7
7
 
8
8
  import { ClosedQA, Factuality, Summary } from "autoevals";
9
- import type { EvalScore } from "./collector.ts";
9
+ import { unavailable, type EvalScore, type EvalUnavailable } from "./collector.ts";
10
10
  import type { AssertionHandle, AutoevalsNamespace, JudgeConfig, JudgeNamespace, ScoringContext } from "../types.ts";
11
11
  import { getEnv } from "../util.ts";
12
12
  import { t } from "../i18n/index.ts";
@@ -43,7 +43,7 @@ export interface JudgeDeps {
43
43
  record(spec: {
44
44
  name: string;
45
45
  severity: "soft";
46
- evaluate(ctx: ScoringContext): Promise<EvalScore>;
46
+ evaluate(ctx: ScoringContext): Promise<EvalScore | EvalUnavailable>;
47
47
  }): AssertionHandle;
48
48
  judge: JudgeConfig | undefined;
49
49
  getOutput: () => string;
@@ -52,17 +52,6 @@ export interface JudgeDeps {
52
52
  signal?: AbortSignal;
53
53
  }
54
54
 
55
- /** 没解析到 judge key 时返回的 no-op 命名空间:judge 断言静默跳过(不记录)。 */
56
- function noOpJudge(): JudgeNamespace {
57
- const handle: AssertionHandle = {
58
- atLeast: () => handle,
59
- gate: () => handle,
60
- };
61
- const skip = () => handle;
62
- const noOpAutoevals: AutoevalsNamespace = { closedQA: skip, factuality: skip, summarizes: skip };
63
- return { autoevals: noOpAutoevals };
64
- }
65
-
66
55
  /** 预检显式配置的 judge:验证 model + API key 存在,并发最小请求确认端点可达。
67
56
  * 返回错误描述字符串,可达则返回 undefined。*/
68
57
  export async function probeJudge(judge: JudgeConfig, signal?: AbortSignal): Promise<string | undefined> {
@@ -99,11 +88,12 @@ export async function probeJudge(judge: JudgeConfig, signal?: AbortSignal): Prom
99
88
  return undefined;
100
89
  }
101
90
 
102
- /** 构造 t.judge 命名空间。每个方法 record 一条延迟 soft 断言。 */
91
+ /** 构造 t.judge 命名空间。每个方法 record 一条延迟 soft 断言。
92
+ * 没解析到模型或 API key 时【不静默、不抛错】:该条断言照常记录,finalize 时落成
93
+ * `outcome: "unavailable"`(带机器可读 reason)——rubric 写了就必须留下记录,评不了的
94
+ * 结论按 Severity 与 Verdict 的折叠规则使 attempt errored(除非作者链 `.optional()`)。 */
103
95
  export function buildJudge(deps: JudgeDeps): JudgeNamespace {
104
96
  const resolved = resolveJudge(deps.judge);
105
- // 没 key 就静默跳过 judge —— eval 不必再手动 gate「环境里有没有 judge key」。
106
- if (!resolved.apiKey) return noOpJudge();
107
97
 
108
98
  const materialFor = async (ctx: ScoringContext, on?: string): Promise<string> => {
109
99
  if (on) {
@@ -124,16 +114,23 @@ export function buildJudge(deps: JudgeDeps): JudgeNamespace {
124
114
 
125
115
  // 三个 autoevals 方法只差评分器和材料字段名,共享行为(record spec / 材料构造 /
126
116
  // 分数归一 / evidence)单一出处。model 解析:单次 { model } → judge config →
127
- // NICEEVAL_JUDGE_MODEL;都没有是配置错误,调用点即报(不静默跳过,会藏住误配)
117
+ // NICEEVAL_JUDGE_MODEL;没解析到模型或 key 时该条记 unavailable(带 reason),
118
+ // 绝不静默消失、也不在调用点崩——评不了的折叠交给 Severity 与 Verdict 规则。
128
119
  const makeAutoeval =
129
120
  (kind: "closedQA" | "factuality" | "summarizes", scorer: Scorer, payloadKey: "criteria" | "expected") =>
130
121
  (reference: string, opts?: { on?: string; model?: string }) => {
131
122
  const model = opts?.model ?? resolved.model;
132
- if (!model) throw new Error(t("judge.modelMissing"));
133
123
  return deps.record({
134
124
  name: `judge:autoevals:${kind}`,
135
125
  severity: "soft",
136
126
  evaluate: async (ctx) => {
127
+ if (!model) {
128
+ return unavailable("judge-model-unresolved (no model in config, NICEEVAL_JUDGE_MODEL unset)");
129
+ }
130
+ if (!resolved.apiKey) {
131
+ const envHint = deps.judge?.apiKeyEnv ?? "NICEEVAL_JUDGE_KEY / OPENAI_API_KEY";
132
+ return unavailable(`judge-key-unresolved (${envHint} unset)`);
133
+ }
137
134
  const output = await materialFor(ctx, opts?.on);
138
135
  const result = await scorer({
139
136
  input: deps.getInput(),
@@ -1,8 +1,26 @@
1
1
  // 作用域断言:读标准事件流的派生事实(toolCalls / parked …)、diff、脚本结果。
2
2
  // 每个 builder 产一个延迟 Spec,context 负责 record。规则覆盖不到的奇怪断言可直接落 events。
3
-
4
- import type { Spec } from "./collector.ts";
5
- import type { DiffData, ScoringContext, StreamEvent, SubagentCall, SubagentMatch, ToolCall, ToolMatch } from "../types.ts";
3
+ //
4
+ // 证据覆盖的三值折叠(见 docs/feature/scoring/architecture/evidence.md):
5
+ // - 正断言:找到匹配即通过(证据存在就是证据);没找到且所需通道非 complete(含 unknown)
6
+ // 记 unavailable——「没采到」不能算成「Agent 没做」;complete 通道上没找到才是 failed。
7
+ // - 负断言:找到反例即 failed(反例是确凿证据);没找到反例且通道非 complete 记 unavailable——
8
+ // 空流证明不了「没发生」。
9
+ // - 上限断言:实测已超限即 failed(partial 只会少采,超限是确凿的);未超限且通道非 complete
10
+ // 记 unavailable——缺证据不能按零聚合。
11
+
12
+ import { unavailable, type EvalUnavailable, type Spec } from "./collector.ts";
13
+ import type { CoverageChannel } from "./coverage.ts";
14
+ import type { ScoringContext, StreamEvent, SubagentCall, SubagentMatch, ToolCall, ToolMatch } from "../types.ts";
15
+
16
+ // ── 覆盖折叠 ──
17
+
18
+ /** 所需通道非 complete 时返回 unavailable(带机器可读 reason),complete 返回 undefined。 */
19
+ function coverageGap(ctx: ScoringContext, channel: CoverageChannel): EvalUnavailable | undefined {
20
+ const c = ctx.coverage[channel];
21
+ if (c.status === "complete") return undefined;
22
+ return unavailable(`coverage:${channel}=${c.status}${c.reason ? ` (${c.reason})` : ""}`);
23
+ }
6
24
 
7
25
  // ── 工具匹配小语言 ──
8
26
 
@@ -49,7 +67,7 @@ function toolMatches(tc: ToolCall, name: string, match?: ToolMatch): boolean {
49
67
  return true;
50
68
  }
51
69
 
52
- // ── evidence:把调用的出入参带回断言结果,view 展开可见,不用翻原始事件流 ──
70
+ // ── received:把调用的出入参带回断言结果,view 展开可见,不用翻原始事件流 ──
53
71
 
54
72
  function briefJson(value: unknown, max = 800): string {
55
73
  let s: string;
@@ -95,18 +113,50 @@ function subagentMatches(call: SubagentCall, name: string, match?: SubagentMatch
95
113
  return true;
96
114
  }
97
115
 
116
+ /** ToolMatch 的期望描述(`≥1 call matching input.city = "Brooklyn"` 之类)。 */
117
+ function describeToolExpectation(name: string, match?: ToolMatch): string {
118
+ const conditions: string[] = [];
119
+ if (match?.input) {
120
+ for (const [k, v] of Object.entries(match.input)) conditions.push(`input.${k} = ${briefJson(v, 120)}`);
121
+ }
122
+ if (match?.status) conditions.push(`status = ${match.status}`);
123
+ const cond = conditions.length ? ` matching ${conditions.join(", ")}` : "";
124
+ const count = match?.count !== undefined ? `exactly ${match.count} calls of ${name}` : `≥1 call of ${name}`;
125
+ return `${count}${cond}`;
126
+ }
127
+
98
128
  // ── builders ──
99
129
 
100
130
  export function succeeded(): Spec {
101
131
  return {
102
132
  name: "succeeded",
103
133
  severity: "gate",
104
- evaluate: (ctx) => (ctx.status !== "failed" && !ctx.facts.parked ? 1 : 0),
134
+ evaluate: (ctx) => {
135
+ // status 通道非 complete(恒 completed 的映射)时,末态不可信,通过与失败都评不了。
136
+ const gap = coverageGap(ctx, "status");
137
+ if (gap) return gap;
138
+ const ok = ctx.status !== "failed" && !ctx.facts.parked;
139
+ if (ok) return 1;
140
+ return {
141
+ score: 0,
142
+ received: ctx.facts.parked
143
+ ? `${ctx.facts.inputRequests.length || 1} unanswered input request`
144
+ : `status: ${ctx.status}`,
145
+ };
146
+ },
105
147
  };
106
148
  }
107
149
 
108
150
  export function parked(): Spec {
109
- return { name: "parked", severity: "gate", evaluate: (ctx) => (ctx.facts.parked ? 1 : 0) };
151
+ return {
152
+ name: "parked",
153
+ severity: "gate",
154
+ evaluate: (ctx) => {
155
+ const gap = coverageGap(ctx, "status");
156
+ if (gap) return gap;
157
+ return ctx.facts.parked ? 1 : { score: 0, received: `status: ${ctx.status} (no pending input request)` };
158
+ },
159
+ };
110
160
  }
111
161
 
112
162
  export function messageIncludes(token: string | RegExp): Spec {
@@ -120,8 +170,15 @@ export function messageIncludes(token: string | RegExp): Spec {
120
170
  .map((e) => e.text)
121
171
  .join("\n");
122
172
  const ok = token instanceof RegExp ? token.test(text) : text.includes(token);
123
- // 失败时把实际被扫的助手文本带回来( t.check 的口径一致);命中时行色已说明一切。
124
- return ok ? 1 : { score: 0, evidence: text ? (text.length > 4000 ? text.slice(0, 4000) + "…" : text) : undefined };
173
+ if (ok) return 1;
174
+ // 正断言:非 complete 通道上没找到记 unavailable,不判失败。
175
+ const gap = coverageGap(ctx, "messages");
176
+ if (gap) return gap;
177
+ return {
178
+ score: 0,
179
+ expected: token instanceof RegExp ? `matches ${token}` : `contains ${JSON.stringify(token)}`,
180
+ received: text ? (text.length > 4000 ? text.slice(0, 4000) + "…" : text) : "(no assistant messages)",
181
+ };
125
182
  },
126
183
  };
127
184
  }
@@ -133,11 +190,24 @@ export function calledTool(name: string, match?: ToolMatch): Spec {
133
190
  evaluate: (ctx) => {
134
191
  const matched = ctx.facts.toolCalls.filter((tc) => toolMatches(tc, name, match));
135
192
  const n = matched.length;
136
- const score = match?.count !== undefined ? (n === match.count ? 1 : 0) : n >= 1 ? 1 : 0;
193
+ const ok = match?.count !== undefined ? n === match.count : n >= 1;
137
194
  // 命中给命中调用的出入参;没命中给同名调用(条件不满足的近失);再没有就列出实际调过的工具。
138
195
  const sameName = ctx.facts.toolCalls.filter((tc) => tc.name === name || tc.originalName === name);
139
196
  const shown = matched.length ? matched : sameName.length ? sameName : ctx.facts.toolCalls;
140
- return { score, evidence: describeCalls(shown) };
197
+ if (ok) return { score: 1, received: describeCalls(shown) };
198
+ // 精确 count 且实测已超出:partial 只会少采,超出是确凿失败;其余未命中按覆盖折叠。
199
+ const definitiveOvershoot = match?.count !== undefined && n > match.count;
200
+ if (!definitiveOvershoot) {
201
+ const gap = coverageGap(ctx, "actions");
202
+ if (gap) return gap;
203
+ }
204
+ return {
205
+ score: 0,
206
+ expected: describeToolExpectation(name, match),
207
+ received:
208
+ describeCalls(shown) ??
209
+ `${ctx.facts.toolCalls.length} tool calls, none matching`,
210
+ };
141
211
  },
142
212
  };
143
213
  }
@@ -148,7 +218,11 @@ export function notCalledTool(name: string, match?: ToolMatch): Spec {
148
218
  severity: "gate",
149
219
  evaluate: (ctx) => {
150
220
  const matched = ctx.facts.toolCalls.filter((tc) => toolMatches(tc, name, match));
151
- return { score: matched.length === 0 ? 1 : 0, evidence: describeCalls(matched) };
221
+ // 负断言:找到反例即 failed(证据确凿),没找到时空流证明不了「没发生」。
222
+ if (matched.length > 0) return { score: 0, received: describeCalls(matched) };
223
+ const gap = coverageGap(ctx, "actions");
224
+ if (gap) return gap;
225
+ return 1;
152
226
  },
153
227
  };
154
228
  }
@@ -162,8 +236,15 @@ export function toolOrder(names: string[]): Spec {
162
236
  for (const tc of ctx.facts.toolCalls) {
163
237
  if (i < names.length && (tc.name === names[i] || tc.originalName === names[i])) i++;
164
238
  }
239
+ if (i === names.length) return 1;
240
+ const gap = coverageGap(ctx, "actions");
241
+ if (gap) return gap;
165
242
  const actual = ctx.facts.toolCalls.map((tc) => tc.originalName ?? tc.name).join(" → ");
166
- return { score: i === names.length ? 1 : 0, evidence: actual || undefined };
243
+ return {
244
+ score: 0,
245
+ expected: names.join(" → "),
246
+ received: actual ? `${actual} (missing ${names[i]})` : "(no tool calls)",
247
+ };
167
248
  },
168
249
  };
169
250
  }
@@ -172,10 +253,12 @@ export function usedNoTools(): Spec {
172
253
  return {
173
254
  name: "usedNoTools",
174
255
  severity: "gate",
175
- evaluate: (ctx) => ({
176
- score: ctx.facts.toolCalls.length === 0 ? 1 : 0,
177
- evidence: describeCalls(ctx.facts.toolCalls),
178
- }),
256
+ evaluate: (ctx) => {
257
+ if (ctx.facts.toolCalls.length > 0) return { score: 0, received: describeCalls(ctx.facts.toolCalls) };
258
+ const gap = coverageGap(ctx, "actions");
259
+ if (gap) return gap;
260
+ return 1;
261
+ },
179
262
  };
180
263
  }
181
264
 
@@ -183,10 +266,14 @@ export function maxToolCalls(max: number): Spec {
183
266
  return {
184
267
  name: `maxToolCalls(${max})`,
185
268
  severity: "gate",
186
- evaluate: (ctx) => ({
187
- score: ctx.facts.toolCalls.length <= max ? 1 : 0,
188
- evidence: describeCalls(ctx.facts.toolCalls),
189
- }),
269
+ evaluate: (ctx) => {
270
+ const n = ctx.facts.toolCalls.length;
271
+ // 上限断言:实测已超限是确凿失败;未超限时,partial 通道可能漏采,不能按不完整计数放行。
272
+ if (n > max) return { score: 0, expected: `≤ ${max} tool calls`, received: describeCalls(ctx.facts.toolCalls) };
273
+ const gap = coverageGap(ctx, "actions");
274
+ if (gap) return gap;
275
+ return 1;
276
+ },
190
277
  };
191
278
  }
192
279
 
@@ -199,9 +286,15 @@ export function loadedSkill(skill: string): Spec {
199
286
  evaluate: (ctx) => {
200
287
  const loaded = ctx.events.filter((e): e is Extract<StreamEvent, { type: "skill.loaded" }> => e.type === "skill.loaded");
201
288
  const matched = loaded.filter((e) => e.skill === skill);
289
+ if (matched.length) return { score: 1, received: matched.map((e) => e.skill).join(", ") };
290
+ const gap = coverageGap(ctx, "events");
291
+ if (gap) return gap;
202
292
  // 没命中时把实际加载过的 skill 列出来(常见失败是名字对不上,而不是一个都没加载)。
203
- const shown = matched.length ? matched : loaded;
204
- return { score: matched.length ? 1 : 0, evidence: shown.length ? shown.map((e) => e.skill).join(", ") : undefined };
293
+ return {
294
+ score: 0,
295
+ expected: `skill ${JSON.stringify(skill)} loaded`,
296
+ received: loaded.length ? loaded.map((e) => e.skill).join(", ") : "(no skills loaded)",
297
+ };
205
298
  },
206
299
  };
207
300
  }
@@ -213,8 +306,13 @@ export function noFailedActions(): Spec {
213
306
  evaluate: (ctx) => {
214
307
  const failedTools = ctx.facts.toolCalls.filter((tc) => tc.status === "failed");
215
308
  const failedSubs = ctx.facts.subagentCalls.filter((s) => s.status === "failed");
216
- const evidence = [describeCalls(failedTools), describeSubagents(failedSubs)].filter(Boolean).join("\n") || undefined;
217
- return { score: failedTools.length || failedSubs.length ? 0 : 1, evidence };
309
+ if (failedTools.length || failedSubs.length) {
310
+ const received = [describeCalls(failedTools), describeSubagents(failedSubs)].filter(Boolean).join("\n") || undefined;
311
+ return { score: 0, received };
312
+ }
313
+ const gap = coverageGap(ctx, "actions");
314
+ if (gap) return gap;
315
+ return 1;
218
316
  },
219
317
  };
220
318
  }
@@ -226,8 +324,14 @@ export function calledSubagent(name: string, match?: SubagentMatch): Spec {
226
324
  evaluate: (ctx) => {
227
325
  const matched = ctx.facts.subagentCalls.filter((call) => subagentMatches(call, name, match));
228
326
  const n = matched.length;
229
- const score = match?.count !== undefined ? (n === match.count ? 1 : 0) : n >= 1 ? 1 : 0;
230
- return { score, evidence: describeSubagents(matched.length ? matched : ctx.facts.subagentCalls) };
327
+ const ok = match?.count !== undefined ? n === match.count : n >= 1;
328
+ if (ok) return { score: 1, received: describeSubagents(matched) };
329
+ const definitiveOvershoot = match?.count !== undefined && n > match.count;
330
+ if (!definitiveOvershoot) {
331
+ const gap = coverageGap(ctx, "actions");
332
+ if (gap) return gap;
333
+ }
334
+ return { score: 0, received: describeSubagents(matched.length ? matched : ctx.facts.subagentCalls) };
231
335
  },
232
336
  };
233
337
  }
@@ -238,8 +342,18 @@ export function eventOfType(type: string, opts?: { count?: number }): Spec {
238
342
  severity: "gate",
239
343
  evaluate: (ctx) => {
240
344
  const n = ctx.events.filter((e) => e.type === type).length;
241
- if (opts?.count !== undefined) return n === opts.count ? 1 : 0;
242
- return n >= 1 ? 1 : 0;
345
+ const ok = opts?.count !== undefined ? n === opts.count : n >= 1;
346
+ if (ok) return 1;
347
+ const definitiveOvershoot = opts?.count !== undefined && n > opts.count;
348
+ if (!definitiveOvershoot) {
349
+ const gap = coverageGap(ctx, "events");
350
+ if (gap) return gap;
351
+ }
352
+ return {
353
+ score: 0,
354
+ expected: opts?.count !== undefined ? `exactly ${opts.count} × ${type}` : `≥1 × ${type}`,
355
+ received: `${n} × ${type}`,
356
+ };
243
357
  },
244
358
  };
245
359
  }
@@ -248,7 +362,13 @@ export function notEventOfType(type: string): Spec {
248
362
  return {
249
363
  name: `notEvent(${type})`,
250
364
  severity: "gate",
251
- evaluate: (ctx) => (ctx.events.some((e) => e.type === type) ? 0 : 1),
365
+ evaluate: (ctx) => {
366
+ const hits = ctx.events.filter((e) => e.type === type);
367
+ if (hits.length > 0) return { score: 0, received: `${hits.length} × ${type}` };
368
+ const gap = coverageGap(ctx, "events");
369
+ if (gap) return gap;
370
+ return 1;
371
+ },
252
372
  };
253
373
  }
254
374
 
@@ -261,39 +381,61 @@ export function eventOrder(types: StreamEvent["type"][]): Spec {
261
381
  for (const ev of ctx.events) {
262
382
  if (i < types.length && ev.type === types[i]) i++;
263
383
  }
264
- return i === types.length ? 1 : 0;
384
+ if (i === types.length) return 1;
385
+ const gap = coverageGap(ctx, "events");
386
+ if (gap) return gap;
387
+ return {
388
+ score: 0,
389
+ expected: types.join(" → "),
390
+ received: `missing ${types[i]} (matched ${i}/${types.length})`,
391
+ };
265
392
  },
266
393
  };
267
394
  }
268
395
 
396
+ /** label 是失败时的全部解释(谓词不透明),必填、进断言标题。 */
269
397
  export function eventsSatisfy(
398
+ label: string,
270
399
  predicate: (events: readonly StreamEvent[]) => boolean,
271
- label = "predicate",
272
400
  ): Spec {
401
+ if (typeof label !== "string" || label.trim().length === 0 || typeof predicate !== "function") {
402
+ throw new TypeError(
403
+ "eventsSatisfy(label, predicate) requires a non-empty string label followed by a predicate function; " +
404
+ `received (${typeof label}, ${typeof predicate}). The former (predicate, label) order is not supported.`,
405
+ );
406
+ }
273
407
  return {
274
- name: `eventsSatisfy(${label})`,
408
+ name: label,
275
409
  severity: "gate",
276
- evaluate: (ctx) => (predicate(ctx.events) ? 1 : 0),
410
+ evaluate: (ctx) => {
411
+ if (predicate(ctx.events)) return 1;
412
+ const gap = coverageGap(ctx, "events");
413
+ if (gap) return gap;
414
+ return { score: 0, received: `${ctx.events.length} events in scope` };
415
+ },
277
416
  };
278
417
  }
279
418
 
280
- // ── 工作区 / 沙箱 ──
281
-
282
- function diffMatchesRe(diff: DiffData, re: RegExp): boolean {
283
- for (const [path, content] of Object.entries(diff.generatedFiles)) {
284
- if (re.test(path) || re.test(content)) return true;
285
- }
286
- for (const path of diff.deletedFiles) {
287
- if (re.test(path)) return true;
288
- }
289
- return false;
290
- }
419
+ // ── 工作区 / 沙箱(断的是 agent 归因增量,见 docs/feature/sandbox/architecture.md)──
291
420
 
292
421
  export function fileChanged(path: string): Spec {
293
422
  return {
294
423
  name: `fileChanged(${path})`,
295
424
  severity: "gate",
296
- evaluate: (ctx) => (ctx.diff.generatedFiles[path] !== undefined ? 1 : 0),
425
+ // 断「任一 send 窗口触及」(行为证据):净效果为 none(改完又改回)也算发生过。
426
+ evaluate: (ctx) => {
427
+ const summary = ctx.diff.files[path];
428
+ if (summary !== undefined && summary.net !== "deleted") return 1;
429
+ const windows = ctx.diff.windows.length;
430
+ return {
431
+ score: 0,
432
+ expected: "changed by agent in some send window",
433
+ received:
434
+ summary !== undefined
435
+ ? `net effect: ${summary.net} (touched in ${summary.windows.join(", ")})`
436
+ : `not changed in any of ${windows} send window${windows === 1 ? "" : "s"}`,
437
+ };
438
+ },
297
439
  };
298
440
  }
299
441
 
@@ -301,7 +443,10 @@ export function fileDeleted(path: string): Spec {
301
443
  return {
302
444
  name: `fileDeleted(${path})`,
303
445
  severity: "gate",
304
- evaluate: (ctx) => (ctx.diff.deletedFiles.includes(path) ? 1 : 0),
446
+ evaluate: (ctx) =>
447
+ ctx.diff.files[path]?.net === "deleted"
448
+ ? 1
449
+ : { score: 0, expected: "deleted by agent", received: ctx.diff.files[path] ? `net effect: ${ctx.diff.files[path]!.net}` : "not touched by agent" },
305
450
  };
306
451
  }
307
452
 
@@ -309,7 +454,19 @@ export function notInDiff(re: RegExp): Spec {
309
454
  return {
310
455
  name: `notInDiff(${re})`,
311
456
  severity: "gate",
312
- evaluate: (ctx) => (diffMatchesRe(ctx.diff, re) ? 0 : 1),
457
+ evaluate: (ctx) => {
458
+ for (const path of Object.keys(ctx.diff.files)) {
459
+ if (re.test(path)) return { score: 0, received: `matched path ${path}` };
460
+ }
461
+ for (const window of ctx.diff.windows) {
462
+ for (const [path, change] of Object.entries(window.changes)) {
463
+ if (change.after !== undefined && re.test(change.after)) {
464
+ return { score: 0, received: `matched in ${path} (window ${window.window})` };
465
+ }
466
+ }
467
+ }
468
+ return 1;
469
+ },
313
470
  };
314
471
  }
315
472
 
@@ -319,7 +476,10 @@ export function noFailedShellCommands(): Spec {
319
476
  severity: "gate",
320
477
  evaluate: (ctx) => {
321
478
  const failed = ctx.facts.toolCalls.filter((tc) => tc.name === "shell" && tc.status === "failed");
322
- return { score: failed.length ? 0 : 1, evidence: describeCalls(failed) };
479
+ if (failed.length) return { score: 0, received: describeCalls(failed) };
480
+ const gap = coverageGap(ctx, "actions");
481
+ if (gap) return gap;
482
+ return 1;
323
483
  },
324
484
  };
325
485
  }
@@ -332,7 +492,11 @@ export function maxTokens(max: number): Spec {
332
492
  severity: "gate",
333
493
  evaluate: (ctx) => {
334
494
  const total = ctx.usage.inputTokens + ctx.usage.outputTokens;
335
- return total <= max ? 1 : 0;
495
+ // 上限断言:实测已超限是确凿失败;未超限时缺 usage 不能按零聚合。
496
+ if (total > max) return { score: 0, expected: `≤ ${max} tokens`, received: `${total} tokens` };
497
+ const gap = coverageGap(ctx, "usage");
498
+ if (gap) return gap;
499
+ return 1;
336
500
  },
337
501
  };
338
502
  }
@@ -343,7 +507,10 @@ export function maxCost(usd: number): Spec {
343
507
  severity: "gate",
344
508
  evaluate: (ctx) => {
345
509
  const cost = ctx.usage.costUSD ?? 0;
346
- return cost <= usd ? 1 : 0;
510
+ if (cost > usd) return { score: 0, expected: `≤ $${usd}`, received: `$${cost.toFixed(4)}` };
511
+ const gap = coverageGap(ctx, "usage");
512
+ if (gap) return gap;
513
+ return 1;
347
514
  },
348
515
  };
349
516
  }