oh-my-knowledge 0.39.0 → 0.40.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.
Files changed (38) hide show
  1. package/dist/assets/agent-skills/omk/references/commands.md +1 -0
  2. package/dist/authoring/evolver.js +9 -5
  3. package/dist/cli/commands/list.d.ts +4 -1
  4. package/dist/cli/commands/list.js +12 -4
  5. package/dist/cli/commands/observe/index.d.ts +9 -0
  6. package/dist/cli/commands/observe/index.js +74 -1
  7. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  8. package/dist/cli/lib/i18n-dict/common.js +8 -0
  9. package/dist/cli/lib/i18n-dict/list.d.ts +1 -1
  10. package/dist/cli/lib/i18n-dict/list.js +4 -0
  11. package/dist/cli/lib/record-evolve-outcome.js +19 -10
  12. package/dist/eval-core/bootstrap.d.ts +53 -2
  13. package/dist/eval-core/bootstrap.js +82 -5
  14. package/dist/eval-core/evaluation-reporting.js +37 -15
  15. package/dist/eval-core/schema.js +5 -3
  16. package/dist/eval-core/verdict.d.ts +30 -0
  17. package/dist/eval-core/verdict.js +71 -21
  18. package/dist/grading/assertions.js +28 -0
  19. package/dist/grading/debias-validate.js +6 -2
  20. package/dist/grading/index.js +6 -1
  21. package/dist/grading/layered-scores.d.ts +15 -0
  22. package/dist/grading/layered-scores.js +64 -38
  23. package/dist/managed/index.d.ts +1 -0
  24. package/dist/managed/index.js +1 -0
  25. package/dist/managed/list-view.d.ts +9 -1
  26. package/dist/managed/list-view.js +7 -1
  27. package/dist/managed/observe-feedback.d.ts +60 -0
  28. package/dist/managed/observe-feedback.js +49 -0
  29. package/dist/managed/store.d.ts +28 -1
  30. package/dist/managed/store.js +84 -2
  31. package/dist/observability/skill-health-analyzer.d.ts +5 -0
  32. package/dist/observability/skill-health-analyzer.js +3 -2
  33. package/dist/renderer/managed-history-renderer.js +80 -3
  34. package/dist/renderer/summary.js +23 -22
  35. package/dist/types/judge.d.ts +8 -0
  36. package/dist/types/managed.d.ts +33 -0
  37. package/dist/types/report.d.ts +10 -0
  38. package/package.json +1 -1
@@ -403,6 +403,7 @@ omk observe [sessionsDir] [flags]
403
403
 
404
404
  **Flags:**
405
405
 
406
+ - `--feedback` `boolean`:把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)
406
407
  - `--from` `option`:起始时间 ISO,优先级高于 --last
407
408
  - `--global` `boolean`:写全局 ~/.oh-my-knowledge/observe-health,而非项目 .omk/observe-health
408
409
  - `--kb` `option`:知识库 root,启用 KB-aware 分析
@@ -868,11 +868,15 @@ export async function evolveSkill({ skillPath, samplesPath, rounds = 5, target =
868
868
  // Significance accept gate: accept only when the candidate is *significantly*
869
869
  // above the current best on the decision (val) set, not merely numerically higher
870
870
  // — rejecting gains indistinguishable from judge noise. `lastReport` is the current
871
- // best's fresh eval and `candidateReport` the candidate's, over the same samples;
872
- // bootstrapDiffCI resamples the two arrays independently (conservative not a paired
873
- // bootstrap). Under-powered decision sets degrade to the legacy point-estimate accept
874
- // (note: that path compares the prior-round best scalar, not this fresh re-eval) and
875
- // flag `gate.underpowered`.
871
+ // best's fresh eval and `candidateReport` the candidate's, over the same samples.
872
+ // **Deliberately UNPAIRED here** (independent resampling, `decideAccept` `bootstrapDiffCI`),
873
+ // unlike the report verdict / debias which switched to `bootstrapPairedDiffCI`: this is an
874
+ // *optimization accept-gate*, and the unpaired bootstrap's wider, conservative CI is the
875
+ // wanted bias — it raises the bar to accept, so evolve does not chase paired-tightened,
876
+ // marginally-significant gains that risk overfitting the decision set. Power is not the goal
877
+ // for de-/escalation gates; not accepting noise is. Under-powered decision sets degrade to the
878
+ // legacy point-estimate accept (note: that path compares the prior-round best scalar, not this
879
+ // fresh re-eval) and flag `gate.underpowered`.
876
880
  const valIds = split ? split.valIds : new Set(allSampleIds);
877
881
  const bestScores = perSampleComposite(lastReport, lastVariantKey, valIds);
878
882
  const candScores = perSampleComposite(candidateReport, candidateVariantKey, valIds);
@@ -2,7 +2,10 @@ import { BaseCommand } from '../oclif/base-command.js';
2
2
  import { sanitizeCell } from '../lib/cell-format.js';
3
3
  import { type ManagedListRow } from '../../managed/index.js';
4
4
  import type { CliLang } from '../lib/i18n.js';
5
- /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
5
+ /** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
6
+ * 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
7
+ * 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
8
+ * `✓`(U+2713)终端按 1 列渲染、保持 1。 */
6
9
  export declare function dispWidth(s: string): number;
7
10
  /** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
8
11
  export declare function truncate(s: string, max: number): string;
@@ -4,11 +4,14 @@ import { BaseCommand } from '../oclif/base-command.js';
4
4
  import { tCli } from '../lib/i18n.js';
5
5
  import { sanitizeCell } from '../lib/cell-format.js';
6
6
  import { globalManagedDir, listManagedRows, managedDir, resolveManagedDir, } from '../../managed/index.js';
7
- /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
7
+ /** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
8
+ * 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
9
+ * 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
10
+ * `✓`(U+2713)终端按 1 列渲染、保持 1。 */
8
11
  export function dispWidth(s) {
9
12
  let w = 0;
10
13
  for (const ch of s)
11
- w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1;
14
+ w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}]/u.test(ch) ? 2 : 1;
12
15
  return w;
13
16
  }
14
17
  function pad(s, width) {
@@ -45,7 +48,9 @@ export function renderTable(rows, lang) {
45
48
  truncate(sanitizeCell(r.name), 40), // name 与 source 同为用户可控、可超长 → 同样按显示宽度截断,防撑爆表宽
46
49
  r.kind, // ArtifactKind 枚举(validator 已收窄),无需洗
47
50
  // 不可达 → 标「?」(drift 未核),绝不冒充 stale;reachable 且漂移才 stale ⚠️;已人工接受标 promoted ✓。
48
- !r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state,
51
+ // 生产盲区 🔬 与生命周期**正交**(observe 量线上部署版),叠加在 state token 之后,不替换 state
52
+ (!r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state)
53
+ + (r.productionGap ? ' 🔬' : ''),
49
54
  r.latestVerdict ? sanitizeCell(r.latestVerdict) : '—',
50
55
  `${r.currentEvidenceCount}/${r.totalEvidenceCount}`,
51
56
  truncate(sanitizeCell(r.sourceLabel), 48),
@@ -99,7 +104,8 @@ export default class List extends BaseCommand {
99
104
  const hasDrift = rows.some((r) => r.drifted);
100
105
  const hasUnreachable = rows.some((r) => !r.reachable);
101
106
  const hasPromoted = rows.some((r) => r.state === 'promoted');
102
- if (hasDrift || hasUnreachable || hasPromoted)
107
+ const hasProductionGap = rows.some((r) => r.productionGap);
108
+ if (hasDrift || hasUnreachable || hasPromoted || hasProductionGap)
103
109
  process.stderr.write('\n');
104
110
  if (hasPromoted)
105
111
  process.stderr.write(tCli('cli.list.promoted_note', lang));
@@ -107,6 +113,8 @@ export default class List extends BaseCommand {
107
113
  process.stderr.write(tCli('cli.list.drift_note', lang));
108
114
  if (hasUnreachable)
109
115
  process.stderr.write(tCli('cli.list.unreachable_note', lang));
116
+ if (hasProductionGap)
117
+ process.stderr.write(tCli('cli.list.production_gap_note', lang));
110
118
  process.stderr.write(tCli('cli.list.legend', lang));
111
119
  });
112
120
  }
@@ -9,6 +9,14 @@ export declare function persistObserveHealthReport(report: SkillHealthReport, ou
9
9
  id: string;
10
10
  jsonPath: string;
11
11
  };
12
+ /**
13
+ * SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
14
+ * ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
15
+ * 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
16
+ * 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
17
+ * `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
18
+ */
19
+ export declare function buildObserveReportView(report: SkillHealthReport, reportId: string, healthBandOf: (weightedGapRate: number) => 'green' | 'yellow' | 'red'): import('../../../managed/index.js').ObserveReportView;
12
20
  export default class Observe extends BaseCommand {
13
21
  static description: string;
14
22
  static examples: {
@@ -27,6 +35,7 @@ export default class Observe extends BaseCommand {
27
35
  skills: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
28
36
  'output-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
29
37
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
38
+ feedback: import("@oclif/core/interfaces").BooleanFlag<boolean>;
30
39
  };
31
40
  run(): Promise<void>;
32
41
  }
@@ -23,6 +23,68 @@ export function persistObserveHealthReport(report, outDir) {
23
23
  indexObserveWrite(report, jsonPath, outDir, id);
24
24
  return { id, jsonPath };
25
25
  }
26
+ // 盲区信号类型 → 人话标签(建议补样本提示用)。技术枚举键的展示名,zh/en 分列。
27
+ const GAP_AREA_LABELS = {
28
+ failed_search: { zh: '检索失败', en: 'failed search' },
29
+ explicit_marker: { zh: '显式缺口', en: 'explicit gap' },
30
+ hedging: { zh: '含糊回避', en: 'hedging' },
31
+ repeated_failure: { zh: '反复失败', en: 'repeated failure' },
32
+ };
33
+ /** 取盲区计数最高的前几类,组成「建议补哪类用例」的人话区域串(只展示信号所在,不生成具体用例)。
34
+ * 只迭代**四个已知盲区类型**(GAP_AREA_LABELS 的键),记录 / 报告里若混入额外键一律忽略,不进展示。 */
35
+ function topGapAreas(gapByType, lang) {
36
+ const sep = lang === 'zh' ? '、' : ', ';
37
+ const areas = Object.keys(GAP_AREA_LABELS)
38
+ .map((k) => [k, gapByType[k] ?? 0])
39
+ .filter(([, n]) => n > 0)
40
+ .sort((a, b) => b[1] - a[1])
41
+ .slice(0, 3)
42
+ .map(([k]) => GAP_AREA_LABELS[k][lang])
43
+ .join(sep);
44
+ return areas || (lang === 'zh' ? '未归类盲区' : 'uncategorized gaps');
45
+ }
46
+ /**
47
+ * SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
48
+ * ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
49
+ * 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
50
+ * 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
51
+ * `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
52
+ */
53
+ export function buildObserveReportView(report, reportId, healthBandOf) {
54
+ return {
55
+ reportId,
56
+ observedAt: report.meta.timeRange?.to || report.meta.generatedAt,
57
+ skills: Object.values(report.bySkill).map((s) => ({
58
+ skillName: s.skillName,
59
+ segmentCount: s.segmentCount,
60
+ gapRate: s.gap.gapRate,
61
+ weightedGapRate: s.gap.weightedGapRate,
62
+ confidence: s.confidence,
63
+ healthBand: healthBandOf(s.gap.weightedGapRate),
64
+ gapByType: s.gap.byType,
65
+ })),
66
+ };
67
+ }
68
+ /**
69
+ * observe → 管理支柱反哺(#235):把每个 skill 的生产健康落成观测追加进同名受管记录,并打印「已记录 / 生产
70
+ * 盲区警示」。**非致命**:管理是 observe 旁路,任何异常都不该让 observe 失败(try/catch 吞掉)。observability /
71
+ * managed 运行时函数动态 import,与 observe 主体一致、不拖累 CLI 启动。
72
+ */
73
+ async function recordObserveFeedback(report, reportId, lang) {
74
+ try {
75
+ const { healthBandOf } = await import('../../../observability/skill-health-analyzer.js');
76
+ const { recordObserveHealth } = await import('../../../managed/index.js');
77
+ const written = recordObserveHealth(buildObserveReportView(report, reportId, healthBandOf));
78
+ for (const w of written) {
79
+ process.stdout.write(w.isProductionGap
80
+ ? tCli('cli.observe.production_gap', lang, { name: w.name, areas: topGapAreas(w.gapByType, lang) })
81
+ : tCli('cli.observe.observation_recorded', lang, { name: w.name }));
82
+ }
83
+ }
84
+ catch {
85
+ // 反哺是 observe 旁路,任何异常都不该让 observe 失败。
86
+ }
87
+ }
26
88
  // `omk observe <sessions-dir>` 是默认命令 —— 分析 sessions 目录的 skill 调用健康度,产出 observe-health 报告(JSON),
27
89
  // 由 Studio 健康报告页按需渲染。observe 这条线的另一条产物是观测收件箱(observe-inbox),走子命令 ingest / inbox / show。
28
90
  export default class Observe extends BaseCommand {
@@ -80,6 +142,14 @@ export default class Observe extends BaseCommand {
80
142
  en: 'Write to global ~/.oh-my-knowledge/observe-health instead of project .omk/observe-health',
81
143
  }),
82
144
  }),
145
+ feedback: Flags.boolean({
146
+ default: true,
147
+ allowNo: true,
148
+ description: bilingual({
149
+ zh: '把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)',
150
+ en: 'Feed production-health observations back to managed skills of the same name (--no-feedback to disable)',
151
+ }),
152
+ }),
83
153
  };
84
154
  async run() {
85
155
  const { args, flags } = await this.parse(Observe);
@@ -120,7 +190,7 @@ export default class Observe extends BaseCommand {
120
190
  const outDir = flags['output-dir']
121
191
  ? resolve(flags['output-dir'])
122
192
  : (flags.global ? globalObserveHealthDir() : projectObserveHealthDir());
123
- const { jsonPath } = persistObserveHealthReport(report, outDir);
193
+ const { id, jsonPath } = persistObserveHealthReport(report, outDir);
124
194
  const { sessionCount, segmentCount, toolCallCount, toolFailureRate } = report.meta;
125
195
  console.log('');
126
196
  console.log(`sessions: ${sessionCount} · segments: ${segmentCount} · tool calls: ${toolCallCount} · fail rate: ${(toolFailureRate * 100).toFixed(1)}%`);
@@ -139,6 +209,9 @@ export default class Observe extends BaseCommand {
139
209
  console.log('');
140
210
  console.log(`report written to: ${jsonPath}`);
141
211
  console.log(tCli('cli.observe.view_hint', lang));
212
+ // #235 受管反哺:把生产健康观测落进同名受管 skill(--no-feedback 关)。非致命旁路。
213
+ if (flags.feedback)
214
+ await recordObserveFeedback(report, id, lang);
142
215
  });
143
216
  }
144
217
  }
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
2
+ export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.observe.observation_recorded' | 'cli.observe.production_gap' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
3
3
  export declare const commonDict: Record<CommonMessageKey, CliMessage>;
@@ -59,6 +59,14 @@ export const commonDict = {
59
59
  zh: '分析 JSON 已写入 output-dir;后续可用 omk observe 持续生成健康报告。',
60
60
  en: 'Analysis JSON written to output-dir; use omk observe to keep producing health reports.',
61
61
  },
62
+ 'cli.observe.observation_recorded': {
63
+ zh: '🔭 已为受管 skill「{name}」记录生产健康观测。\n',
64
+ en: '🔭 Recorded a production-health observation for managed skill "{name}".\n',
65
+ },
66
+ 'cli.observe.production_gap': {
67
+ zh: '⚠️ 受管 skill「{name}」线上检测到生产盲区({areas})。建议补充对应用例后重跑 omk eval —— observe 只提示,不改样本集。\n',
68
+ en: '⚠️ Managed skill "{name}" shows production gaps in real traffic ({areas}). Consider adding matching samples and re-running omk eval — observe only suggests, it does not edit the sample set.\n',
69
+ },
62
70
  'cli.studio.started': {
63
71
  zh: 'studio 已启动:{url}',
64
72
  en: 'Studio running at {url}',
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.legend';
2
+ export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.production_gap_note' | 'cli.list.legend';
3
3
  export declare const listDict: Record<ListMessageKey, CliMessage>;
@@ -29,6 +29,10 @@ export const listDict = {
29
29
  zh: '✓ = 当前版本已按证据人工接受为 promoted(omk promote)。\n',
30
30
  en: '✓ = current version accepted as promoted on evidence (omk promote).\n',
31
31
  },
32
+ 'cli.list.production_gap_note': {
33
+ zh: '🔬 = observe 在线上检测到生产盲区(与生命周期无关的版本无关信号);建议补对应用例后重跑 omk eval。\n',
34
+ en: '🔬 = observe detected a production gap in real traffic (a version-agnostic signal, orthogonal to lifecycle); add matching samples and re-run omk eval.\n',
35
+ },
32
36
  'cli.list.legend': {
33
37
  zh: '证据列 = 当前有效 / 全部(历史含旧内容证据,供回滚)。\n',
34
38
  en: 'EVIDENCE column = current / total (history keeps old-content evidence for rollback).\n',
@@ -2,26 +2,35 @@ import { resolve } from 'node:path';
2
2
  import { createOverlayReportStore } from '../../server/report-store.js';
3
3
  import { projectReportsDir, globalReportsDir } from '../../eval-core/measurement-dirs.js';
4
4
  import { computeVerdict } from '../../eval-core/verdict.js';
5
- import { bootstrapDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES } from '../../eval-core/bootstrap.js';
5
+ import { bootstrapPairedDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES } from '../../eval-core/bootstrap.js';
6
6
  import { resolveManagedDir, managedDir, loadAllManagedRecords, appendManagedEvidence, rebaselineManagedContentHash, buildEvidenceRef, probeSourceState, } from '../../managed/index.js';
7
7
  /**
8
8
  * round-bestRound vs round-0 的忠实 verdict:复刻 eval 管线(evaluation-reporting.ts)对两变体抽
9
- * per-sample composite → bootstrapDiffCI → computeVerdict,与 `omk eval --bootstrap` 同口径、同 α / 重采样数。
9
+ * per-sample composite → sample 配对 bootstrapPairedDiffCI → computeVerdict,与 `omk eval --bootstrap`
10
+ * 同口径、同 α / 重采样数。
10
11
  * 不自造门限:评委是否显著、是否 PROGRESS 全交给既有 computeVerdict。
11
12
  */
12
13
  function winnerVerdict(report, winnerVariant) {
13
14
  const baseline = 'round-0';
14
- const scoresOf = (v) => report.results
15
- .map((r) => r.variants[v])
16
- .filter((e) => !!e && typeof e.compositeScore === 'number' && e.compositeScore > 0)
17
- .map((e) => e.compositeScore);
18
- const ctrl = scoresOf(baseline);
19
- const treat = scoresOf(winnerVariant);
20
- const pairComparisons = ctrl.length >= 2 && treat.length >= 2
15
+ // sample **配对**(与 evaluation-reporting 主 A/B 同口径 —— 本函数职责就是复刻 eval 管线):baseline
16
+ // winner 在同一 sample 上都可测(composite > 0)才入对。同一 sample 两版分数正相关,配对 bootstrap 收紧 diff
17
+ // CI;独立重采样会高估方差、保守失功效。diff = b a = winner baseline。
18
+ const compositeOf = (v, r) => {
19
+ const e = r.variants[v];
20
+ return e && typeof e.compositeScore === 'number' && e.compositeScore > 0 ? e.compositeScore : undefined;
21
+ };
22
+ const pairs = [];
23
+ for (const r of report.results) {
24
+ const a = compositeOf(baseline, r);
25
+ const b = compositeOf(winnerVariant, r);
26
+ if (a !== undefined && b !== undefined)
27
+ pairs.push({ a, b });
28
+ }
29
+ const pairComparisons = pairs.length >= 2
21
30
  ? [{
22
31
  control: baseline,
23
32
  treatment: winnerVariant,
24
- diffBootstrapCI: bootstrapDiffCI(ctrl, treat, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES),
33
+ diffBootstrapCI: bootstrapPairedDiffCI(pairs, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES),
25
34
  }]
26
35
  : undefined;
27
36
  const slice = {
@@ -17,8 +17,14 @@
17
17
  * - bootstrapDiffCI: CI for the difference (B - A); 0 outside the CI = significant
18
18
  * - bootstrapWithMetric: generic interface so saturation analysis can reuse
19
19
  *
20
- * Reproducibility: pass a fixed `seed` to get deterministic CIs across runs.
21
- * Without a seed we use Math.random() fine for production but not for tests.
20
+ * Reproducibility: CIs are **deterministic by default** when no `seed` is passed,
21
+ * a fixed `DEFAULT_BOOTSTRAP_SEED` is used, so the same eval run twice yields
22
+ * byte-identical CIs (and a stable verdict near the significance boundary). This is
23
+ * a measurement-validity requirement: an unseeded `Math.random()` would let the
24
+ * `significant` flag flip between identical runs. Library callers (and specific paths
25
+ * such as `eval gold compare --seed`) may pass an explicit `seed` to vary the draw; the
26
+ * main `omk eval` deliberately exposes no seed knob — a fixed default also prevents
27
+ * seed-shopping for significance.
22
28
  */
23
29
  export interface BootstrapCI {
24
30
  /** Lower bound of the CI. */
@@ -42,6 +48,21 @@ export interface BootstrapDiffCI extends BootstrapCI {
42
48
  export declare const DEFAULT_BOOTSTRAP_SAMPLES = 1000;
43
49
  /** Default significance level; 0.05 → 95% CI. */
44
50
  export declare const DEFAULT_BOOTSTRAP_ALPHA = 0.05;
51
+ /**
52
+ * Fixed default bootstrap seed → CIs are **deterministic by default** (omk default-strict:
53
+ * reproducibility affects verdict validity, so it is on by default, not opt-in). The specific
54
+ * value is arbitrary — only that it is **fixed** matters; it is an implementation detail, not a
55
+ * user-facing constant, so unlike DEFAULT_BOOTSTRAP_SAMPLES / α it is neither cited in docs nor
56
+ * guarded by `doc-constants-drift.test.ts`. Callers wanting a different draw pass an explicit `seed`.
57
+ */
58
+ export declare const DEFAULT_BOOTSTRAP_SEED = 20260616;
59
+ /**
60
+ * Confidence-level label for display: `(1 − α)·100%`. Multiple-comparison (Bonferroni) correction
61
+ * shrinks a pairwise α to α/K and widens the CI accordingly — the label must track α so a corrected
62
+ * (wider) interval is never mislabeled "95%". No alpha (single comparison / classic A-B) ⇒ nominal 95%.
63
+ * Shared single source for the HTML renderer and the `omk eval` CLI verdict so both read one scale.
64
+ */
65
+ export declare function ciLevelLabel(alpha?: number): string;
45
66
  /**
46
67
  * Bootstrap confidence interval for the mean of a single sample.
47
68
  *
@@ -68,6 +89,36 @@ export declare function bootstrapMeanCI(scores: number[], alpha?: number, sample
68
89
  * @returns BootstrapDiffCI with low/high of (B - A) and significant flag.
69
90
  */
70
91
  export declare function bootstrapDiffCI(scoresA: number[], scoresB: number[], alpha?: number, samples?: number, seed?: number): BootstrapDiffCI;
92
+ /**
93
+ * Bootstrap CI for the *difference* of two means on **paired** observations — when A and B
94
+ * are two measurements of the **same unit** (e.g. control vs treatment on the same sample,
95
+ * or original vs alternate judge prompt on the same response). Resamples the **pair indices
96
+ * jointly** and averages each pair's `b - a`, so the within-pair correlation is preserved.
97
+ *
98
+ * Why paired (vs `bootstrapDiffCI`'s independent resampling): when A and B move together
99
+ * across units (the usual case — the same sample scored by two variants is positively
100
+ * correlated), much of each group's variance is shared and cancels in the per-pair diff.
101
+ * The independent (unpaired) bootstrap ignores that, over-states the diff's variance, and
102
+ * widens the CI — *conservative*, costing real power. Use paired whenever the design is
103
+ * paired; use `bootstrapDiffCI` only for genuinely independent groups (or where a deliberate
104
+ * conservative bias is wanted). The point estimate is identical (mean of per-pair diffs =
105
+ * difference of paired means); only the CI tightens.
106
+ *
107
+ * `significant` is derived from the **rounded** `low`/`high` (the persisted bounds), so the
108
+ * flag never contradicts what is stored / displayed: a CI that rounds to include 0 reads as
109
+ * not-significant. (Computing it on the unrounded bounds would let the JSON say `low: 0,
110
+ * significant: true` — a self-contradictory `CI=[0, …]` that downstream `computeVerdict` and
111
+ * external consumers cannot reconcile.) Matches `bootstrapDiffCI`.
112
+ *
113
+ * @param pairs Aligned observations; `a` = control/baseline, `b` = treatment. diff = b - a.
114
+ * @param alpha Significance level. Default 0.05.
115
+ * @param samples Bootstrap resamples. Default 1000.
116
+ * @param seed Optional seed (deterministic by default — see module header).
117
+ */
118
+ export declare function bootstrapPairedDiffCI(pairs: Array<{
119
+ a: number;
120
+ b: number;
121
+ }>, alpha?: number, samples?: number, seed?: number): BootstrapDiffCI;
71
122
  /**
72
123
  * Generic bootstrap CI for an arbitrary sample-level metric. Used by
73
124
  * saturation analysis to get CI on metrics like stddev or
@@ -17,8 +17,14 @@
17
17
  * - bootstrapDiffCI: CI for the difference (B - A); 0 outside the CI = significant
18
18
  * - bootstrapWithMetric: generic interface so saturation analysis can reuse
19
19
  *
20
- * Reproducibility: pass a fixed `seed` to get deterministic CIs across runs.
21
- * Without a seed we use Math.random() fine for production but not for tests.
20
+ * Reproducibility: CIs are **deterministic by default** when no `seed` is passed,
21
+ * a fixed `DEFAULT_BOOTSTRAP_SEED` is used, so the same eval run twice yields
22
+ * byte-identical CIs (and a stable verdict near the significance boundary). This is
23
+ * a measurement-validity requirement: an unseeded `Math.random()` would let the
24
+ * `significant` flag flip between identical runs. Library callers (and specific paths
25
+ * such as `eval gold compare --seed`) may pass an explicit `seed` to vary the draw; the
26
+ * main `omk eval` deliberately exposes no seed knob — a fixed default also prevents
27
+ * seed-shopping for significance.
22
28
  */
23
29
  /**
24
30
  * Default number of bootstrap resamples. Every eval path uses this unless
@@ -28,6 +34,24 @@
28
34
  export const DEFAULT_BOOTSTRAP_SAMPLES = 1000;
29
35
  /** Default significance level; 0.05 → 95% CI. */
30
36
  export const DEFAULT_BOOTSTRAP_ALPHA = 0.05;
37
+ /**
38
+ * Fixed default bootstrap seed → CIs are **deterministic by default** (omk default-strict:
39
+ * reproducibility affects verdict validity, so it is on by default, not opt-in). The specific
40
+ * value is arbitrary — only that it is **fixed** matters; it is an implementation detail, not a
41
+ * user-facing constant, so unlike DEFAULT_BOOTSTRAP_SAMPLES / α it is neither cited in docs nor
42
+ * guarded by `doc-constants-drift.test.ts`. Callers wanting a different draw pass an explicit `seed`.
43
+ */
44
+ export const DEFAULT_BOOTSTRAP_SEED = 20260616;
45
+ /**
46
+ * Confidence-level label for display: `(1 − α)·100%`. Multiple-comparison (Bonferroni) correction
47
+ * shrinks a pairwise α to α/K and widens the CI accordingly — the label must track α so a corrected
48
+ * (wider) interval is never mislabeled "95%". No alpha (single comparison / classic A-B) ⇒ nominal 95%.
49
+ * Shared single source for the HTML renderer and the `omk eval` CLI verdict so both read one scale.
50
+ */
51
+ export function ciLevelLabel(alpha) {
52
+ const pct = (1 - (alpha ?? DEFAULT_BOOTSTRAP_ALPHA)) * 100;
53
+ return `${Number.isInteger(pct) ? pct : Number(pct.toFixed(1))}%`;
54
+ }
31
55
  /** Mulberry32 PRNG — seedable, deterministic for tests. */
32
56
  function mulberry32(seed) {
33
57
  let s = seed >>> 0;
@@ -40,9 +64,9 @@ function mulberry32(seed) {
40
64
  };
41
65
  }
42
66
  function makeRng(seed) {
43
- if (seed == null)
44
- return Math.random;
45
- return mulberry32(seed);
67
+ // 默认确定性:无显式 seed 时退 DEFAULT_BOOTSTRAP_SEED(而非 Math.random)——否则同一 eval 两跑会得到
68
+ // 不同 CI,临界点 significant 翻转 → verdict 不可复现。见模块头 Reproducibility。
69
+ return mulberry32(seed ?? DEFAULT_BOOTSTRAP_SEED);
46
70
  }
47
71
  /** Sample n indices with replacement from [0, length) using the given PRNG. */
48
72
  function resampleIndices(length, n, rng) {
@@ -150,6 +174,59 @@ export function bootstrapDiffCI(scoresA, scoresB, alpha = DEFAULT_BOOTSTRAP_ALPH
150
174
  significant: !(low <= 0 && 0 <= high),
151
175
  };
152
176
  }
177
+ /**
178
+ * Bootstrap CI for the *difference* of two means on **paired** observations — when A and B
179
+ * are two measurements of the **same unit** (e.g. control vs treatment on the same sample,
180
+ * or original vs alternate judge prompt on the same response). Resamples the **pair indices
181
+ * jointly** and averages each pair's `b - a`, so the within-pair correlation is preserved.
182
+ *
183
+ * Why paired (vs `bootstrapDiffCI`'s independent resampling): when A and B move together
184
+ * across units (the usual case — the same sample scored by two variants is positively
185
+ * correlated), much of each group's variance is shared and cancels in the per-pair diff.
186
+ * The independent (unpaired) bootstrap ignores that, over-states the diff's variance, and
187
+ * widens the CI — *conservative*, costing real power. Use paired whenever the design is
188
+ * paired; use `bootstrapDiffCI` only for genuinely independent groups (or where a deliberate
189
+ * conservative bias is wanted). The point estimate is identical (mean of per-pair diffs =
190
+ * difference of paired means); only the CI tightens.
191
+ *
192
+ * `significant` is derived from the **rounded** `low`/`high` (the persisted bounds), so the
193
+ * flag never contradicts what is stored / displayed: a CI that rounds to include 0 reads as
194
+ * not-significant. (Computing it on the unrounded bounds would let the JSON say `low: 0,
195
+ * significant: true` — a self-contradictory `CI=[0, …]` that downstream `computeVerdict` and
196
+ * external consumers cannot reconcile.) Matches `bootstrapDiffCI`.
197
+ *
198
+ * @param pairs Aligned observations; `a` = control/baseline, `b` = treatment. diff = b - a.
199
+ * @param alpha Significance level. Default 0.05.
200
+ * @param samples Bootstrap resamples. Default 1000.
201
+ * @param seed Optional seed (deterministic by default — see module header).
202
+ */
203
+ export function bootstrapPairedDiffCI(pairs, alpha = DEFAULT_BOOTSTRAP_ALPHA, samples = DEFAULT_BOOTSTRAP_SAMPLES, seed) {
204
+ if (pairs.length === 0) {
205
+ return { low: 0, high: 0, estimate: 0, samples: 0, significant: false };
206
+ }
207
+ const n = pairs.length;
208
+ const diffs = pairs.map((p) => p.b - p.a);
209
+ const rng = makeRng(seed);
210
+ const resampleDiffMeans = new Array(samples);
211
+ for (let s = 0; s < samples; s++) {
212
+ const idx = resampleIndices(n, n, rng);
213
+ let sum = 0;
214
+ for (const i of idx)
215
+ sum += diffs[i];
216
+ resampleDiffMeans[s] = sum / n;
217
+ }
218
+ resampleDiffMeans.sort((a, b) => a - b);
219
+ const low = round4(sortedQuantile(resampleDiffMeans, alpha / 2));
220
+ const high = round4(sortedQuantile(resampleDiffMeans, 1 - alpha / 2));
221
+ return {
222
+ low,
223
+ high,
224
+ estimate: round4(mean(diffs)),
225
+ samples,
226
+ // significant 与持久化的(舍入)边界一致 —— 见函数头:绝不出现「low:0 但 significant:true」自相矛盾。
227
+ significant: !(low <= 0 && 0 <= high),
228
+ };
229
+ }
153
230
  /**
154
231
  * Generic bootstrap CI for an arbitrary sample-level metric. Used by
155
232
  * saturation analysis to get CI on metrics like stddev or
@@ -8,7 +8,7 @@ import { indexReportWrite } from './artifact-index.js';
8
8
  import { buildVariantSummary } from './schema.js';
9
9
  import { buildVariantConfig, resolveExecutionStrategy } from './execution-strategy.js';
10
10
  import { getJudgePromptHash } from '../grading/judge.js';
11
- import { bootstrapMeanCI, bootstrapDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES, } from './bootstrap.js';
11
+ import { bootstrapMeanCI, bootstrapPairedDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES, } from './bootstrap.js';
12
12
  import { getExecutorRuntimeFingerprint } from '../executors/runtime-fingerprint.js';
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  function findPackageJson(startDir) {
@@ -121,6 +121,9 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
121
121
  const bootstrapSamples = request?.bootstrapSamples ?? DEFAULT_BOOTSTRAP_SAMPLES;
122
122
  let pairComparisons;
123
123
  if (bootstrapEnabled) {
124
+ // 不变量(见 grading/layered-scores.ts):composite 在至少一层可测时恒 ≥ 1,`compositeScore === 0`
125
+ // 当且仅当该样本**无任何可测层**(真·缺测,如纯评委样本且评委失败)。故 `> 0` 过滤精确剔除非测量、
126
+ // 绝不丢"低分内容"(评委失败已在上游当缺测,不会以 0 进 composite)。下同(control / treatment)。
124
127
  for (const variant of variants) {
125
128
  const entries = Object.values(results).map((r) => r[variant]).filter(Boolean);
126
129
  const compositeScores = entries
@@ -135,23 +138,42 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
135
138
  if (variants.length >= 2) {
136
139
  pairComparisons = [];
137
140
  const controlName = variants[0];
138
- const controlEntries = Object.values(results).map((r) => r[controlName]).filter(Boolean);
139
- const controlScores = controlEntries
140
- .filter((e) => typeof e.compositeScore === 'number' && e.compositeScore > 0)
141
- .map((e) => e.compositeScore);
141
+ const sampleRecords = Object.values(results);
142
+ // **配对** diff CI:A/B 是同一批 sample 分别过 control / treatment(配对设计),按 sample 对齐 ——
143
+ // 同一 sample control treatment 都可测(composite > 0,见上不变量)才入对。同一 sample 两 variant
144
+ // 的分数正相关,配对 bootstrap 据此抵消共有方差、收紧 diff CI、更有功效;旧的独立(非配对)重采样高估
145
+ // 方差、CI 偏宽、保守失功效(见 bootstrapPairedDiffCI)。点估计不变,只收紧 CI。
146
+ // 先收齐每个 treatment 的配对数据;只有 ≥2 对(否则无 CI 可算)才算一个真正被检验的比较。
147
+ const eligible = [];
142
148
  for (let i = 1; i < variants.length; i++) {
143
149
  const treatmentName = variants[i];
144
- const treatmentEntries = Object.values(results).map((r) => r[treatmentName]).filter(Boolean);
145
- const treatmentScores = treatmentEntries
146
- .filter((e) => typeof e.compositeScore === 'number' && e.compositeScore > 0)
147
- .map((e) => e.compositeScore);
148
- if (controlScores.length >= 2 && treatmentScores.length >= 2) {
149
- pairComparisons.push({
150
- control: controlName,
151
- treatment: treatmentName,
152
- diffBootstrapCI: bootstrapDiffCI(controlScores, treatmentScores, DEFAULT_BOOTSTRAP_ALPHA, bootstrapSamples),
153
- });
150
+ const pairs = [];
151
+ for (const r of sampleRecords) {
152
+ const c = r[controlName];
153
+ const t = r[treatmentName];
154
+ const a = c && typeof c.compositeScore === 'number' && c.compositeScore > 0 ? c.compositeScore : undefined;
155
+ const b = t && typeof t.compositeScore === 'number' && t.compositeScore > 0 ? t.compositeScore : undefined;
156
+ if (a !== undefined && b !== undefined)
157
+ pairs.push({ a, b });
154
158
  }
159
+ if (pairs.length >= 2)
160
+ eligible.push({ treatment: treatmentName, pairs });
161
+ }
162
+ // 多重比较(Bonferroni)校正:同时检验 K 个 treatment-vs-control 假设时,family-wise 假阳性随 K 膨胀
163
+ // (computeVerdict 的 worst-case roll-up 取最差 —— 任一对假阳即拉高总判定)。每对 CI 用 α/K(K = 实际
164
+ // 产出 CI 的比较数)把 family-wise error 压回名义 α。K=1(单 treatment / 经典 A-B)即 α 不变、与历史单对
165
+ // 口径逐字节一致,此时不写 alpha 字段(渲染按名义 95% CI,既有报告 / 快照不动)。CI 与 significant 同在 α/K
166
+ // 下算,二者自洽(绝不出现 CI 含 0 却 significant 的矛盾)。注:K 很大时 α/K 落到极端分位,1000 重采样的
167
+ // 尾部分位偏粗,大 K 慎读 —— 不在本 PR 提采样数。
168
+ const familySize = eligible.length;
169
+ const perComparisonAlpha = familySize >= 1 ? DEFAULT_BOOTSTRAP_ALPHA / familySize : DEFAULT_BOOTSTRAP_ALPHA;
170
+ for (const { treatment, pairs } of eligible) {
171
+ pairComparisons.push({
172
+ control: controlName,
173
+ treatment,
174
+ diffBootstrapCI: bootstrapPairedDiffCI(pairs, perComparisonAlpha, bootstrapSamples),
175
+ ...(familySize >= 2 ? { alpha: perComparisonAlpha } : {}),
176
+ });
155
177
  }
156
178
  }
157
179
  }
@@ -88,7 +88,8 @@ export function buildVariantResult(execResult, gradeResult, options) {
88
88
  layeredScores.factScore = assertionFact != null
89
89
  ? Number(((assertionFact + hardScore) / 2).toFixed(2))
90
90
  : hardScore;
91
- // Recompute composite from updated layers (保留 0 分,仅过滤真正缺失)
91
+ // Recompute composite from updated layers. 仅过滤 null/undefined(缺测层);评委失败时 judgeScore
92
+ // 本就是 undefined(见 grading 的 score=0 修复:评委失败=缺测、不以 0 进层),不存在「0 分」要保留。
92
93
  const scores = [layeredScores.factScore, layeredScores.behaviorScore, layeredScores.judgeScore].filter((s) => s != null);
93
94
  compositeScore = scores.length > 0 ? Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)) : compositeScore;
94
95
  }
@@ -204,8 +205,9 @@ export function buildVariantSummary(entries) {
204
205
  };
205
206
  })(),
206
207
  ...(() => {
207
- // 保留 0 分用例(评委打"完全不合格"是合法低分,不是缺失)
208
- // filter null / undefined(真正缺数据,如该 sample 无对应断言或未配 judge)
208
+ // 各层分量为 null/undefined = 该层缺测(无对应断言 / 未配 judge / 评委失败)。评委失败已在 grading 层
209
+ // 当缺测、judgeScore undefined( score=0 修复),不存在「0 分内容」要保留;评委有效分恒 1-5
210
+ // filter != null 即精确剔除缺测层。
209
211
  const factScores = ok.map((e) => e.layeredScores?.factScore).filter((s) => s != null);
210
212
  const behaviorScores = ok.map((e) => e.layeredScores?.behaviorScore).filter((s) => s != null);
211
213
  const judgeScores = ok.map((e) => e.layeredScores?.judgeScore).filter((s) => s != null);