@miphamai/cli 0.82.0 → 0.83.0
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/package.json
CHANGED
package/src/core/eval-harness.ts
CHANGED
|
@@ -97,10 +97,24 @@ export function regressedAnchors(results: EvalResult[]): string[] {
|
|
|
97
97
|
|
|
98
98
|
const SCORES_FILE = join(homedir(), '.mipham', 'crsi', 'eval-scores.jsonl')
|
|
99
99
|
|
|
100
|
+
/** 落盘的契约粒度投影 —— 只要 id/passed/role(EvalResult 的 description/detail 不落盘)。 */
|
|
101
|
+
export interface ContractResultRecord {
|
|
102
|
+
id: string
|
|
103
|
+
passed: boolean
|
|
104
|
+
role?: ContractRole
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 一次评估的契约粒度快照:契约 id → 是否通过。 */
|
|
108
|
+
export type ContractSnapshot = Record<string, boolean>
|
|
109
|
+
|
|
110
|
+
function toContractResultRecord(r: EvalResult): ContractResultRecord {
|
|
111
|
+
return { id: r.id, passed: r.passed, ...(r.role ? { role: r.role } : {}) }
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
/** 追加一次评估分数到 rewards 日志(按奖励函数名键控)。 */
|
|
101
115
|
export function appendEvalScore(
|
|
102
116
|
name: string,
|
|
103
|
-
report: { score: number; passed: number; total: number },
|
|
117
|
+
report: { score: number; passed: number; total: number; results?: EvalResult[] },
|
|
104
118
|
): void {
|
|
105
119
|
try {
|
|
106
120
|
mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
|
|
@@ -112,6 +126,9 @@ export function appendEvalScore(
|
|
|
112
126
|
score: report.score,
|
|
113
127
|
passed: report.passed,
|
|
114
128
|
total: report.total,
|
|
129
|
+
// 契约粒度(B1)。缺省不写该键:B1 之前落盘的旧记录没有它,
|
|
130
|
+
// 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
|
|
131
|
+
...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
|
|
115
132
|
}) + '\n',
|
|
116
133
|
'utf-8',
|
|
117
134
|
)
|
|
@@ -135,6 +152,105 @@ export function getLastEvalScore(name: string): number | null {
|
|
|
135
152
|
}
|
|
136
153
|
}
|
|
137
154
|
|
|
155
|
+
/**
|
|
156
|
+
* 某奖励函数最近 n 次**按契约粒度**落盘的记录,新→旧。
|
|
157
|
+
*
|
|
158
|
+
* 只认带 `results` 的记录 —— B1 之前落盘的旧记录(只有聚合分数)被跳过,
|
|
159
|
+
* 于是调用方不必区分新旧形态。逐行容错:坏行跳过而不是让整条历史归零
|
|
160
|
+
* (`fixCache` 负责清理坏行)。
|
|
161
|
+
*/
|
|
162
|
+
export function getContractHistory(name: string, n = 3): ContractSnapshot[] {
|
|
163
|
+
try {
|
|
164
|
+
if (!existsSync(SCORES_FILE)) return []
|
|
165
|
+
const lines = readFileSync(SCORES_FILE, 'utf-8').trim().split('\n').filter(Boolean)
|
|
166
|
+
const out: ContractSnapshot[] = []
|
|
167
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
168
|
+
let rec: { name?: string; results?: ContractResultRecord[] }
|
|
169
|
+
try {
|
|
170
|
+
rec = JSON.parse(lines[i]!) as { name?: string; results?: ContractResultRecord[] }
|
|
171
|
+
} catch {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (rec.name !== name || !Array.isArray(rec.results)) continue
|
|
175
|
+
const snap: ContractSnapshot = {}
|
|
176
|
+
for (const r of rec.results) {
|
|
177
|
+
if (typeof r?.id === 'string') snap[r.id] = r.passed === true
|
|
178
|
+
}
|
|
179
|
+
out.push(snap)
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
} catch {
|
|
183
|
+
return []
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type ContractDelta = 'regressed' | 'fixed' | 'flaky' | 'new' | 'gone'
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 纯函数:当前 run vs 历史 → 每条契约的变化。**只报变化**,未变化的契约不出现。
|
|
191
|
+
*
|
|
192
|
+
* delta 判据(`history` 新→旧):
|
|
193
|
+
* - 历史上没出现过 → `new`
|
|
194
|
+
* - 历史上 true/false 都出现过 → `flaky`(压过 regressed/fixed:抖动的契约
|
|
195
|
+
* 不该被报成「已修复」或「回归」)
|
|
196
|
+
* - 上次与本次相反 → `regressed`(上次 PASS→本次 FAIL)/ `fixed`
|
|
197
|
+
* - 本次没有但历史有 → `gone`
|
|
198
|
+
*
|
|
199
|
+
* 为什么 `flaky` 要压过相邻两次的比较:只比相邻两次会把一个每次都在翻的契约
|
|
200
|
+
* 误报成「真回归」,而那正是这个账本要区分开的东西。
|
|
201
|
+
*/
|
|
202
|
+
export function diffContractHistory(
|
|
203
|
+
current: ContractResultRecord[],
|
|
204
|
+
history: ContractSnapshot[],
|
|
205
|
+
): { id: string; delta: ContractDelta; role?: ContractRole }[] {
|
|
206
|
+
const out: { id: string; delta: ContractDelta; role?: ContractRole }[] = []
|
|
207
|
+
const seen = new Set<string>()
|
|
208
|
+
for (const c of current) {
|
|
209
|
+
seen.add(c.id)
|
|
210
|
+
const past = history.filter((h) => c.id in h).map((h) => h[c.id] === true)
|
|
211
|
+
let delta: ContractDelta
|
|
212
|
+
if (past.length === 0) delta = 'new'
|
|
213
|
+
else if (past.includes(true) && past.includes(false)) delta = 'flaky'
|
|
214
|
+
else if (past[0] === !c.passed) delta = c.passed ? 'fixed' : 'regressed'
|
|
215
|
+
else continue // 未变化
|
|
216
|
+
out.push({ id: c.id, delta, ...(c.role ? { role: c.role } : {}) })
|
|
217
|
+
}
|
|
218
|
+
for (const h of history) {
|
|
219
|
+
for (const id of Object.keys(h)) {
|
|
220
|
+
if (seen.has(id)) continue
|
|
221
|
+
seen.add(id)
|
|
222
|
+
out.push({ id, delta: 'gone' })
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 把 diffContractHistory 的结果渲染成展示行。纯函数——不做 I/O、不读时钟。
|
|
230
|
+
* 返回空数组表示「无变化」,调用方据此决定是否打印标题。
|
|
231
|
+
*/
|
|
232
|
+
export function renderContractDiff(
|
|
233
|
+
deltas: { id: string; delta: ContractDelta; role?: ContractRole }[],
|
|
234
|
+
): string[] {
|
|
235
|
+
const text: Record<ContractDelta, string> = {
|
|
236
|
+
regressed: '上次 PASS,本次 FAIL(回归)',
|
|
237
|
+
fixed: '上次 FAIL,本次 PASS(已修复)',
|
|
238
|
+
flaky: '近几次结果不一致(抖动)',
|
|
239
|
+
new: '本次新增的契约',
|
|
240
|
+
gone: '本次未出现(已移出契约集)',
|
|
241
|
+
}
|
|
242
|
+
const icon: Record<ContractDelta, string> = {
|
|
243
|
+
regressed: '❌',
|
|
244
|
+
fixed: '✅',
|
|
245
|
+
flaky: '⚠️',
|
|
246
|
+
new: '🆕',
|
|
247
|
+
gone: '➖',
|
|
248
|
+
}
|
|
249
|
+
return deltas.map(
|
|
250
|
+
(d) => `${icon[d.delta]} ${d.id} ← ${text[d.delta]}${d.role ? ` \`${d.role}\`` : ''}`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
138
254
|
// ── Harness ──
|
|
139
255
|
|
|
140
256
|
/** 构建隔离组件,避免读用户 ~/.mipham 运行时状态。 */
|
|
@@ -26,6 +26,13 @@ export interface ImprovementReport {
|
|
|
26
26
|
predictedDelta?: number
|
|
27
27
|
/** 预测是否命中。与 predictedDelta 同时出现、同时缺席(JSON 序列化会丢掉 undefined 键)。 */
|
|
28
28
|
predictionHit?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* B2 代价维:与分数数组逐项对齐的前/后耗时。**只记录,不进任何门禁** ——
|
|
31
|
+
* `verdict` / `deltaMean` / `minEffect` 一律不看这两个字段。
|
|
32
|
+
* 缺席(而非空数组)= 该记录早于代价维落地,或该样本未记代价。
|
|
33
|
+
*/
|
|
34
|
+
baselineDurations?: number[]
|
|
35
|
+
postDurations?: number[]
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface ImprovementRecord extends ImprovementReport {
|
|
@@ -83,6 +90,11 @@ export function buildImprovementReport(
|
|
|
83
90
|
...(predicted !== undefined
|
|
84
91
|
? { predictedDelta: predicted, predictionHit: predictionHit(predicted, deltaMean) }
|
|
85
92
|
: {}),
|
|
93
|
+
// 代价维:**两条同生同灭**,与上面 ε 那条同理 —— 只写一半(有 baselineDurations
|
|
94
|
+
// 而无 postDurations)会让「代价」这件事在记录里既非有也非无,读侧无从判断。
|
|
95
|
+
...(sample.baselineDurations !== undefined && sample.postDurations !== undefined
|
|
96
|
+
? { baselineDurations: sample.baselineDurations, postDurations: sample.postDurations }
|
|
97
|
+
: {}),
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -148,6 +160,24 @@ export function predictionHitRate(records: ImprovementRecord[]): {
|
|
|
148
160
|
return { total, hits, rate: total === 0 ? 0 : hits / total, lo, hi }
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/**
|
|
164
|
+
* 代价维的只读展示(B2):前/后均值一行。两个耗时数组缺席 → null,调用方据此整行不打印。
|
|
165
|
+
*
|
|
166
|
+
* **刻意不下结论**:倍数只是描述,不是判据。把「代价过高」变成 verdict 的一部分需要
|
|
167
|
+
* 样本量支撑,而当前 k 默认 3、`minEffect` 已经要 `max(20, 2×噪声)` —— 再塞一个维度
|
|
168
|
+
* 只会把统计问题变得更糟。所以这里只回答「花了多少」,不回答「值不值」。
|
|
169
|
+
*
|
|
170
|
+
* 基线均值为 0 时**不给倍数**:那个比值是 ∞(或 0/0 的 NaN),打出来是假读数。
|
|
171
|
+
*/
|
|
172
|
+
export function formatCostLine(report: ImprovementReport): string | null {
|
|
173
|
+
const { baselineDurations: before_, postDurations: after_ } = report
|
|
174
|
+
if (before_ === undefined || after_ === undefined) return null
|
|
175
|
+
const before = Math.round(mean(before_))
|
|
176
|
+
const after = Math.round(mean(after_))
|
|
177
|
+
const line = `⏱️ 代价: 均值 ${before}ms → ${after}ms`
|
|
178
|
+
return before > 0 ? `${line}(×${(after / before).toFixed(1)})` : line
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
// ── 台账 ──
|
|
152
182
|
|
|
153
183
|
export function improvementPath(): string {
|
|
@@ -68,6 +68,8 @@ export interface TaskPerformanceReport {
|
|
|
68
68
|
score: number
|
|
69
69
|
results: TaskPerformanceResult[]
|
|
70
70
|
failures: string[]
|
|
71
|
+
/** B2 代价维:整轮(LLM 生成 + 冻结测试判定)的墙钟耗时。只记录,不参与任何判定。 */
|
|
72
|
+
durationMs: number
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/** 剥掉 LLM 可能包裹的 markdown 代码块(```ts ... ```),拿到裸代码。 */
|
|
@@ -101,6 +103,7 @@ export async function runTaskPerformance(
|
|
|
101
103
|
const wanted = opts?.skill?.name
|
|
102
104
|
const tasks = loadPerformanceTasks().filter((t) => (t.skill ?? undefined) === wanted)
|
|
103
105
|
const results: TaskPerformanceResult[] = []
|
|
106
|
+
const startedAt = Date.now()
|
|
104
107
|
for (const task of tasks) {
|
|
105
108
|
const code = await collectGeneratedCode(llm, task.prompt, opts?.skill?.text)
|
|
106
109
|
if (!code) {
|
|
@@ -127,6 +130,7 @@ export async function runTaskPerformance(
|
|
|
127
130
|
score: results.length > 0 ? Math.round((passed / results.length) * 100) : 100,
|
|
128
131
|
results,
|
|
129
132
|
failures: results.filter((r) => !r.passed).map((r) => r.id),
|
|
133
|
+
durationMs: Date.now() - startedAt,
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
|
|
@@ -210,6 +214,13 @@ export interface SkillDeltaSample {
|
|
|
210
214
|
skillName: string
|
|
211
215
|
baselineScores: number[]
|
|
212
216
|
postScores: number[]
|
|
217
|
+
/**
|
|
218
|
+
* B2 代价维:与分数数组**逐项对齐**的耗时(第 i 项就是产出第 i 个分数的那次采样)。
|
|
219
|
+
* 缺席 = 该样本未记代价(旧记录 / 旧调用点),读侧须按「缺席」处理、不得当成 0。
|
|
220
|
+
* 只记录,不参与改进判定 —— 样本量 k=3 时再加一个维度只会让统计问题更糟。
|
|
221
|
+
*/
|
|
222
|
+
baselineDurations?: number[]
|
|
223
|
+
postDurations?: number[]
|
|
213
224
|
}
|
|
214
225
|
|
|
215
226
|
/**
|
|
@@ -227,18 +238,28 @@ export async function measureSkillDeltaRepeated(
|
|
|
227
238
|
const k = opts?.k ?? 3
|
|
228
239
|
const baselineScores: number[] = []
|
|
229
240
|
const postScores: number[] = []
|
|
241
|
+
const baselineDurations: number[] = []
|
|
242
|
+
const postDurations: number[] = []
|
|
230
243
|
for (let i = 0; i < k; i++) {
|
|
231
244
|
const r = await runTaskPerformance(llm, {
|
|
232
245
|
skill: { name: resolved.skillName, text: resolved.baselineText },
|
|
233
246
|
})
|
|
234
247
|
baselineScores.push(r.score)
|
|
248
|
+
baselineDurations.push(r.durationMs)
|
|
235
249
|
}
|
|
236
250
|
for (let i = 0; i < k; i++) {
|
|
237
251
|
const r = await runTaskPerformance(llm, {
|
|
238
252
|
skill: { name: resolved.skillName, text: resolved.postText },
|
|
239
253
|
})
|
|
240
254
|
postScores.push(r.score)
|
|
255
|
+
postDurations.push(r.durationMs)
|
|
241
256
|
}
|
|
242
257
|
|
|
243
|
-
return {
|
|
258
|
+
return {
|
|
259
|
+
skillName: resolved.skillName,
|
|
260
|
+
baselineScores,
|
|
261
|
+
postScores,
|
|
262
|
+
baselineDurations,
|
|
263
|
+
postDurations,
|
|
264
|
+
}
|
|
244
265
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.83.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/ui/commands.ts
CHANGED
|
@@ -37,7 +37,13 @@ import {
|
|
|
37
37
|
MANAGED_RULES_FILE,
|
|
38
38
|
} from '../core/crsi-producer'
|
|
39
39
|
import { prefilterProposal } from '../core/proposal-guard'
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
runEval,
|
|
42
|
+
appendEvalScore,
|
|
43
|
+
getContractHistory,
|
|
44
|
+
diffContractHistory,
|
|
45
|
+
renderContractDiff,
|
|
46
|
+
} from '../core/eval-harness'
|
|
41
47
|
import { listRewardFns } from '../core/reward-fn'
|
|
42
48
|
import {
|
|
43
49
|
runTaskPerformance,
|
|
@@ -47,6 +53,7 @@ import {
|
|
|
47
53
|
import { randomUUID } from 'node:crypto'
|
|
48
54
|
import {
|
|
49
55
|
buildImprovementReport,
|
|
56
|
+
formatCostLine,
|
|
50
57
|
appendImprovement,
|
|
51
58
|
readImprovements,
|
|
52
59
|
improvementRate,
|
|
@@ -901,9 +908,12 @@ const crsiModifyCmd: CommandHandler = async (ctx, args) => {
|
|
|
901
908
|
? 'regressed ⚠️'
|
|
902
909
|
: 'inconclusive'
|
|
903
910
|
const sign = report.deltaMean >= 0 ? '+' : ''
|
|
911
|
+
// B2 代价维:只展示、不进判定(`formatCostLine` 缺席时返回 null ⇒ 整行不打)。
|
|
912
|
+
const costLine = formatCostLine(report)
|
|
904
913
|
improvementLine =
|
|
905
914
|
`\n📊 改进判定: ${label} (delta ${sign}${report.deltaMean.toFixed(1)}, 噪声 ${report.noise.toFixed(1)}, 阈值 ${report.minEffect.toFixed(1)})` +
|
|
906
915
|
`\n 改进率: ${rate.improved}/${rate.total} (${(rate.rate * 100).toFixed(0)}%, Wilson 95% [${(rate.lo * 100).toFixed(0)}%, ${(rate.hi * 100).toFixed(0)}%])` +
|
|
916
|
+
(costLine ? `\n${costLine}` : '') +
|
|
907
917
|
(report.verdict === 'regressed' ? '\n ⚠️ 任务表现倒退:--approve 将被拒绝。' : '')
|
|
908
918
|
}
|
|
909
919
|
} catch {
|
|
@@ -1009,6 +1019,10 @@ const crsiProposeCmd: CommandHandler = async (ctx, args) => {
|
|
|
1009
1019
|
`\n🎯 ε 预测命中: ${report.predictionHit ? '命中 ✅' : '未命中 ⚠️'}` +
|
|
1010
1020
|
`(预测 ${report.predictedDelta},实际 delta ${report.deltaMean.toFixed(1)})`
|
|
1011
1021
|
}
|
|
1022
|
+
// B2 代价维:**与手工路径同一行读数**,两条渲染路径都接(只接一条即本仓库记过的
|
|
1023
|
+
// 「局部正确全局遗漏」)。ε 行可以有、代价行可以没有,故各自独立追加。
|
|
1024
|
+
const costLine = formatCostLine(report)
|
|
1025
|
+
if (costLine) predictionLine += `\n${costLine}`
|
|
1012
1026
|
}
|
|
1013
1027
|
} catch {
|
|
1014
1028
|
// 测量失败(LLM 不可用等)不阻断提案流程 —— 与手工路径 :889 的处置一致。
|
|
@@ -1132,14 +1146,26 @@ const crsiEvalCmd: CommandHandler = async (ctx, args) => {
|
|
|
1132
1146
|
content: `❌ 未知 reward: ${rewardName}。可用: ${fns.map((f) => f.name).join(', ')}`,
|
|
1133
1147
|
}
|
|
1134
1148
|
}
|
|
1149
|
+
// 先读后写:比较基准是「上一次已落盘的态」,不依赖刚写进去那条排第几。
|
|
1150
|
+
const prev = getContractHistory(fn.name)
|
|
1135
1151
|
const report = await fn.evaluate()
|
|
1152
|
+
const deltaLines = report.results
|
|
1153
|
+
? renderContractDiff(diffContractHistory(report.results, prev))
|
|
1154
|
+
: []
|
|
1136
1155
|
appendEvalScore(fn.name, report)
|
|
1137
1156
|
return {
|
|
1138
|
-
content:
|
|
1157
|
+
content: [
|
|
1158
|
+
`得分 **${report.score}/100** (${report.passed}/${report.total})`,
|
|
1159
|
+
`失败: ${report.failures.join(', ') || '无'}`,
|
|
1160
|
+
...(deltaLines.length > 0 ? ['', '### 与上次相比', ...deltaLines] : []),
|
|
1161
|
+
].join('\n'),
|
|
1139
1162
|
}
|
|
1140
1163
|
}
|
|
1141
1164
|
|
|
1165
|
+
// 先读后写(同上):基准是上一次落盘的态。
|
|
1166
|
+
const prevSnapshot = getContractHistory('mechanism-sentinel')
|
|
1142
1167
|
const report = runEval()
|
|
1168
|
+
const deltaLines = renderContractDiff(diffContractHistory(report.results, prevSnapshot))
|
|
1143
1169
|
appendEvalScore('mechanism-sentinel', report)
|
|
1144
1170
|
|
|
1145
1171
|
const lines: string[] = ['## 🧪 CRSI Eval Harness', '']
|
|
@@ -1154,6 +1180,11 @@ const crsiEvalCmd: CommandHandler = async (ctx, args) => {
|
|
|
1154
1180
|
if (report.failures.length > 0) {
|
|
1155
1181
|
lines.push('', `❌ 失败任务: ${report.failures.join(', ')}`)
|
|
1156
1182
|
}
|
|
1183
|
+
// 只读展示:账本按契约粒度落盘后,才能回答「是哪条契约翻的」。
|
|
1184
|
+
// 这里不做任何自动决策——闸门仍只看 regressedAnchors / score。
|
|
1185
|
+
if (deltaLines.length > 0) {
|
|
1186
|
+
lines.push('', '### 与上次相比', ...deltaLines)
|
|
1187
|
+
}
|
|
1157
1188
|
|
|
1158
1189
|
// 奖励函数注册表(reward function = policy→feedback 抽象可见)
|
|
1159
1190
|
// 传 llm 列出完整注册表(task-performance 需 llm 才能跑,但构造它零 LLM 调用)。
|