modelfunction-cli 0.5.3 → 0.5.4

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- npx -y modelfunction-cli@0.5.1 --version
8
+ npx -y modelfunction-cli@0.5.3 --version
9
9
  # 或
10
10
  npm i -D modelfunction-cli
11
11
  npx mf --version
@@ -126,7 +126,7 @@ mf eval --suite mf.suite.json --models internal-ds \
126
126
  "mcpServers": {
127
127
  "modelfunction": {
128
128
  "command": "npx",
129
- "args": ["-y", "modelfunction-cli@0.5.1", "mcp"]
129
+ "args": ["-y", "modelfunction-cli@0.5.3", "mcp"]
130
130
  }
131
131
  }
132
132
  }
@@ -138,7 +138,9 @@ mf eval --suite mf.suite.json --models internal-ds \
138
138
 
139
139
  对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
140
140
 
141
- `0.5.2` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查,门禁 warn 只保留混淆对/必填来源/结构性错误。本仓库 `0.5.3` 待发布:静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms,550 能力 9.6s→2.1s,输出与 golden 一致),以及 `installed` 形状校验(接受 `mf scan --json` 输出,无法识别时报错而不是静默返回假 pass)和 `profiles` 兼容平台形态(对象或 `[{agentType, tools, skills}]` 数组)。示例统一使用 `@0.5.2`。
141
+ `0.5.3` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查、门禁严重度收敛、静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms)、`installed` 形状校验与 `profiles` 平台形态兼容。示例统一使用 `@0.5.3`。
142
+
143
+ `0.5.4`(待发布):用例新增 `excludeFromBase` / `excludeFromProfiles`(布尔,排除计分但不省调用);评分卡新增行级 `errorKind` 与 `errors` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`;挂载对比不再把超时/HTTP 错误记成 jammed,分组表单列 `errors`;`mf audit` 分组表增加 `neg%`。契约见 `SCHEMA.md`。
142
144
 
143
145
  ## 数据边界
144
146
 
@@ -167,7 +169,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
167
169
 
168
170
  ```bash
169
171
  # 1. 把 mf 挂成 MCP server
170
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.1 mcp
172
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.3 mcp
171
173
  ```
172
174
 
173
175
  ```text
package/SCHEMA.md CHANGED
@@ -51,6 +51,8 @@
51
51
  "note": "",
52
52
  "source": "manual",
53
53
  "reviewed": true,
54
+ "excludeFromBase": false,
55
+ "excludeFromProfiles": false,
54
56
  "tags": []
55
57
  }
56
58
  ```
@@ -59,6 +61,7 @@
59
61
  - `expectedArgs` 仅在有 `expectedTool` 时可用;值为断言,支持字面量、`$contains`、`$oneOf`、`$exists`。
60
62
  - `toolInput` + `expect` 用于 `--execute`:`expect` 支持 `ok`、`contains`、`equals`、`errorType`、`maxDurationMs`、`maxPeakRssMB`。
61
63
  - `reviewed` 缺省为 `true`(手写用例);`mf case add` 缺省写入 `source=agent`、`reviewed=false`。
64
+ - `excludeFromBase` / `excludeFromProfiles` 必须是布尔值。用例仍会实际调用(不省 token),只是不计入对应范围的正式指标;用于「该用例只对某一挂载形态有意义」的情况。
62
65
 
63
66
  ### skills[]
64
67
 
@@ -89,8 +92,10 @@ skills 只做静态检查,不参与模型评测。
89
92
  "fingerprint": "sha256:...",
90
93
  "execution": { "runtime": "node", "engine": "node-permission", "entry": "index.mjs", "hash": "..." }
91
94
  },
92
- "models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "selectionAccuracy": 0.83,
93
- "argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "p50LatencyMs": 900,
95
+ "models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "excludedTotal": 0,
96
+ "selectionAccuracy": 0.83, "selectionAccuracyExcludingErrors": 0.83,
97
+ "argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "errorsByKind": {},
98
+ "scoredTotal": 12, "p50LatencyMs": 900,
94
99
  "p95LatencyMs": 1800, "avgCostCny": 0.001, "totalCostCny": 0.012,
95
100
  "inputTokens": 1000, "cachedTokens": 0, "outputTokens": 200,
96
101
  "rows": [ { "id": "c1", "expectedTool": "...", "actualTool": "...",
@@ -101,9 +106,10 @@ skills 只做静态检查,不参与模型评测。
101
106
  }
102
107
  ```
103
108
 
104
- - 正式指标只统计 `reviewed` 行;未复核结果单独给出。
109
+ - 正式指标只统计 `reviewed` 且未被 `excludeFromBase` 排除的行;未复核与排除结果单独给出。
110
+ - `errors` 是调用层失败(超时、HTTP、网络)的数量,按 `errorsByKind` 分类;它们仍计入 `selectionAccuracy`(保守口径),同时给出排除错误后的 `selectionAccuracyExcludingErrors` 与可评分行数 `scoredTotal`。
105
111
  - `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
106
- - `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`。
112
+ - `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`;错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
107
113
  - `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
108
114
 
109
115
  ## 3. 扫描输出(`mf scan`)
@@ -338,4 +344,4 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
338
344
  }
339
345
  ```
340
346
 
341
- `profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程。
347
+ `profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程;负例占比告警只按整 suite 计算,分组表里的 `negativeRatio` 是该组「正例 + 负例」内的比例,仅供参考。
package/lib/audit.js CHANGED
@@ -134,11 +134,11 @@ export function formatAudit(report) {
134
134
  if (!profiles) {
135
135
  lines.push("未定义 profiles(平台可先用 `mf cluster --out <suite>` 生成候选分组)。");
136
136
  } else {
137
- lines.push("group tools cases negatives tools-without-cases");
138
- lines.push("---------------------- ----- ----- --------- -------------------");
137
+ lines.push("group tools cases negatives neg% tools-without-cases");
138
+ lines.push("---------------------- ----- ----- --------- ---- -------------------");
139
139
  for (const row of profiles.groups) {
140
140
  lines.push(
141
- `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${row.missing.length ? row.missing.join(", ") : "-"}`
141
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
142
142
  );
143
143
  }
144
144
  if (profiles.unmounted.length > 0) lines.push("", `未挂载工具:${profiles.unmounted.join(", ")}`);
package/lib/cases.js CHANGED
@@ -185,12 +185,12 @@ export function formatCoverage(report) {
185
185
  );
186
186
  }
187
187
  if (report.profiles && report.profiles.length > 0) {
188
- lines.push("", "profiles(分组后可评分的用例;负例每组都跑):");
189
- lines.push("group tools cases neg tools-without-cases");
190
- lines.push("---------------------- ----- ----- --- -------------------");
188
+ lines.push("", "profiles(分组后可评分的用例;负例每组都跑,比例按组内正例+负例计):");
189
+ lines.push("group tools cases neg neg% tools-without-cases");
190
+ lines.push("---------------------- ----- ----- --- ---- -------------------");
191
191
  for (const row of report.profiles) {
192
192
  lines.push(
193
- `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${row.missing.length ? row.missing.join(", ") : "-"}`
193
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
194
194
  );
195
195
  }
196
196
  }
package/lib/profile.js CHANGED
@@ -54,7 +54,11 @@ export function selectProfiles(profiles, wanted) {
54
54
  // A case belongs to a group when its expected tool is mounted there. Negative
55
55
  // cases ("no tool applies") hold everywhere: the group's agent still gets them.
56
56
  export function selectProfileCases(cases, mountedTools) {
57
- return cases.filter((testCase) => testCase.expectedTool === null || mountedTools.has(testCase.expectedTool));
57
+ return cases.filter(
58
+ (testCase) =>
59
+ testCase.excludeFromProfiles !== true &&
60
+ (testCase.expectedTool === null || mountedTools.has(testCase.expectedTool))
61
+ );
58
62
  }
59
63
 
60
64
  export function profileWarnings(profiles, suite) {
@@ -76,11 +80,12 @@ export function profileWarnings(profiles, suite) {
76
80
  }
77
81
 
78
82
  export function profileCaseCoverage(profiles, cases) {
79
- const negatives = cases.filter((testCase) => testCase.expectedTool === null).length;
83
+ const considered = cases.filter((testCase) => testCase.excludeFromProfiles !== true);
84
+ const negatives = considered.filter((testCase) => testCase.expectedTool === null).length;
80
85
  return profiles.map((profile) => {
81
86
  const mounted = new Set(profile.tools);
82
87
  const counts = new Map(profile.tools.map((name) => [name, 0]));
83
- for (const testCase of cases) {
88
+ for (const testCase of considered) {
84
89
  if (testCase.expectedTool !== null && mounted.has(testCase.expectedTool)) {
85
90
  counts.set(testCase.expectedTool, counts.get(testCase.expectedTool) + 1);
86
91
  }
@@ -88,7 +93,14 @@ export function profileCaseCoverage(profiles, cases) {
88
93
  const missing = [...counts.entries()].filter(([, count]) => count === 0).map(([name]) => name);
89
94
  let total = 0;
90
95
  for (const count of counts.values()) total += count;
91
- return { name: profile.name, tools: profile.tools.length, cases: total, negatives, missing };
96
+ return {
97
+ name: profile.name,
98
+ tools: profile.tools.length,
99
+ cases: total,
100
+ negatives,
101
+ negativeRatio: total + negatives > 0 ? negatives / (total + negatives) : 0,
102
+ missing
103
+ };
92
104
  });
93
105
  }
94
106
 
@@ -99,18 +111,21 @@ export function analyzeMount(profile, mounted, allInOne) {
99
111
  for (const before of allInOne.rows) {
100
112
  const row = after.get(before.id);
101
113
  if (!row || before.reviewed === false || row.reviewed === false) continue;
114
+ // A provider error is not evidence about mounting; score it separately.
115
+ if (before.error || row.error) continue;
102
116
  if (!before.selectionCorrect && row.selectionCorrect) lifted.push(changeOf(before, row));
103
117
  else if (before.selectionCorrect && !row.selectionCorrect) jammed.push(changeOf(before, row));
104
118
  }
105
119
 
106
120
  const mountedMetrics = metricsOf(mounted);
107
121
  const allMetrics = metricsOf(allInOne);
108
- const reviewed = mounted.reviewedTotal;
122
+ const reviewed = mountedMetrics.scored;
109
123
  return {
110
124
  profile: profile.name,
111
125
  mountedTools: profile.tools.length,
112
126
  cases: mounted.total,
113
127
  reviewed,
128
+ errors: mountedMetrics.errors,
114
129
  mounted: mountedMetrics,
115
130
  allInOne: allMetrics,
116
131
  delta: {
@@ -128,32 +143,44 @@ export function analyzeMount(profile, mounted, allInOne) {
128
143
 
129
144
  export function modelTotals(entries) {
130
145
  let reviewed = 0;
146
+ let errors = 0;
131
147
  let mountedCorrect = 0;
132
148
  let allCorrect = 0;
149
+ const usage = { inputTokens: 0, cachedTokens: 0, outputTokens: 0, costCny: 0, estimated: false };
133
150
  for (const entry of entries) {
134
151
  reviewed += entry.reviewed;
152
+ errors += entry.errors ?? 0;
135
153
  mountedCorrect += entry.mounted.correct;
136
154
  allCorrect += entry.allInOne.correct;
155
+ usage.inputTokens += entry.usage?.inputTokens ?? 0;
156
+ usage.cachedTokens += entry.usage?.cachedTokens ?? 0;
157
+ usage.outputTokens += entry.usage?.outputTokens ?? 0;
158
+ usage.costCny += entry.usage?.costCny ?? 0;
159
+ usage.estimated ||= Boolean(entry.usage?.estimated);
137
160
  }
138
161
  if (reviewed === 0) {
139
162
  return {
140
163
  reviewed: 0,
164
+ errors,
141
165
  mountedCorrect: 0,
142
166
  allCorrect: 0,
143
167
  mountedAccuracy: null,
144
168
  allAccuracy: null,
145
- delta: null
169
+ delta: null,
170
+ usage
146
171
  };
147
172
  }
148
173
  const mountedAccuracy = mountedCorrect / reviewed;
149
174
  const allAccuracy = allCorrect / reviewed;
150
175
  return {
151
176
  reviewed,
177
+ errors,
152
178
  mountedCorrect,
153
179
  allCorrect,
154
180
  mountedAccuracy,
155
181
  allAccuracy,
156
- delta: mountedAccuracy - allAccuracy
182
+ delta: mountedAccuracy - allAccuracy,
183
+ usage
157
184
  };
158
185
  }
159
186
 
@@ -169,15 +196,16 @@ export function formatProfileReport(report, { showCases = false } = {}) {
169
196
  entry.profile,
170
197
  String(entry.mountedTools),
171
198
  String(entry.cases),
199
+ String(entry.errors ?? 0),
172
200
  pct(entry.mounted.selectionAccuracy),
173
201
  pct(entry.allInOne.selectionAccuracy),
174
202
  signedPoints(entry.delta.selection)
175
203
  ]);
176
204
  lines.push(
177
205
  ...renderTable(
178
- ["profile", "tools", "cases", "mounted", "all-in-one", "delta"],
206
+ ["profile", "tools", "cases", "errors", "mounted", "all-in-one", "delta"],
179
207
  rows,
180
- [1, 2, 3, 4, 5]
208
+ [1, 2, 3, 4, 5, 6]
181
209
  )
182
210
  );
183
211
  const totals = model.totals;
@@ -185,7 +213,8 @@ export function formatProfileReport(report, { showCases = false } = {}) {
185
213
  lines.push(
186
214
  ` 合计: 分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed})` +
187
215
  ` / 全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed})` +
188
- `,${signedPoints(totals.delta)}`
216
+ `,${signedPoints(totals.delta)}` +
217
+ (totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
189
218
  );
190
219
  }
191
220
  const unstable = unstableOf(model.profiles);
@@ -224,12 +253,12 @@ export function formatProfileMarkdown(report) {
224
253
  lines.push(
225
254
  `### ${model.model}`,
226
255
  "",
227
- "| group | tools | cases | mounted | all-in-one | delta |",
228
- "| --- | --- | --- | --- | --- | --- |"
256
+ "| group | tools | cases | errors | mounted | all-in-one | delta |",
257
+ "| --- | --- | --- | --- | --- | --- | --- |"
229
258
  );
230
259
  for (const entry of model.profiles) {
231
260
  lines.push(
232
- `| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
261
+ `| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${entry.errors ?? 0} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
233
262
  );
234
263
  }
235
264
  const totals = model.totals;
@@ -237,7 +266,8 @@ export function formatProfileMarkdown(report) {
237
266
  if (totals.reviewed > 0) {
238
267
  lines.push(
239
268
  `- 合计:分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed}),` +
240
- `全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}`
269
+ `全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}` +
270
+ (totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
241
271
  );
242
272
  } else {
243
273
  lines.push("- 合计:没有已复核用例,无法比较");
@@ -267,14 +297,18 @@ function changeOf(before, after) {
267
297
 
268
298
  function metricsOf(summary) {
269
299
  const reviewedRows = summary.rows.filter((row) => row.reviewed !== false);
270
- const correct = reviewedRows.filter((row) => row.selectionCorrect).length;
300
+ const errorRows = reviewedRows.filter((row) => row.error);
301
+ const viableRows = reviewedRows.filter((row) => !row.error);
302
+ const correct = viableRows.filter((row) => row.selectionCorrect).length;
271
303
  return {
272
- selectionAccuracy: reviewedRows.length ? correct / reviewedRows.length : null,
304
+ selectionAccuracy: viableRows.length ? correct / viableRows.length : null,
273
305
  argAccuracy: summary.argAccuracy,
274
- passRate: reviewedRows.length
275
- ? reviewedRows.filter((row) => row.passed).length / reviewedRows.length
306
+ passRate: viableRows.length
307
+ ? viableRows.filter((row) => row.passed).length / viableRows.length
276
308
  : null,
277
- correct
309
+ correct,
310
+ errors: errorRows.length,
311
+ scored: viableRows.length
278
312
  };
279
313
  }
280
314
 
package/lib/score.js CHANGED
@@ -89,7 +89,7 @@ export function costCnyOf(usage, pricing) {
89
89
  );
90
90
  }
91
91
 
92
- export function scoreModel({ model, cases, results, pricing }) {
92
+ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
93
93
  const rows = cases.map((testCase, index) => {
94
94
  const result = results[index];
95
95
  const actualTool = result.ok ? (result.toolCalls?.[0]?.name ?? null) : null;
@@ -98,6 +98,7 @@ export function scoreModel({ model, cases, results, pricing }) {
98
98
  ? matchArgs(result.toolCalls?.[0]?.arguments, testCase.expectedArgs)
99
99
  : { total: 0, matched: 0, failures: [] };
100
100
  const passed = Boolean(result.ok) && selectionCorrect && args.failures.length === 0;
101
+ const error = result.error ?? null;
101
102
  return {
102
103
  id: testCase.id,
103
104
  expectedTool: testCase.expectedTool,
@@ -106,21 +107,35 @@ export function scoreModel({ model, cases, results, pricing }) {
106
107
  args,
107
108
  passed,
108
109
  reviewed: testCase.reviewed !== false,
110
+ ...(testCase.excludeFromBase ? { excludeFromBase: true } : {}),
111
+ ...(testCase.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
109
112
  source: testCase.source ?? "manual",
110
113
  latencyMs: result.latencyMs ?? 0,
111
114
  usage: result.usage ?? { input: 0, output: 0 },
112
- error: result.error ?? null
115
+ error,
116
+ ...(error ? { errorKind: classifyError(error) } : {})
113
117
  };
114
118
  });
115
119
 
116
120
  const total = rows.length;
117
- const reviewedRows = rows.filter((row) => row.reviewed);
121
+ // An excluded case still runs (so profile / all-in-one views keep their rows);
122
+ // it just does not contribute to this scope's official metrics.
123
+ const excluded = (row) => (scope === "profiles" ? row.excludeFromProfiles : row.excludeFromBase);
124
+ const reviewedRows = rows.filter((row) => row.reviewed && !excluded(row));
118
125
  const unreviewedRows = rows.filter((row) => !row.reviewed);
126
+ const excludedRows = rows.filter((row) => row.reviewed && excluded(row));
119
127
  const selectionHits = reviewedRows.filter((row) => row.selectionCorrect).length;
120
128
  const argFields = reviewedRows.reduce((sum, row) => sum + row.args.total, 0);
121
129
  const argHits = reviewedRows.reduce((sum, row) => sum + row.args.matched, 0);
122
130
  const passed = reviewedRows.filter((row) => row.passed).length;
123
- const errors = rows.filter((row) => row.error).length;
131
+ const errorRows = reviewedRows.filter((row) => row.error);
132
+ const errors = errorRows.length;
133
+ const errorsByKind = errorRows.reduce((counts, row) => {
134
+ counts[row.errorKind] = (counts[row.errorKind] ?? 0) + 1;
135
+ return counts;
136
+ }, {});
137
+ const scoredTotal = reviewedRows.length - errors;
138
+ const selectionHitsWithoutErrors = reviewedRows.filter((row) => !row.error && row.selectionCorrect).length;
124
139
  const latencies = rows.filter((row) => row.latencyMs > 0).map((row) => row.latencyMs);
125
140
  const inputTokens = rows.reduce((sum, row) => sum + (row.usage.input ?? 0), 0);
126
141
  const outputTokens = rows.reduce((sum, row) => sum + (row.usage.output ?? 0), 0);
@@ -135,14 +150,18 @@ export function scoreModel({ model, cases, results, pricing }) {
135
150
  model,
136
151
  total,
137
152
  reviewedTotal,
153
+ excludedTotal: excludedRows.length,
138
154
  unreviewedTotal: unreviewedRows.length,
139
155
  unreviewedSelectionAccuracy: unreviewedRows.length
140
156
  ? unreviewedRows.filter((row) => row.selectionCorrect).length / unreviewedRows.length
141
157
  : null,
142
158
  selectionAccuracy: reviewedTotal ? selectionHits / reviewedTotal : 0,
159
+ selectionAccuracyExcludingErrors: scoredTotal ? selectionHitsWithoutErrors / scoredTotal : null,
143
160
  argAccuracy: argFields ? argHits / argFields : null,
144
161
  passRate: reviewedTotal ? passed / reviewedTotal : 0,
145
162
  errors,
163
+ errorsByKind,
164
+ scoredTotal,
146
165
  p50LatencyMs: percentile(latencies, 0.5),
147
166
  p95LatencyMs: percentile(latencies, 0.95),
148
167
  avgCostCny: total ? costCny / total : 0,
@@ -154,6 +173,15 @@ export function scoreModel({ model, cases, results, pricing }) {
154
173
  };
155
174
  }
156
175
 
176
+ export function classifyError(error) {
177
+ const text = String(error ?? "");
178
+ if (/timeout|timed out/i.test(text)) return "timeout";
179
+ if (/^HTTP 429\b/.test(text)) return "rate-limit";
180
+ if (/^HTTP \d{3}\b/.test(text)) return "http";
181
+ if (/ECONN|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(text)) return "network";
182
+ return "other";
183
+ }
184
+
157
185
  function percentile(values, ratio) {
158
186
  if (values.length === 0) return 0;
159
187
  const sorted = [...values].sort((a, b) => a - b);
package/lib/suite.js CHANGED
@@ -95,6 +95,11 @@ function normalizeCase(entry, index, toolNames) {
95
95
  if (toolInput && expectedTool === null) {
96
96
  throw new Error(`case ${id}: toolInput requires an expectedTool`);
97
97
  }
98
+ for (const key of ["excludeFromBase", "excludeFromProfiles"]) {
99
+ if (entry[key] !== undefined && typeof entry[key] !== "boolean") {
100
+ throw new Error(`case ${id}: "${key}" must be a boolean`);
101
+ }
102
+ }
98
103
  return {
99
104
  id,
100
105
  input: entry.input,
@@ -103,6 +108,8 @@ function normalizeCase(entry, index, toolNames) {
103
108
  note: entry.note ?? "",
104
109
  source: typeof entry.source === "string" && entry.source ? entry.source : "manual",
105
110
  reviewed: entry.reviewed === undefined ? true : Boolean(entry.reviewed),
111
+ ...(entry.excludeFromBase ? { excludeFromBase: true } : {}),
112
+ ...(entry.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
106
113
  tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
107
114
  toolInput,
108
115
  expect:
package/mf.js CHANGED
@@ -153,7 +153,8 @@ export async function runEval(options, io = console) {
153
153
  model: adapter.id,
154
154
  cases,
155
155
  results: evaluated.results,
156
- pricing
156
+ pricing,
157
+ scope: "base"
157
158
  });
158
159
  if (evaluated.stability) summary.stability = evaluated.stability;
159
160
  runs.push({ adapter, results: evaluated.results, summary });
@@ -250,6 +251,15 @@ export async function runEval(options, io = console) {
250
251
  if (summary.unreviewedTotal > 0) {
251
252
  io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
252
253
  }
254
+ if (summary.excludedTotal > 0) {
255
+ io.log(`note: ${summary.excludedTotal} case(s) marked excludeFromBase, excluded from base score`);
256
+ }
257
+ if (summary.errors > 0) {
258
+ const kinds = Object.entries(summary.errorsByKind)
259
+ .map(([kind, count]) => `${kind} ${count}`)
260
+ .join(", ");
261
+ io.log(`note: ${summary.errors} call error(s) counted as misses (${kinds})`);
262
+ }
253
263
  if (summary.exec) {
254
264
  const parts = [
255
265
  `exec: ${summary.exec.passed}/${summary.exec.executed} passed`,
@@ -915,6 +925,14 @@ async function evaluateProfiles({
915
925
  const selected = selectProfiles(profiles, selection);
916
926
  const indexById = new Map(cases.map((testCase, index) => [testCase.id, index]));
917
927
  const warnings = profileWarnings(selected, suite);
928
+ const excludedFromProfiles = cases.filter((testCase) => testCase.excludeFromProfiles === true);
929
+ if (excludedFromProfiles.length > 0) {
930
+ warnings.push(
931
+ `${excludedFromProfiles.length} 条用例标记 excludeFromProfiles,不参与分组评分: ${excludedFromProfiles
932
+ .map((testCase) => testCase.id)
933
+ .join(", ")}`
934
+ );
935
+ }
918
936
 
919
937
  const groups = [];
920
938
  const caseSets = selected.map((profile) => {
@@ -946,15 +964,24 @@ async function evaluateProfiles({
946
964
  model: run.adapter.id,
947
965
  cases: profileCases,
948
966
  results: mountedEval.results,
949
- pricing
967
+ pricing,
968
+ scope: "profiles"
950
969
  });
951
970
  const allInOne = scoreModel({
952
971
  model: run.adapter.id,
953
972
  cases: profileCases,
954
973
  results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
955
- pricing
974
+ pricing,
975
+ scope: "profiles"
956
976
  });
957
977
  const entry = analyzeMount(profile, mounted, allInOne);
978
+ entry.usage = {
979
+ inputTokens: mounted.inputTokens,
980
+ cachedTokens: mounted.cachedTokens,
981
+ outputTokens: mounted.outputTokens,
982
+ costCny: mounted.totalCostCny,
983
+ estimated: mounted.rows.some((row) => row.usage?.estimated)
984
+ };
958
985
  if (mountedEval.stability) entry.stability = mountedEval.stability;
959
986
  entries.push(entry);
960
987
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",