@akemi-mio/creativity 0.1.0 → 0.1.1
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/dist/ExperimentRunStore.d.ts +97 -0
- package/dist/ExperimentRunStore.js +160 -0
- package/dist/InsightSamplingExperiment.d.ts +82 -0
- package/dist/InsightSamplingExperiment.js +308 -0
- package/dist/MechanismGraph.d.ts +46 -0
- package/dist/MechanismGraph.js +76 -0
- package/dist/MechanismRegistry.d.ts +118 -0
- package/dist/MechanismRegistry.js +233 -0
- package/dist/WhenThenRunner.d.ts +135 -0
- package/dist/WhenThenRunner.js +358 -0
- package/dist/types.d.ts +15 -1
- package/package.json +32 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExperimentRunStore — 实验运行存储(机制涌现 P2b,定案 D4/D5/A1/A3/A4)
|
|
3
|
+
*
|
|
4
|
+
* ExperimentRun 实体:candidateId/baselineId/assignment/outcome/metrics/evidenceIds/stale。
|
|
5
|
+
* 三模式:baseline/candidate/ablation;消融缺任一侧=inconclusive。
|
|
6
|
+
* 停止规则:minSamples 硬门槛(默认 30),n<minSamples 只能产出 inconclusive、禁进 PromotionRequest。
|
|
7
|
+
* stale 标记:baseSnapshotId 与当前 runtime 不匹配时标 stale,stale 禁晋级。
|
|
8
|
+
* Memory 机制性隔离(A1):MIO EXPERIMENT 标志路由 experiment-memory.jsonl。
|
|
9
|
+
*/
|
|
10
|
+
export type ExperimentAssignment = 'baseline' | 'candidate' | 'ablation';
|
|
11
|
+
export type ExperimentOutcome = 'success' | 'failure' | 'inconclusive';
|
|
12
|
+
export interface ExperimentRun {
|
|
13
|
+
id: string;
|
|
14
|
+
hypothesisId?: string;
|
|
15
|
+
candidateId?: string;
|
|
16
|
+
baselineId?: string;
|
|
17
|
+
assignment: ExperimentAssignment;
|
|
18
|
+
outcome: ExperimentOutcome;
|
|
19
|
+
/** 指标快照(白名单标量) */
|
|
20
|
+
metrics: Record<string, number | boolean | string>;
|
|
21
|
+
/** 证据 trace/memory id 列表 */
|
|
22
|
+
evidenceIds: string[];
|
|
23
|
+
/** 快照 id(实验时的 runtime 快照) */
|
|
24
|
+
baseSnapshotId?: string;
|
|
25
|
+
/** stale:baseSnapshotId 与当前 runtime 不匹配 → 禁晋级 */
|
|
26
|
+
stale?: boolean;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
completedAt?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface StoppingRuleConfig {
|
|
31
|
+
/** 最小样本量硬门槛;n < minSamples → 只能 inconclusive,禁晋级 */
|
|
32
|
+
minSamples: number;
|
|
33
|
+
/** 最大样本量(可选,达到后强制停止) */
|
|
34
|
+
maxSamples?: number;
|
|
35
|
+
/** 最小成功率阈值(可选,0-1;达到后可提前 promote 候选) */
|
|
36
|
+
minSuccessRate?: number;
|
|
37
|
+
/** 最大失败率阈值(可选,0-1;超过后可提前 reject 候选) */
|
|
38
|
+
maxFailureRate?: number;
|
|
39
|
+
/** 实验最大持续时间 ms(可选) */
|
|
40
|
+
maxDurationMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/** 默认停止规则:minSamples=30(硬门槛) */
|
|
43
|
+
export declare const DEFAULT_STOPPING_RULE: StoppingRuleConfig;
|
|
44
|
+
/**
|
|
45
|
+
* 解析并校验停止规则 JSON 配置。
|
|
46
|
+
* 失败返回 null(fail-closed);未知字段忽略。
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseStoppingRule(raw: unknown): StoppingRuleConfig | null;
|
|
49
|
+
export interface AssignmentResult {
|
|
50
|
+
assignment: ExperimentAssignment;
|
|
51
|
+
reason?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 根据可用侧分配三模式。
|
|
55
|
+
* - 有 candidate + baseline → candidate(对照)
|
|
56
|
+
* - 只有 baseline → baseline
|
|
57
|
+
* - 消融:需要 candidate + baseline + ablation 标识;缺任一侧 → inconclusive
|
|
58
|
+
*/
|
|
59
|
+
export declare function assignExperimentMode(input: {
|
|
60
|
+
candidateId?: string;
|
|
61
|
+
baselineId?: string;
|
|
62
|
+
ablationId?: string;
|
|
63
|
+
}): AssignmentResult;
|
|
64
|
+
export interface StoppingDecision {
|
|
65
|
+
/** 是否应停止实验 */
|
|
66
|
+
shouldStop: boolean;
|
|
67
|
+
/** 推荐结果(仅在 n>=minSamples 且有足够信息时非 null) */
|
|
68
|
+
recommended: ExperimentOutcome | null;
|
|
69
|
+
/** 停止原因(公开原因,不泄漏沙箱内部) */
|
|
70
|
+
reason: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 基于样本评估停止规则。
|
|
74
|
+
*
|
|
75
|
+
* - n < minSamples → 只能产出 inconclusive(A3 硬门槛)
|
|
76
|
+
* - n >= minSamples 且有 maxFailureRate/minSuccessRate → 可提前停止
|
|
77
|
+
* - maxSamples / maxDurationMs 达到 → 停止
|
|
78
|
+
*/
|
|
79
|
+
export declare function evaluateStoppingRule(config: StoppingRuleConfig, runs: readonly ExperimentRun[], now?: number): StoppingDecision;
|
|
80
|
+
export interface PromotionGateResult {
|
|
81
|
+
allowed: boolean;
|
|
82
|
+
/** 公开原因 */
|
|
83
|
+
reason: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 检查是否允许进入 PromotionRequest。
|
|
87
|
+
* A3:n < minSamples 禁晋级。
|
|
88
|
+
* A4:stale 禁晋级。
|
|
89
|
+
*/
|
|
90
|
+
export declare function checkPromotionGate(config: StoppingRuleConfig, runs: readonly ExperimentRun[], currentSnapshotId?: string): PromotionGateResult;
|
|
91
|
+
/**
|
|
92
|
+
* 实验 Memory 路由:MIO EXPERIMENT 标志 → experiment-memory.jsonl。
|
|
93
|
+
* 服务端 memory-record 检测此标志并强制路由。
|
|
94
|
+
*/
|
|
95
|
+
export declare function routeExperimentMemoryPath(baseMemoryPath: string, isExperiment: boolean): string;
|
|
96
|
+
/** MIO EXPERIMENT 环境标志检测 */
|
|
97
|
+
export declare function isExperimentMode(env?: Record<string, string | undefined>): boolean;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ExperimentRunStore — 实验运行存储(机制涌现 P2b,定案 D4/D5/A1/A3/A4)
|
|
4
|
+
*
|
|
5
|
+
* ExperimentRun 实体:candidateId/baselineId/assignment/outcome/metrics/evidenceIds/stale。
|
|
6
|
+
* 三模式:baseline/candidate/ablation;消融缺任一侧=inconclusive。
|
|
7
|
+
* 停止规则:minSamples 硬门槛(默认 30),n<minSamples 只能产出 inconclusive、禁进 PromotionRequest。
|
|
8
|
+
* stale 标记:baseSnapshotId 与当前 runtime 不匹配时标 stale,stale 禁晋级。
|
|
9
|
+
* Memory 机制性隔离(A1):MIO EXPERIMENT 标志路由 experiment-memory.jsonl。
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.DEFAULT_STOPPING_RULE = void 0;
|
|
13
|
+
exports.parseStoppingRule = parseStoppingRule;
|
|
14
|
+
exports.assignExperimentMode = assignExperimentMode;
|
|
15
|
+
exports.evaluateStoppingRule = evaluateStoppingRule;
|
|
16
|
+
exports.checkPromotionGate = checkPromotionGate;
|
|
17
|
+
exports.routeExperimentMemoryPath = routeExperimentMemoryPath;
|
|
18
|
+
exports.isExperimentMode = isExperimentMode;
|
|
19
|
+
/** 默认停止规则:minSamples=30(硬门槛) */
|
|
20
|
+
exports.DEFAULT_STOPPING_RULE = { minSamples: 30 };
|
|
21
|
+
/**
|
|
22
|
+
* 解析并校验停止规则 JSON 配置。
|
|
23
|
+
* 失败返回 null(fail-closed);未知字段忽略。
|
|
24
|
+
*/
|
|
25
|
+
function parseStoppingRule(raw) {
|
|
26
|
+
if (raw === null || typeof raw !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
const obj = raw;
|
|
29
|
+
const minSamples = obj.minSamples;
|
|
30
|
+
if (typeof minSamples !== 'number' || !Number.isFinite(minSamples) || minSamples < 1)
|
|
31
|
+
return null;
|
|
32
|
+
const config = { minSamples: Math.floor(minSamples) };
|
|
33
|
+
if (typeof obj.maxSamples === 'number' && Number.isFinite(obj.maxSamples) && obj.maxSamples >= config.minSamples) {
|
|
34
|
+
config.maxSamples = Math.floor(obj.maxSamples);
|
|
35
|
+
}
|
|
36
|
+
if (typeof obj.minSuccessRate === 'number' && Number.isFinite(obj.minSuccessRate) && obj.minSuccessRate >= 0 && obj.minSuccessRate <= 1) {
|
|
37
|
+
config.minSuccessRate = obj.minSuccessRate;
|
|
38
|
+
}
|
|
39
|
+
if (typeof obj.maxFailureRate === 'number' && Number.isFinite(obj.maxFailureRate) && obj.maxFailureRate >= 0 && obj.maxFailureRate <= 1) {
|
|
40
|
+
config.maxFailureRate = obj.maxFailureRate;
|
|
41
|
+
}
|
|
42
|
+
if (typeof obj.maxDurationMs === 'number' && Number.isFinite(obj.maxDurationMs) && obj.maxDurationMs > 0) {
|
|
43
|
+
config.maxDurationMs = Math.floor(obj.maxDurationMs);
|
|
44
|
+
}
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 根据可用侧分配三模式。
|
|
49
|
+
* - 有 candidate + baseline → candidate(对照)
|
|
50
|
+
* - 只有 baseline → baseline
|
|
51
|
+
* - 消融:需要 candidate + baseline + ablation 标识;缺任一侧 → inconclusive
|
|
52
|
+
*/
|
|
53
|
+
function assignExperimentMode(input) {
|
|
54
|
+
const { candidateId, baselineId, ablationId } = input;
|
|
55
|
+
if (ablationId) {
|
|
56
|
+
// 消融需要双侧对照
|
|
57
|
+
if (!candidateId || !baselineId) {
|
|
58
|
+
return { assignment: 'ablation', reason: 'inconclusive_missing_side' };
|
|
59
|
+
}
|
|
60
|
+
return { assignment: 'ablation' };
|
|
61
|
+
}
|
|
62
|
+
if (candidateId && baselineId)
|
|
63
|
+
return { assignment: 'candidate' };
|
|
64
|
+
if (baselineId)
|
|
65
|
+
return { assignment: 'baseline' };
|
|
66
|
+
return { assignment: 'baseline', reason: 'no_baseline' };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 基于样本评估停止规则。
|
|
70
|
+
*
|
|
71
|
+
* - n < minSamples → 只能产出 inconclusive(A3 硬门槛)
|
|
72
|
+
* - n >= minSamples 且有 maxFailureRate/minSuccessRate → 可提前停止
|
|
73
|
+
* - maxSamples / maxDurationMs 达到 → 停止
|
|
74
|
+
*/
|
|
75
|
+
function evaluateStoppingRule(config, runs, now = Date.now()) {
|
|
76
|
+
const n = runs.length;
|
|
77
|
+
const successCount = runs.filter((r) => r.outcome === 'success').length;
|
|
78
|
+
const failureCount = runs.filter((r) => r.outcome === 'failure').length;
|
|
79
|
+
const inconclusiveCount = runs.filter((r) => r.outcome === 'inconclusive').length;
|
|
80
|
+
const successRate = n > 0 ? successCount / n : 0;
|
|
81
|
+
const failureRate = n > 0 ? failureCount / n : 0;
|
|
82
|
+
// 硬门槛:n < minSamples → inconclusive(A3)
|
|
83
|
+
if (n < config.minSamples) {
|
|
84
|
+
return {
|
|
85
|
+
shouldStop: false,
|
|
86
|
+
recommended: null,
|
|
87
|
+
reason: 'below_min_samples',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// maxSamples
|
|
91
|
+
if (config.maxSamples !== undefined && n >= config.maxSamples) {
|
|
92
|
+
return { shouldStop: true, recommended: majorityOutcome(successCount, failureCount, inconclusiveCount), reason: 'max_samples' };
|
|
93
|
+
}
|
|
94
|
+
// maxFailureRate 超过 → 停止 + reject
|
|
95
|
+
if (config.maxFailureRate !== undefined && failureRate > config.maxFailureRate) {
|
|
96
|
+
return { shouldStop: true, recommended: 'failure', reason: 'failure_rate_exceeded' };
|
|
97
|
+
}
|
|
98
|
+
// minSuccessRate 达到 → 停止 + success
|
|
99
|
+
if (config.minSuccessRate !== undefined && successRate >= config.minSuccessRate) {
|
|
100
|
+
return { shouldStop: true, recommended: 'success', reason: 'success_rate_met' };
|
|
101
|
+
}
|
|
102
|
+
// maxDurationMs
|
|
103
|
+
if (config.maxDurationMs !== undefined) {
|
|
104
|
+
const oldest = Math.min(...runs.map((r) => r.createdAt));
|
|
105
|
+
if (now - oldest >= config.maxDurationMs) {
|
|
106
|
+
return { shouldStop: true, recommended: majorityOutcome(successCount, failureCount, inconclusiveCount), reason: 'max_duration' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { shouldStop: false, recommended: null, reason: 'continue' };
|
|
110
|
+
}
|
|
111
|
+
function majorityOutcome(s, f, i) {
|
|
112
|
+
if (s > f && s > i)
|
|
113
|
+
return 'success';
|
|
114
|
+
if (f > s && f > i)
|
|
115
|
+
return 'failure';
|
|
116
|
+
return 'inconclusive';
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 检查是否允许进入 PromotionRequest。
|
|
120
|
+
* A3:n < minSamples 禁晋级。
|
|
121
|
+
* A4:stale 禁晋级。
|
|
122
|
+
*/
|
|
123
|
+
function checkPromotionGate(config, runs, currentSnapshotId) {
|
|
124
|
+
// A4: stale 检查
|
|
125
|
+
const anyStale = runs.some((r) => r.stale === true);
|
|
126
|
+
if (anyStale) {
|
|
127
|
+
return { allowed: false, reason: 'stale_snapshot' };
|
|
128
|
+
}
|
|
129
|
+
// A4: baseSnapshotId 与当前 runtime 不匹配
|
|
130
|
+
if (currentSnapshotId) {
|
|
131
|
+
const mismatched = runs.some((r) => r.baseSnapshotId !== undefined && r.baseSnapshotId !== currentSnapshotId);
|
|
132
|
+
if (mismatched) {
|
|
133
|
+
return { allowed: false, reason: 'snapshot_mismatch' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// A3: minSamples 硬门槛
|
|
137
|
+
const n = runs.length;
|
|
138
|
+
if (n < config.minSamples) {
|
|
139
|
+
return { allowed: false, reason: 'below_min_samples' };
|
|
140
|
+
}
|
|
141
|
+
return { allowed: true, reason: 'ok' };
|
|
142
|
+
}
|
|
143
|
+
// ══════════════════════════════════════════
|
|
144
|
+
// Memory 机制性隔离(A1)
|
|
145
|
+
// ══════════════════════════════════════════
|
|
146
|
+
/**
|
|
147
|
+
* 实验 Memory 路由:MIO EXPERIMENT 标志 → experiment-memory.jsonl。
|
|
148
|
+
* 服务端 memory-record 检测此标志并强制路由。
|
|
149
|
+
*/
|
|
150
|
+
function routeExperimentMemoryPath(baseMemoryPath, isExperiment) {
|
|
151
|
+
if (!isExperiment)
|
|
152
|
+
return baseMemoryPath;
|
|
153
|
+
// 替换扩展名为 experiment-memory.jsonl
|
|
154
|
+
const dir = baseMemoryPath.includes('/') ? baseMemoryPath.slice(0, baseMemoryPath.lastIndexOf('/') + 1) : '';
|
|
155
|
+
return `${dir}experiment-memory.jsonl`;
|
|
156
|
+
}
|
|
157
|
+
/** MIO EXPERIMENT 环境标志检测 */
|
|
158
|
+
function isExperimentMode(env = process.env) {
|
|
159
|
+
return env.MIO_EXPERIMENT === '1' || env.MIO_EXPERIMENT === 'true';
|
|
160
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InsightSamplingExperiment — insight 抽样策略 dogfood 实验(机制涌现 P3,定案 D4/R4)
|
|
3
|
+
*
|
|
4
|
+
* 首域 = insight 抽样策略(issue-6 P4 sourceQuota 有现成 before/after + 历史 observations 可重放)。
|
|
5
|
+
* 三组对照:现有 quota 抽样 / 变体 A / 变体 B + 消融(移除配额/移除时间序保持)+ 未见任务泛化。
|
|
6
|
+
* 产出 CapabilityDelta 报告;允许 inconclusive(不逼结论)。
|
|
7
|
+
* 硬前置 A1:MIO EXPERIMENT 标志路由 experiment-memory.jsonl(由 ExperimentRunStore 提供)。
|
|
8
|
+
*/
|
|
9
|
+
import type { ExperimentRun } from './ExperimentRunStore';
|
|
10
|
+
/** Observation 最小形状(内联,避免跨包依赖) */
|
|
11
|
+
interface ObservationLike {
|
|
12
|
+
id: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
source: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
export type SamplingVariant = 'baseline' | 'variant_a' | 'variant_b' | 'ablation_no_quota' | 'ablation_no_time_order';
|
|
18
|
+
/**
|
|
19
|
+
* baseline: 现有 quota 抽样(issue-6 P4 sampleBySourceQuota)。
|
|
20
|
+
* variant_a: 更高 minPerSource(保底提升,家族优先更强)。
|
|
21
|
+
* variant_b: 只用 Phase A 保底,不做 Phase B 余量补齐(纯家族公平)。
|
|
22
|
+
* ablation_no_quota: 移除配额 → 退化为 slice(0, limit)(issue-6 修复前)。
|
|
23
|
+
* ablation_no_time_order: 移除时间序保持 → 输出按 weight 降序。
|
|
24
|
+
*/
|
|
25
|
+
export declare function sampleVariant(observations: readonly ObservationLike[], variant: SamplingVariant, limit?: number): ObservationLike[];
|
|
26
|
+
export interface SamplingMetrics {
|
|
27
|
+
/** 家族覆盖数(sourceFamily 去重) */
|
|
28
|
+
familyCoverage: number;
|
|
29
|
+
/** 最大家族占比(0-1;越低越公平) */
|
|
30
|
+
maxFamilyShare: number;
|
|
31
|
+
/** 时间序保持度:spearman 相关(0-1;1=完全保持) */
|
|
32
|
+
timeOrderPreservation: number;
|
|
33
|
+
/** 小源(len<quota)是否全部入选 */
|
|
34
|
+
smallSourceIncluded: boolean;
|
|
35
|
+
/** 样本量 */
|
|
36
|
+
sampleSize: number;
|
|
37
|
+
}
|
|
38
|
+
/** 计算抽样指标 */
|
|
39
|
+
export declare function computeSamplingMetrics(original: readonly ObservationLike[], sampled: readonly ObservationLike[]): SamplingMetrics;
|
|
40
|
+
export interface VariantResult {
|
|
41
|
+
variant: SamplingVariant;
|
|
42
|
+
metrics: SamplingMetrics;
|
|
43
|
+
runIds: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface CapabilityDeltaReport {
|
|
46
|
+
experimentId: string;
|
|
47
|
+
hypothesisId?: string;
|
|
48
|
+
baselineVariant: SamplingVariant;
|
|
49
|
+
variants: VariantResult[];
|
|
50
|
+
/** 相对 baseline 的 delta(每指标:variant - baseline) */
|
|
51
|
+
deltas: Array<{
|
|
52
|
+
variant: SamplingVariant;
|
|
53
|
+
familyCoverageDelta: number;
|
|
54
|
+
maxFamilyShareDelta: number;
|
|
55
|
+
timeOrderPreservationDelta: number;
|
|
56
|
+
smallSourceIncludedDelta: number;
|
|
57
|
+
}>;
|
|
58
|
+
/** 泛化:在未见任务(held-out observations)上的表现 */
|
|
59
|
+
generalization?: {
|
|
60
|
+
heldOutSize: number;
|
|
61
|
+
baselineMetrics: SamplingMetrics;
|
|
62
|
+
bestVariant: SamplingVariant;
|
|
63
|
+
bestMetrics: SamplingMetrics;
|
|
64
|
+
};
|
|
65
|
+
/** 结论(允许 inconclusive) */
|
|
66
|
+
conclusion: 'variant_a_wins' | 'variant_b_wins' | 'baseline_wins' | 'inconclusive';
|
|
67
|
+
reason: string;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 运行 dogfood 实验:三组对照 + 消融,产出 CapabilityDelta 报告。
|
|
72
|
+
* observations 从历史重放;splitTrainTest 按时间切分(前 70% train,后 30% held-out)。
|
|
73
|
+
*/
|
|
74
|
+
export declare function runInsightSamplingExperiment(experimentId: string, observations: readonly ObservationLike[], options?: {
|
|
75
|
+
hypothesisId?: string;
|
|
76
|
+
limit?: number;
|
|
77
|
+
/** 每变体重复次数(取指标均值,降噪) */
|
|
78
|
+
repeats?: number;
|
|
79
|
+
}): CapabilityDeltaReport;
|
|
80
|
+
/** 把 CapabilityDelta 报告转成 ExperimentRun 列表(供 ExperimentRunStore.checkPromotionGate 使用) */
|
|
81
|
+
export declare function reportToExperimentRuns(report: CapabilityDeltaReport): ExperimentRun[];
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* InsightSamplingExperiment — insight 抽样策略 dogfood 实验(机制涌现 P3,定案 D4/R4)
|
|
4
|
+
*
|
|
5
|
+
* 首域 = insight 抽样策略(issue-6 P4 sourceQuota 有现成 before/after + 历史 observations 可重放)。
|
|
6
|
+
* 三组对照:现有 quota 抽样 / 变体 A / 变体 B + 消融(移除配额/移除时间序保持)+ 未见任务泛化。
|
|
7
|
+
* 产出 CapabilityDelta 报告;允许 inconclusive(不逼结论)。
|
|
8
|
+
* 硬前置 A1:MIO EXPERIMENT 标志路由 experiment-memory.jsonl(由 ExperimentRunStore 提供)。
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.sampleVariant = sampleVariant;
|
|
12
|
+
exports.computeSamplingMetrics = computeSamplingMetrics;
|
|
13
|
+
exports.runInsightSamplingExperiment = runInsightSamplingExperiment;
|
|
14
|
+
exports.reportToExperimentRuns = reportToExperimentRuns;
|
|
15
|
+
/** RSS feedUrl 折叠(与 observer sourceQuota.sourceFamily 同语义) */
|
|
16
|
+
function sourceFamily(source) {
|
|
17
|
+
return /^https?:\/\//.test(source) ? 'rss' : source;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* baseline: 现有 quota 抽样(issue-6 P4 sampleBySourceQuota)。
|
|
21
|
+
* variant_a: 更高 minPerSource(保底提升,家族优先更强)。
|
|
22
|
+
* variant_b: 只用 Phase A 保底,不做 Phase B 余量补齐(纯家族公平)。
|
|
23
|
+
* ablation_no_quota: 移除配额 → 退化为 slice(0, limit)(issue-6 修复前)。
|
|
24
|
+
* ablation_no_time_order: 移除时间序保持 → 输出按 weight 降序。
|
|
25
|
+
*/
|
|
26
|
+
function sampleVariant(observations, variant, limit = 30) {
|
|
27
|
+
const obs = [...observations];
|
|
28
|
+
switch (variant) {
|
|
29
|
+
case 'baseline':
|
|
30
|
+
return sampleByQuota(obs, limit, 5);
|
|
31
|
+
case 'variant_a':
|
|
32
|
+
return sampleByQuota(obs, limit, 8);
|
|
33
|
+
case 'variant_b': {
|
|
34
|
+
// Phase A only: 每家族取 min(quota, len),quota = floor(limit/家族数) 或 5
|
|
35
|
+
const byFamily = new Map();
|
|
36
|
+
for (const o of obs) {
|
|
37
|
+
const f = sourceFamily(o.source);
|
|
38
|
+
const g = byFamily.get(f) ?? [];
|
|
39
|
+
g.push(o);
|
|
40
|
+
byFamily.set(f, g);
|
|
41
|
+
}
|
|
42
|
+
const families = [...byFamily.keys()];
|
|
43
|
+
let quota = 5;
|
|
44
|
+
if (families.length * quota > limit)
|
|
45
|
+
quota = Math.max(1, Math.floor(limit / families.length));
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const f of families)
|
|
48
|
+
out.push(...byFamily.get(f).slice(0, quota));
|
|
49
|
+
// 保持时间序
|
|
50
|
+
out.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
51
|
+
return out.slice(0, limit);
|
|
52
|
+
}
|
|
53
|
+
case 'ablation_no_quota':
|
|
54
|
+
return obs.slice(0, limit);
|
|
55
|
+
case 'ablation_no_time_order': {
|
|
56
|
+
const sampled = sampleByQuota(obs, limit, 5);
|
|
57
|
+
return sampled.sort((a, b) => sourceFamily(a.source).localeCompare(sourceFamily(b.source)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 本地 quota 抽样(与 observer sourceQuota.sampleBySourceQuota 同语义的精简版)。
|
|
63
|
+
* Phase A 每家族保底 + Phase B 余量最大余数法 + 时间序保持。
|
|
64
|
+
*/
|
|
65
|
+
function sampleByQuota(observations, limit, minPerSource) {
|
|
66
|
+
if (limit <= 0)
|
|
67
|
+
return [];
|
|
68
|
+
if (observations.length <= limit)
|
|
69
|
+
return observations.slice();
|
|
70
|
+
const groups = new Map();
|
|
71
|
+
observations.forEach((obs, idx) => {
|
|
72
|
+
const family = sourceFamily(obs.source);
|
|
73
|
+
let g = groups.get(family);
|
|
74
|
+
if (!g) {
|
|
75
|
+
g = [];
|
|
76
|
+
groups.set(family, g);
|
|
77
|
+
}
|
|
78
|
+
g.push({ idx, obs });
|
|
79
|
+
});
|
|
80
|
+
const families = [...groups.keys()];
|
|
81
|
+
let quota = minPerSource;
|
|
82
|
+
if (families.length * quota > limit)
|
|
83
|
+
quota = Math.max(1, Math.floor(limit / families.length));
|
|
84
|
+
const picked = new Set();
|
|
85
|
+
const taken = new Map();
|
|
86
|
+
for (const family of families) {
|
|
87
|
+
const g = groups.get(family);
|
|
88
|
+
const take = Math.min(quota, g.length);
|
|
89
|
+
for (let i = 0; i < take; i++)
|
|
90
|
+
picked.add(g[i].idx);
|
|
91
|
+
taken.set(family, take);
|
|
92
|
+
}
|
|
93
|
+
const remaining = limit - picked.size;
|
|
94
|
+
if (remaining > 0) {
|
|
95
|
+
const shares = families.map((family) => {
|
|
96
|
+
const group = groups.get(family);
|
|
97
|
+
return { family, group, pool: group.length - taken.get(family), base: 0, frac: 0 };
|
|
98
|
+
});
|
|
99
|
+
const totalPool = shares.reduce((s, x) => s + x.pool, 0);
|
|
100
|
+
if (totalPool > 0) {
|
|
101
|
+
for (const s of shares) {
|
|
102
|
+
const exact = (remaining * s.pool) / totalPool;
|
|
103
|
+
s.base = Math.floor(exact);
|
|
104
|
+
s.frac = exact - s.base;
|
|
105
|
+
}
|
|
106
|
+
let leftover = remaining - shares.reduce((s, x) => s + x.base, 0);
|
|
107
|
+
const byFrac = [...shares].sort((a, b) => b.frac - a.frac);
|
|
108
|
+
for (const s of byFrac) {
|
|
109
|
+
if (leftover <= 0)
|
|
110
|
+
break;
|
|
111
|
+
if (s.pool > s.base) {
|
|
112
|
+
s.base++;
|
|
113
|
+
leftover--;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const s of shares) {
|
|
117
|
+
const start = taken.get(s.family);
|
|
118
|
+
for (let i = start; i < start + s.base; i++)
|
|
119
|
+
picked.add(s.group[i].idx);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return observations.filter((_, i) => picked.has(i));
|
|
124
|
+
}
|
|
125
|
+
/** 计算抽样指标 */
|
|
126
|
+
function computeSamplingMetrics(original, sampled) {
|
|
127
|
+
const families = new Set(original.map((o) => sourceFamily(o.source)));
|
|
128
|
+
const sampledFamilies = new Set(sampled.map((o) => sourceFamily(o.source)));
|
|
129
|
+
const byFamily = new Map();
|
|
130
|
+
for (const s of sampled) {
|
|
131
|
+
const f = sourceFamily(s.source);
|
|
132
|
+
byFamily.set(f, (byFamily.get(f) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
const maxCount = Math.max(0, ...byFamily.values());
|
|
135
|
+
const maxFamilyShare = sampled.length > 0 ? maxCount / sampled.length : 0;
|
|
136
|
+
// 时间序保持度:采样序列在原序列中的排名相关(简化为顺序单调比)
|
|
137
|
+
const idxMap = new Map();
|
|
138
|
+
original.forEach((o, i) => idxMap.set(o.id, i));
|
|
139
|
+
const ranks = sampled.map((s) => idxMap.get(s.id) ?? -1);
|
|
140
|
+
let concordant = 0;
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (let i = 0; i < ranks.length; i++) {
|
|
143
|
+
for (let j = i + 1; j < ranks.length; j++) {
|
|
144
|
+
if (ranks[i] < 0 || ranks[j] < 0)
|
|
145
|
+
continue;
|
|
146
|
+
total++;
|
|
147
|
+
if (ranks[i] < ranks[j])
|
|
148
|
+
concordant++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const timeOrderPreservation = total > 0 ? concordant / total : 1;
|
|
152
|
+
// 小源:len < 5 的家族是否全部入选
|
|
153
|
+
const origByFamily = new Map();
|
|
154
|
+
for (const o of original) {
|
|
155
|
+
const f = sourceFamily(o.source);
|
|
156
|
+
const g = origByFamily.get(f) ?? [];
|
|
157
|
+
g.push(o);
|
|
158
|
+
origByFamily.set(f, g);
|
|
159
|
+
}
|
|
160
|
+
const smallSourceIncluded = [...origByFamily.entries()].every(([f, g]) => {
|
|
161
|
+
if (g.length >= 5)
|
|
162
|
+
return true;
|
|
163
|
+
const inSample = sampled.filter((s) => sourceFamily(s.source) === f);
|
|
164
|
+
return inSample.length >= g.length;
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
familyCoverage: sampledFamilies.size,
|
|
168
|
+
maxFamilyShare,
|
|
169
|
+
timeOrderPreservation,
|
|
170
|
+
smallSourceIncluded,
|
|
171
|
+
sampleSize: sampled.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 运行 dogfood 实验:三组对照 + 消融,产出 CapabilityDelta 报告。
|
|
176
|
+
* observations 从历史重放;splitTrainTest 按时间切分(前 70% train,后 30% held-out)。
|
|
177
|
+
*/
|
|
178
|
+
function runInsightSamplingExperiment(experimentId, observations, options) {
|
|
179
|
+
const limit = options?.limit ?? 30;
|
|
180
|
+
const repeats = options?.repeats ?? 1;
|
|
181
|
+
const sorted = [...observations].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
182
|
+
const split = Math.max(1, Math.floor(sorted.length * 0.7));
|
|
183
|
+
const train = sorted.slice(0, split);
|
|
184
|
+
const heldOut = sorted.slice(split);
|
|
185
|
+
const variants = ['baseline', 'variant_a', 'variant_b', 'ablation_no_quota', 'ablation_no_time_order'];
|
|
186
|
+
const variantResults = [];
|
|
187
|
+
for (const v of variants) {
|
|
188
|
+
let metricsAcc = null;
|
|
189
|
+
const runIds = [];
|
|
190
|
+
for (let r = 0; r < repeats; r++) {
|
|
191
|
+
const sampled = sampleVariant(train, v, limit);
|
|
192
|
+
const m = computeSamplingMetrics(train, sampled);
|
|
193
|
+
metricsAcc = metricsAcc
|
|
194
|
+
? {
|
|
195
|
+
familyCoverage: metricsAcc.familyCoverage + m.familyCoverage,
|
|
196
|
+
maxFamilyShare: metricsAcc.maxFamilyShare + m.maxFamilyShare,
|
|
197
|
+
timeOrderPreservation: metricsAcc.timeOrderPreservation + m.timeOrderPreservation,
|
|
198
|
+
smallSourceIncluded: metricsAcc.smallSourceIncluded && m.smallSourceIncluded,
|
|
199
|
+
sampleSize: metricsAcc.sampleSize + m.sampleSize,
|
|
200
|
+
}
|
|
201
|
+
: m;
|
|
202
|
+
runIds.push(`${experimentId}:${v}:${r}`);
|
|
203
|
+
}
|
|
204
|
+
const avg = metricsAcc
|
|
205
|
+
? {
|
|
206
|
+
familyCoverage: Math.round(metricsAcc.familyCoverage / repeats),
|
|
207
|
+
maxFamilyShare: metricsAcc.maxFamilyShare / repeats,
|
|
208
|
+
timeOrderPreservation: metricsAcc.timeOrderPreservation / repeats,
|
|
209
|
+
smallSourceIncluded: metricsAcc.smallSourceIncluded,
|
|
210
|
+
sampleSize: Math.round(metricsAcc.sampleSize / repeats),
|
|
211
|
+
}
|
|
212
|
+
: { familyCoverage: 0, maxFamilyShare: 1, timeOrderPreservation: 0, smallSourceIncluded: false, sampleSize: 0 };
|
|
213
|
+
variantResults.push({ variant: v, metrics: avg, runIds });
|
|
214
|
+
}
|
|
215
|
+
const baseline = variantResults.find((r) => r.variant === 'baseline');
|
|
216
|
+
const deltas = variantResults
|
|
217
|
+
.filter((r) => r.variant !== 'baseline')
|
|
218
|
+
.map((r) => ({
|
|
219
|
+
variant: r.variant,
|
|
220
|
+
familyCoverageDelta: r.metrics.familyCoverage - baseline.metrics.familyCoverage,
|
|
221
|
+
maxFamilyShareDelta: r.metrics.maxFamilyShare - baseline.metrics.maxFamilyShare,
|
|
222
|
+
timeOrderPreservationDelta: r.metrics.timeOrderPreservation - baseline.metrics.timeOrderPreservation,
|
|
223
|
+
smallSourceIncludedDelta: (r.metrics.smallSourceIncluded ? 1 : 0) - (baseline.metrics.smallSourceIncluded ? 1 : 0),
|
|
224
|
+
}));
|
|
225
|
+
// 泛化:held-out 上 baseline vs best variant(按 familyCoverage + smallSourceIncluded 排序)
|
|
226
|
+
let generalization;
|
|
227
|
+
if (heldOut.length > 0) {
|
|
228
|
+
const baseSampled = sampleVariant(heldOut, 'baseline', limit);
|
|
229
|
+
const baseMetrics = computeSamplingMetrics(heldOut, baseSampled);
|
|
230
|
+
const scored = ['variant_a', 'variant_b'].map((v) => {
|
|
231
|
+
const s = sampleVariant(heldOut, v, limit);
|
|
232
|
+
return { v, m: computeSamplingMetrics(heldOut, s) };
|
|
233
|
+
});
|
|
234
|
+
const best = scored.sort((a, b) => {
|
|
235
|
+
const sc = (m) => m.familyCoverage * 10 + (m.smallSourceIncluded ? 5 : 0) - m.maxFamilyShare;
|
|
236
|
+
return sc(b.m) - sc(a.m);
|
|
237
|
+
})[0];
|
|
238
|
+
generalization = {
|
|
239
|
+
heldOutSize: heldOut.length,
|
|
240
|
+
baselineMetrics: baseMetrics,
|
|
241
|
+
bestVariant: best?.v ?? 'baseline',
|
|
242
|
+
bestMetrics: best?.m ?? baseMetrics,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// 结论:variant_a / variant_b 优于 baseline → 对应 wins;否则 baseline_wins;样本太少 → inconclusive
|
|
246
|
+
const aDelta = deltas.find((d) => d.variant === 'variant_a');
|
|
247
|
+
const bDelta = deltas.find((d) => d.variant === 'variant_b');
|
|
248
|
+
const aBetter = aDelta && (aDelta.familyCoverageDelta > 0 || aDelta.smallSourceIncludedDelta > 0);
|
|
249
|
+
const bBetter = bDelta && (bDelta.familyCoverageDelta > 0 || bDelta.smallSourceIncludedDelta > 0);
|
|
250
|
+
let conclusion;
|
|
251
|
+
let reason;
|
|
252
|
+
if (train.length < 10) {
|
|
253
|
+
conclusion = 'inconclusive';
|
|
254
|
+
reason = 'too_few_observations';
|
|
255
|
+
}
|
|
256
|
+
else if (aBetter && bBetter) {
|
|
257
|
+
// 双优 → inconclusive(需人工判断)
|
|
258
|
+
conclusion = 'inconclusive';
|
|
259
|
+
reason = 'both_variants_improve';
|
|
260
|
+
}
|
|
261
|
+
else if (aBetter) {
|
|
262
|
+
conclusion = 'variant_a_wins';
|
|
263
|
+
reason = 'higher_family_coverage_or_small_source';
|
|
264
|
+
}
|
|
265
|
+
else if (bBetter) {
|
|
266
|
+
conclusion = 'variant_b_wins';
|
|
267
|
+
reason = 'higher_family_coverage_or_small_source';
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
conclusion = 'baseline_wins';
|
|
271
|
+
reason = 'no_improvement_over_baseline';
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
experimentId,
|
|
275
|
+
hypothesisId: options?.hypothesisId,
|
|
276
|
+
baselineVariant: 'baseline',
|
|
277
|
+
variants: variantResults,
|
|
278
|
+
deltas,
|
|
279
|
+
generalization,
|
|
280
|
+
conclusion,
|
|
281
|
+
reason,
|
|
282
|
+
createdAt: Date.now(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** 把 CapabilityDelta 报告转成 ExperimentRun 列表(供 ExperimentRunStore.checkPromotionGate 使用) */
|
|
286
|
+
function reportToExperimentRuns(report) {
|
|
287
|
+
return report.variants.map((v) => ({
|
|
288
|
+
id: `${report.experimentId}:${v.variant}`,
|
|
289
|
+
hypothesisId: report.hypothesisId,
|
|
290
|
+
candidateId: v.variant,
|
|
291
|
+
baselineId: report.baselineVariant,
|
|
292
|
+
assignment: (v.variant === 'baseline'
|
|
293
|
+
? 'baseline'
|
|
294
|
+
: v.variant.startsWith('ablation')
|
|
295
|
+
? 'ablation'
|
|
296
|
+
: 'candidate'),
|
|
297
|
+
outcome: report.conclusion === 'inconclusive' ? 'inconclusive' : v.variant === report.conclusion.replace('_wins', '') ? 'success' : 'failure',
|
|
298
|
+
metrics: {
|
|
299
|
+
familyCoverage: v.metrics.familyCoverage,
|
|
300
|
+
maxFamilyShare: v.metrics.maxFamilyShare,
|
|
301
|
+
timeOrderPreservation: v.metrics.timeOrderPreservation,
|
|
302
|
+
smallSourceIncluded: v.metrics.smallSourceIncluded,
|
|
303
|
+
sampleSize: v.metrics.sampleSize,
|
|
304
|
+
},
|
|
305
|
+
evidenceIds: v.runIds,
|
|
306
|
+
createdAt: report.createdAt,
|
|
307
|
+
}));
|
|
308
|
+
}
|