@akemi-mio/creativity 0.1.0 → 0.2.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.
@@ -0,0 +1,456 @@
1
+ "use strict";
2
+ /**
3
+ * FeedbackLearning — 正向反馈闭环离线学习核心(设计 v1.0,P1)
4
+ *
5
+ * 纯计算,不碰 DB、不写 Memory、不改假设状态、不进晋级门输入。
6
+ * 输入规范化证据事件 + 假设 provenance,输出:
7
+ * - ConceptPairWeight:概念对 × 三维度(选择/质量/落地)加权证据
8
+ * - FeedbackSnapshot:不可变 + 版本号(sha256 前 12 位)
9
+ * - 离线回放:原始 vs 反馈策略的逐对调整与证据追溯
10
+ *
11
+ * 评审决议(spec §十三)落地:
12
+ * §13.2 带权 Beta 变体:衰减后计数为小数,不取整(取整顺序相关会破坏 reproducibility)。
13
+ * §13.3 三维度归一分母 = 该维度 S_d + F_d,不跨维度、不用假设数;无事件维度取先验均值。
14
+ * §13.1 batch 身份:experimentRunId 优先,否则 hypothesisId(或 implemented 的 commitSha)。
15
+ * §13.4 快照只带结构化字段,禁止 raw 事件正文。
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DEFAULT_FEEDBACK_LEARNING_CONFIG = exports.POSITIVE_DIMENSIONS = void 0;
19
+ exports.evidenceWeight = evidenceWeight;
20
+ exports.isNegativeEvent = isNegativeEvent;
21
+ exports.timeDecay = timeDecay;
22
+ exports.conceptIdOf = conceptIdOf;
23
+ exports.pairKeyOf = pairKeyOf;
24
+ exports.canonicalJson = canonicalJson;
25
+ exports.hashPrefix = hashPrefix;
26
+ exports.aggregateConceptPairWeights = aggregateConceptPairWeights;
27
+ exports.buildFeedbackSnapshot = buildFeedbackSnapshot;
28
+ exports.replayFeedbackAdjustment = replayFeedbackAdjustment;
29
+ exports.clipPromptText = clipPromptText;
30
+ exports.buildFeedbackPromptBlock = buildFeedbackPromptBlock;
31
+ exports.buildMixerInputFromSnapshot = buildMixerInputFromSnapshot;
32
+ const crypto_1 = require("crypto");
33
+ const FeedbackEvents_1 = require("./FeedbackEvents");
34
+ const types_1 = require("./types");
35
+ exports.POSITIVE_DIMENSIONS = ['selection', 'quality', 'landing'];
36
+ exports.DEFAULT_FEEDBACK_LEARNING_CONFIG = {
37
+ halfLifeDays: 30,
38
+ alpha0: 1,
39
+ beta0: 1,
40
+ minSamples: 30,
41
+ weightCaps: {
42
+ validated: 0.5,
43
+ adopted: 0.2,
44
+ implemented: 0.3,
45
+ rejected: 1.0,
46
+ regression_failed: 1.0,
47
+ },
48
+ attributionMinCoverage: 0.3,
49
+ explorationBudget: 0.2,
50
+ lambdaClampPct: 0.1,
51
+ };
52
+ /** 单条事件对某维度的贡献权重(未衰减)。负向事件归 negative 维度。 */
53
+ function evidenceWeight(eventType, caps) {
54
+ switch (eventType) {
55
+ case 'validated':
56
+ return caps.validated;
57
+ case 'adopted':
58
+ return caps.adopted;
59
+ case 'implemented':
60
+ return caps.implemented;
61
+ case 'rejected':
62
+ return caps.rejected;
63
+ case 'regression_failed':
64
+ return caps.regression_failed;
65
+ default:
66
+ return 1;
67
+ }
68
+ }
69
+ function isNegativeEvent(eventType) {
70
+ return eventType === 'rejected' || eventType === 'regression_failed';
71
+ }
72
+ /** 时间衰减 w(t) = 2^(-Δt/H),Δt 以天为单位;未来事件不放大。 */
73
+ function timeDecay(occurredAt, now, halfLifeDays) {
74
+ if (halfLifeDays <= 0)
75
+ return 1;
76
+ const dtDays = Math.max(0, (now - occurredAt) / 86_400_000);
77
+ return Math.pow(2, -dtDays / halfLifeDays);
78
+ }
79
+ /** 概念身份:取 origin(稳定字段),缺失时回退 name 并标记 hypothesis_only。 */
80
+ function conceptIdOf(source) {
81
+ return (0, types_1.sourceConceptId)(source);
82
+ }
83
+ function pairKeyOf(a, b) {
84
+ return (0, types_1.conceptPairKey)(a, b);
85
+ }
86
+ /** 稳定序列化:对象键排序、数组保序,保证跨平台 hash 一致。 */
87
+ function canonicalJson(value) {
88
+ if (value === null || typeof value !== 'object')
89
+ return JSON.stringify(value);
90
+ if (Array.isArray(value))
91
+ return `[${value.map(canonicalJson).join(',')}]`;
92
+ const obj = value;
93
+ const keys = Object.keys(obj).sort();
94
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}`;
95
+ }
96
+ function hashPrefix(input, length = 12) {
97
+ return (0, crypto_1.createHash)('sha256').update(input, 'utf8').digest('hex').slice(0, length);
98
+ }
99
+ function emptyEstimate(alpha0, beta0) {
100
+ return {
101
+ positiveCount: 0,
102
+ negativeCount: 0,
103
+ evidenceCount: 0,
104
+ posteriorMean: alpha0 / (alpha0 + beta0),
105
+ confidence: 0,
106
+ lastEvidenceAt: 0,
107
+ };
108
+ }
109
+ function batchKeyOf(event) {
110
+ return event.experimentRunId || (event.commitSha ? `commit:${event.commitSha}` : event.hypothesisId);
111
+ }
112
+ /** 稳定排序(locale 不敏感、字段序固定)——reproducibility 前提。 */
113
+ function sortEvents(events) {
114
+ return [...events].sort((a, b) => {
115
+ if (a.hypothesisId !== b.hypothesisId)
116
+ return a.hypothesisId < b.hypothesisId ? -1 : 1;
117
+ if (a.eventType !== b.eventType)
118
+ return a.eventType < b.eventType ? -1 : 1;
119
+ if (a.sourceId !== b.sourceId)
120
+ return a.sourceId < b.sourceId ? -1 : 1;
121
+ return a.occurredAt - b.occurredAt;
122
+ });
123
+ }
124
+ function isAttributable(sources) {
125
+ if (!sources || sources.length < 2)
126
+ return false;
127
+ return sources.filter((s) => s.origin && s.origin.trim()).length >= 2;
128
+ }
129
+ /**
130
+ * 聚合概念对权重。
131
+ *
132
+ * @param events 规范化后的证据事件(可乱序;内部稳定排序)
133
+ * @param hypotheses hypothesisId → provenance(用于概念对归因)
134
+ * @param config 学习配置
135
+ * @param now 评估时刻,epoch ms
136
+ */
137
+ function aggregateConceptPairWeights(events, hypotheses, config = exports.DEFAULT_FEEDBACK_LEARNING_CONFIG, now = Date.now()) {
138
+ const sorted = sortEvents(events);
139
+ const pairMap = new Map();
140
+ // (pairKey, dimension, batchKey) → 衰减后最大权重;同源事件不伪造样本量
141
+ const slotMap = new Map();
142
+ const trace = [];
143
+ let positiveAttributed = 0;
144
+ let positiveTotal = 0;
145
+ let appliedCount = 0;
146
+ let dedupedEventCount = 0;
147
+ let naiveWeightApplied = 0;
148
+ let duplicateWeightApplied = 0;
149
+ const getPair = (pairKey, attribution) => {
150
+ let pair = pairMap.get(pairKey);
151
+ if (!pair) {
152
+ const [conceptAId, conceptBId] = pairKey.split('|');
153
+ pair = {
154
+ conceptAId,
155
+ conceptBId,
156
+ pairKey,
157
+ attribution,
158
+ selection: emptyEstimate(config.alpha0, config.beta0),
159
+ quality: emptyEstimate(config.alpha0, config.beta0),
160
+ landing: emptyEstimate(config.alpha0, config.beta0),
161
+ negative: emptyEstimate(config.alpha0, config.beta0),
162
+ lastEvidenceAt: 0,
163
+ };
164
+ pairMap.set(pairKey, pair);
165
+ }
166
+ return pair;
167
+ };
168
+ for (const event of sorted) {
169
+ const dimension = (0, FeedbackEvents_1.feedbackEvidenceDimension)(event.eventType);
170
+ const weight = evidenceWeight(event.eventType, config.weightCaps);
171
+ const batchKey = batchKeyOf(event);
172
+ const provenance = hypotheses[event.hypothesisId];
173
+ const sources = provenance?.sources;
174
+ const attributable = isAttributable(sources);
175
+ if (!isNegativeEvent(event.eventType)) {
176
+ positiveTotal++;
177
+ if (attributable)
178
+ positiveAttributed++;
179
+ }
180
+ if (!attributable) {
181
+ // 无 provenance:只记假设级,不归因到对(R5)
182
+ const pairKey = `hypothesis:${event.hypothesisId}`;
183
+ const pair = getPair(pairKey, 'hypothesis_only');
184
+ const est = pair[dimension];
185
+ est.evidenceCount += 1;
186
+ const decayed = weight * timeDecay(event.occurredAt, now, config.halfLifeDays);
187
+ if (isNegativeEvent(event.eventType))
188
+ est.negativeCount += decayed;
189
+ else
190
+ est.positiveCount += decayed;
191
+ if (event.occurredAt > pair.lastEvidenceAt)
192
+ pair.lastEvidenceAt = event.occurredAt;
193
+ appliedCount++;
194
+ naiveWeightApplied += decayed;
195
+ trace.push({
196
+ hypothesisId: event.hypothesisId,
197
+ eventType: event.eventType,
198
+ sourceId: event.sourceId,
199
+ pairKey,
200
+ dimension,
201
+ weight: decayed,
202
+ batchKey,
203
+ duplicate: false,
204
+ });
205
+ continue;
206
+ }
207
+ // 有 provenance:来源两两构成贡献概念对
208
+ const ids = sources.map(conceptIdOf);
209
+ for (let i = 0; i < ids.length; i++) {
210
+ for (let j = i + 1; j < ids.length; j++) {
211
+ const key = pairKeyOf(ids[i], ids[j]);
212
+ const slotKey = `${key}\u0000${dimension}\u0000${batchKey}`;
213
+ const decayed = weight * timeDecay(event.occurredAt, now, config.halfLifeDays);
214
+ const existing = slotMap.get(slotKey);
215
+ const duplicate = !!existing && existing.weight >= decayed;
216
+ if (duplicate) {
217
+ dedupedEventCount++;
218
+ duplicateWeightApplied += 0; // 去重后不重复加权
219
+ }
220
+ else {
221
+ slotMap.set(slotKey, { weight: decayed, batchKey, dimension, lastEvidenceAt: event.occurredAt });
222
+ appliedCount++;
223
+ naiveWeightApplied += decayed;
224
+ }
225
+ trace.push({
226
+ hypothesisId: event.hypothesisId,
227
+ eventType: event.eventType,
228
+ sourceId: event.sourceId,
229
+ pairKey: key,
230
+ dimension,
231
+ weight: duplicate ? 0 : decayed,
232
+ batchKey,
233
+ duplicate,
234
+ });
235
+ }
236
+ }
237
+ }
238
+ // slot 汇总到 pair 的维度估计(跨 batch 求和,已按 batch 去重)
239
+ for (const [slotKey, slot] of slotMap) {
240
+ const [pairKey, dimension] = slotKey.split('\u0000');
241
+ const pair = getPair(pairKey, 'pair');
242
+ const est = pair[dimension];
243
+ if (isNegativeEventDimension(dimension))
244
+ est.negativeCount += slot.weight;
245
+ else
246
+ est.positiveCount += slot.weight;
247
+ if (slot.lastEvidenceAt > est.lastEvidenceAt)
248
+ est.lastEvidenceAt = slot.lastEvidenceAt;
249
+ if (slot.lastEvidenceAt > pair.lastEvidenceAt)
250
+ pair.lastEvidenceAt = slot.lastEvidenceAt;
251
+ }
252
+ // 回填每维度 evidenceCount(原始事件数)与后验/置信度
253
+ const rawCounts = countRawEvidence(sorted, hypotheses);
254
+ for (const pair of pairMap.values()) {
255
+ applyPosterior(pair, config);
256
+ const rc = rawCounts.get(pair.pairKey);
257
+ if (rc) {
258
+ pair.selection.evidenceCount = rc.selection;
259
+ pair.quality.evidenceCount = rc.quality;
260
+ pair.landing.evidenceCount = rc.landing;
261
+ pair.negative.evidenceCount = rc.negative;
262
+ }
263
+ }
264
+ const pairs = [...pairMap.values()].sort((a, b) => (a.pairKey < b.pairKey ? -1 : a.pairKey > b.pairKey ? 1 : 0));
265
+ return {
266
+ pairs,
267
+ attributionCoverage: positiveTotal === 0 ? 1 : positiveAttributed / positiveTotal,
268
+ duplicateApplicationRate: naiveWeightApplied === 0 ? 0 : duplicateWeightApplied / naiveWeightApplied,
269
+ appliedCount,
270
+ dedupedEventCount,
271
+ trace,
272
+ };
273
+ }
274
+ function isNegativeEventDimension(d) {
275
+ return d === 'negative';
276
+ }
277
+ function applyPosterior(pair, config) {
278
+ for (const d of [...exports.POSITIVE_DIMENSIONS, 'negative']) {
279
+ const est = pair[d];
280
+ const s = est.positiveCount;
281
+ const f = est.negativeCount;
282
+ est.posteriorMean = (config.alpha0 + s) / (config.alpha0 + config.beta0 + s + f);
283
+ est.confidence = (s + f) / (s + f + config.alpha0 + config.beta0);
284
+ }
285
+ }
286
+ function countRawEvidence(events, hypotheses) {
287
+ const counts = new Map();
288
+ const bump = (pairKey, d) => {
289
+ let c = counts.get(pairKey);
290
+ if (!c) {
291
+ c = { selection: 0, quality: 0, landing: 0, negative: 0 };
292
+ counts.set(pairKey, c);
293
+ }
294
+ c[d]++;
295
+ };
296
+ for (const event of events) {
297
+ const d = (0, FeedbackEvents_1.feedbackEvidenceDimension)(event.eventType);
298
+ const sources = hypotheses[event.hypothesisId]?.sources;
299
+ if (!isAttributable(sources)) {
300
+ bump(`hypothesis:${event.hypothesisId}`, d);
301
+ continue;
302
+ }
303
+ const ids = sources.map(conceptIdOf);
304
+ for (let i = 0; i < ids.length; i++) {
305
+ for (let j = i + 1; j < ids.length; j++) {
306
+ bump(pairKeyOf(ids[i], ids[j]), d);
307
+ }
308
+ }
309
+ }
310
+ return counts;
311
+ }
312
+ /**
313
+ * 构建不可变快照。同输入 + 同配置 + 同版本 → 同 hash(100% 可复现)。
314
+ */
315
+ function buildFeedbackSnapshot(events, hypotheses, config = exports.DEFAULT_FEEDBACK_LEARNING_CONFIG, now = Date.now()) {
316
+ const aggregate = aggregateConceptPairWeights(events, hypotheses, config, now);
317
+ const configHash = hashPrefix(canonicalJson(config), 12);
318
+ const window = canonicalJson({
319
+ schemaVersion: FeedbackEvents_1.FEEDBACK_EVENT_SCHEMA_VERSION,
320
+ now,
321
+ events: sortEvents(events)
322
+ .map((e) => ({
323
+ hypothesisId: e.hypothesisId,
324
+ eventType: e.eventType,
325
+ sourceId: e.sourceId,
326
+ sourceType: e.sourceType,
327
+ commitSha: e.commitSha ?? null,
328
+ experimentRunId: e.experimentRunId ?? null,
329
+ occurredAt: e.occurredAt,
330
+ idempotencyKey: e.idempotencyKey,
331
+ })),
332
+ config,
333
+ });
334
+ const version = hashPrefix(window, 12);
335
+ const pairs = aggregate.pairs.map((p) => ({ ...p, feedbackSnapshotVersion: version }));
336
+ return {
337
+ version,
338
+ configHash,
339
+ generatedAt: now,
340
+ eventCount: events.length,
341
+ attributionCoverage: aggregate.attributionCoverage,
342
+ duplicateApplicationRate: aggregate.duplicateApplicationRate,
343
+ dedupedEventCount: aggregate.dedupedEventCount,
344
+ pairs,
345
+ };
346
+ }
347
+ /**
348
+ * 离线回放:给定概念对权重,计算有界反馈调整(λ clamp)。
349
+ * 不读 DB、不写任何状态;供 P2 接入 ConceptMixer 前做离线对比。
350
+ */
351
+ function replayFeedbackAdjustment(weight, config = exports.DEFAULT_FEEDBACK_LEARNING_CONFIG) {
352
+ let pos = 0;
353
+ let conf = 0;
354
+ let evidence = 0;
355
+ for (const d of exports.POSITIVE_DIMENSIONS) {
356
+ const est = weight[d];
357
+ pos += est.posteriorMean * est.confidence;
358
+ conf += est.confidence;
359
+ evidence += est.evidenceCount;
360
+ }
361
+ const posMean = conf > 0 ? pos / conf : 0.5;
362
+ const negMean = weight.negative.posteriorMean;
363
+ // 正向证据把分数拉向 +1,负向证据拉向 -1;无正向证据时取先验中性 0.5
364
+ const adjustment = Math.max(-1, Math.min(1, (posMean - 0.5) * 2 - negMean));
365
+ const clamp = Math.max(0, config.lambdaClampPct);
366
+ return {
367
+ pairKey: weight.pairKey,
368
+ adjustment,
369
+ evidenceCount: evidence,
370
+ posteriorMean: posMean,
371
+ confidence: conf,
372
+ clampedAdjustment: adjustment * clamp,
373
+ };
374
+ }
375
+ const PROMPT_TEXT_LIMIT = 120;
376
+ /** 确定性裁剪:字符串上限 120 字符(§13.4)。 */
377
+ function clipPromptText(s, limit = PROMPT_TEXT_LIMIT) {
378
+ const clean = String(s ?? '').replace(/\s+/g, ' ').trim();
379
+ return clean.length > limit ? `${clean.slice(0, limit)}…` : clean;
380
+ }
381
+ function band(value) {
382
+ if (value < 0.5)
383
+ return 'low';
384
+ if (value < 0.8)
385
+ return 'medium';
386
+ return 'high';
387
+ }
388
+ function confidenceBand(value) {
389
+ if (value < 0.3)
390
+ return 'low';
391
+ if (value < 0.7)
392
+ return 'medium';
393
+ return 'high';
394
+ }
395
+ function scopeOf(evidenceCount) {
396
+ return evidenceCount >= 3 ? '多批次独立验证' : '限定任务类别';
397
+ }
398
+ const LIMITS = '尚未验证跨场景泛化';
399
+ /**
400
+ * 从不可变快照构建结构化提示块。只带结构化字段,永不回灌 raw 事件正文
401
+ * 或 provenance.origin 自由文本(§13.4,红队注入面)。
402
+ */
403
+ function buildFeedbackPromptBlock(snapshot) {
404
+ const verified = [];
405
+ const failures = [];
406
+ for (const pair of snapshot.pairs) {
407
+ if (pair.attribution === 'hypothesis_only')
408
+ continue;
409
+ for (const d of exports.POSITIVE_DIMENSIONS) {
410
+ const est = pair[d];
411
+ if (est.evidenceCount <= 0)
412
+ continue;
413
+ verified.push({
414
+ pairKey: pair.pairKey,
415
+ conceptA: pair.conceptAId,
416
+ conceptB: pair.conceptBId,
417
+ dimension: d,
418
+ evidenceCount: est.evidenceCount,
419
+ posteriorBand: band(est.posteriorMean),
420
+ confidenceBand: confidenceBand(est.confidence),
421
+ scope: scopeOf(est.evidenceCount),
422
+ limits: LIMITS,
423
+ });
424
+ }
425
+ if (pair.negative.evidenceCount > 0) {
426
+ failures.push({
427
+ pairKey: pair.pairKey,
428
+ conceptA: pair.conceptAId,
429
+ conceptB: pair.conceptBId,
430
+ dimension: 'negative',
431
+ evidenceCount: pair.negative.evidenceCount,
432
+ scope: '特定实验条件',
433
+ limits: LIMITS,
434
+ });
435
+ }
436
+ }
437
+ const byPair = (a, b) => a.pairKey < b.pairKey ? -1 : a.pairKey > b.pairKey ? 1 : a.dimension < b.dimension ? -1 : a.dimension > b.dimension ? 1 : 0;
438
+ verified.sort(byPair);
439
+ failures.sort(byPair);
440
+ return { version: snapshot.version, verified, failures };
441
+ }
442
+ /**
443
+ * 快照 → ConceptMixer 有界消费输入(spec §六.2)。
444
+ * pairAdjustments 用快照 pairKey(conceptId 对齐);λ clamp 由 mixer 再兜底。
445
+ */
446
+ function buildMixerInputFromSnapshot(snapshot, config = exports.DEFAULT_FEEDBACK_LEARNING_CONFIG) {
447
+ const pairAdjustments = {};
448
+ for (const pair of snapshot.pairs) {
449
+ pairAdjustments[pair.pairKey] = replayFeedbackAdjustment(pair, config).clampedAdjustment;
450
+ }
451
+ return {
452
+ pairAdjustments,
453
+ explorationBudget: config.explorationBudget,
454
+ maxAdjustment: config.lambdaClampPct,
455
+ };
456
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * FeedbackRollout — 正向反馈闭环放量决策(设计 v1.0,P3 收尾)
3
+ *
4
+ * "离线回放 → 影子运行 → 小流量 → 正式启用,每阶段预固定基线、指标、回滚条件"(spec §八 P3)。
5
+ * 本模块把那张表落成可执行判定:
6
+ * - rollback:安全不变量破坏(Duplicate Application Rate > 0)→ 立即建议回滚 off。
7
+ * - advance:当前阶段的预固定门槛全部满足 → 建议进入下一阶段。
8
+ * - hold:证据不足 → 保持当前阶段。
9
+ *
10
+ * 纯函数:不读写文件、不查 DB。阈值进配置 + 解析校验(R6 纪律)。
11
+ * 反馈永不进入 checkPromotionGate/checkSubmitGate;放量判定只决定"生成端消费档位"。
12
+ */
13
+ import type { FeedbackSnapshot } from './FeedbackLearning';
14
+ import { type FeedbackMode } from './FeedbackRuntime';
15
+ export interface FeedbackRolloutThresholds {
16
+ /** 每阶段所需的最少运行观测数(shadow 或 canary) */
17
+ minStageRuns: number;
18
+ /** 升档所需的最小归因覆盖率(有 provenance 的正向事件占比) */
19
+ minAttributionCoverage: number;
20
+ /** canary → full 所允许的最大负向事件率(负向证据 / 全部证据) */
21
+ maxNegativeEventRate: number;
22
+ }
23
+ export declare const DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS: FeedbackRolloutThresholds;
24
+ /** 解析并校验放量阈值;失败返回 null(fail-closed,调用方回退默认)。 */
25
+ export declare function parseFeedbackRolloutThresholds(raw: unknown): FeedbackRolloutThresholds | null;
26
+ /** 从环境变量解析(缺省全默认)。 */
27
+ export declare function resolveFeedbackRolloutThresholds(env?: Record<string, string | undefined>): FeedbackRolloutThresholds;
28
+ /** 一次观测模式下的生成运行摘要(shadow/canary/full;off 不观测不记录)。 */
29
+ export interface FeedbackRuntimeRun {
30
+ at: number;
31
+ mode: Exclude<FeedbackMode, 'off'>;
32
+ snapshotVersion: string | null;
33
+ pairCount: number;
34
+ applied: boolean;
35
+ }
36
+ /** 阶梯:off → shadow → canary → full。 */
37
+ export declare const FEEDBACK_MODE_LADDER: readonly FeedbackMode[];
38
+ export declare function nextFeedbackMode(current: FeedbackMode): FeedbackMode | null;
39
+ export interface FeedbackRolloutMetrics {
40
+ snapshotVersion: string | null;
41
+ shadowRuns: number;
42
+ canaryRuns: number;
43
+ attributionCoverage: number;
44
+ duplicateApplicationRate: number;
45
+ negativeEventRate: number;
46
+ eventCount: number;
47
+ }
48
+ export type FeedbackRolloutDecision = 'rollback' | 'advance' | 'hold';
49
+ export interface FeedbackRolloutReport {
50
+ decision: FeedbackRolloutDecision;
51
+ from: FeedbackMode;
52
+ /** rollback → 'off';advance → 下一阶段;hold → null */
53
+ to: FeedbackMode | null;
54
+ reasons: string[];
55
+ metrics: FeedbackRolloutMetrics;
56
+ }
57
+ /** 快照内负向事件率:负向证据 / 全部证据(无证据 → 0)。 */
58
+ export declare function negativeEventRateOf(snapshot: FeedbackSnapshot | null): number;
59
+ export interface BuildFeedbackRolloutReportArgs {
60
+ snapshot: FeedbackSnapshot | null;
61
+ runs: readonly FeedbackRuntimeRun[];
62
+ currentMode: FeedbackMode;
63
+ thresholds?: FeedbackRolloutThresholds;
64
+ }
65
+ /**
66
+ * 放量判定。判定顺序:
67
+ * 1. 安全不变量破坏(重复加权)→ rollback(无条件,压过一切)。
68
+ * 2. off → shadow:观测零风险,无条件 advance。
69
+ * 3. shadow → canary / canary → full:预固定门槛全部满足才 advance,否则 hold。
70
+ * 4. full:已到顶,hold。
71
+ */
72
+ export declare function buildFeedbackRolloutReport(args: BuildFeedbackRolloutReportArgs): FeedbackRolloutReport;
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * FeedbackRollout — 正向反馈闭环放量决策(设计 v1.0,P3 收尾)
4
+ *
5
+ * "离线回放 → 影子运行 → 小流量 → 正式启用,每阶段预固定基线、指标、回滚条件"(spec §八 P3)。
6
+ * 本模块把那张表落成可执行判定:
7
+ * - rollback:安全不变量破坏(Duplicate Application Rate > 0)→ 立即建议回滚 off。
8
+ * - advance:当前阶段的预固定门槛全部满足 → 建议进入下一阶段。
9
+ * - hold:证据不足 → 保持当前阶段。
10
+ *
11
+ * 纯函数:不读写文件、不查 DB。阈值进配置 + 解析校验(R6 纪律)。
12
+ * 反馈永不进入 checkPromotionGate/checkSubmitGate;放量判定只决定"生成端消费档位"。
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.FEEDBACK_MODE_LADDER = exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS = void 0;
16
+ exports.parseFeedbackRolloutThresholds = parseFeedbackRolloutThresholds;
17
+ exports.resolveFeedbackRolloutThresholds = resolveFeedbackRolloutThresholds;
18
+ exports.nextFeedbackMode = nextFeedbackMode;
19
+ exports.negativeEventRateOf = negativeEventRateOf;
20
+ exports.buildFeedbackRolloutReport = buildFeedbackRolloutReport;
21
+ exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS = {
22
+ minStageRuns: 5,
23
+ minAttributionCoverage: 0.3,
24
+ maxNegativeEventRate: 0.5,
25
+ };
26
+ function clampNumber(value, min, max, fallback) {
27
+ const num = typeof value === 'string' ? Number(value) : value;
28
+ if (typeof num !== 'number' || !Number.isFinite(num))
29
+ return fallback;
30
+ return Math.max(min, Math.min(max, num));
31
+ }
32
+ /** 解析并校验放量阈值;失败返回 null(fail-closed,调用方回退默认)。 */
33
+ function parseFeedbackRolloutThresholds(raw) {
34
+ if (raw === null || typeof raw !== 'object')
35
+ return null;
36
+ const obj = raw;
37
+ const hasAny = ['minStageRuns', 'minAttributionCoverage', 'maxNegativeEventRate'].some((k) => obj[k] !== undefined);
38
+ if (!hasAny)
39
+ return null;
40
+ return {
41
+ minStageRuns: clampNumber(obj.minStageRuns, 1, 10_000, exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS.minStageRuns),
42
+ minAttributionCoverage: clampNumber(obj.minAttributionCoverage, 0, 1, exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS.minAttributionCoverage),
43
+ maxNegativeEventRate: clampNumber(obj.maxNegativeEventRate, 0, 1, exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS.maxNegativeEventRate),
44
+ };
45
+ }
46
+ /** 从环境变量解析(缺省全默认)。 */
47
+ function resolveFeedbackRolloutThresholds(env = process.env) {
48
+ return (parseFeedbackRolloutThresholds({
49
+ minStageRuns: env.MIO_FEEDBACK_MIN_STAGE_RUNS,
50
+ minAttributionCoverage: env.MIO_FEEDBACK_MIN_COVERAGE,
51
+ maxNegativeEventRate: env.MIO_FEEDBACK_MAX_NEGATIVE_RATE,
52
+ }) ?? exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS);
53
+ }
54
+ // ══════════════════════════════════════════
55
+ // 放量判定
56
+ // ══════════════════════════════════════════
57
+ /** 阶梯:off → shadow → canary → full。 */
58
+ exports.FEEDBACK_MODE_LADDER = ['off', 'shadow', 'canary', 'full'];
59
+ function nextFeedbackMode(current) {
60
+ const index = exports.FEEDBACK_MODE_LADDER.indexOf(current);
61
+ return index >= 0 && index < exports.FEEDBACK_MODE_LADDER.length - 1 ? exports.FEEDBACK_MODE_LADDER[index + 1] : null;
62
+ }
63
+ /** 快照内负向事件率:负向证据 / 全部证据(无证据 → 0)。 */
64
+ function negativeEventRateOf(snapshot) {
65
+ if (!snapshot)
66
+ return 0;
67
+ let negative = 0;
68
+ let total = 0;
69
+ for (const pair of snapshot.pairs) {
70
+ for (const dimension of ['selection', 'quality', 'landing']) {
71
+ total += pair[dimension].evidenceCount;
72
+ }
73
+ negative += pair.negative.evidenceCount;
74
+ total += pair.negative.evidenceCount;
75
+ }
76
+ return total > 0 ? negative / total : 0;
77
+ }
78
+ function metricsOf(snapshot, runs) {
79
+ return {
80
+ snapshotVersion: snapshot?.version ?? null,
81
+ shadowRuns: runs.filter((r) => r.mode === 'shadow').length,
82
+ canaryRuns: runs.filter((r) => r.mode === 'canary').length,
83
+ attributionCoverage: snapshot?.attributionCoverage ?? 0,
84
+ duplicateApplicationRate: snapshot?.duplicateApplicationRate ?? 0,
85
+ negativeEventRate: negativeEventRateOf(snapshot),
86
+ eventCount: snapshot?.eventCount ?? 0,
87
+ };
88
+ }
89
+ /**
90
+ * 放量判定。判定顺序:
91
+ * 1. 安全不变量破坏(重复加权)→ rollback(无条件,压过一切)。
92
+ * 2. off → shadow:观测零风险,无条件 advance。
93
+ * 3. shadow → canary / canary → full:预固定门槛全部满足才 advance,否则 hold。
94
+ * 4. full:已到顶,hold。
95
+ */
96
+ function buildFeedbackRolloutReport(args) {
97
+ const thresholds = args.thresholds ?? exports.DEFAULT_FEEDBACK_ROLLOUT_THRESHOLDS;
98
+ const metrics = metricsOf(args.snapshot, args.runs);
99
+ const reasons = [];
100
+ // 1) 安全不变量:重复加权率必须为 0(P1 验收不变量)
101
+ if (metrics.duplicateApplicationRate > 0) {
102
+ reasons.push(`duplicate_application_rate=${metrics.duplicateApplicationRate.toFixed(4)} > 0 (invariant broken)`);
103
+ return { decision: 'rollback', from: args.currentMode, to: 'off', reasons, metrics };
104
+ }
105
+ const next = nextFeedbackMode(args.currentMode);
106
+ if (args.currentMode === 'full' || next === null) {
107
+ reasons.push('already at full stage');
108
+ return { decision: 'hold', from: args.currentMode, to: null, reasons, metrics };
109
+ }
110
+ // 2) off → shadow:只增加观测,不改变生成行为
111
+ if (args.currentMode === 'off') {
112
+ reasons.push('observation adds no generation risk');
113
+ return { decision: 'advance', from: 'off', to: 'shadow', reasons, metrics };
114
+ }
115
+ // 3) shadow → canary / canary → full:预固定门槛
116
+ const stageRuns = args.currentMode === 'shadow' ? metrics.shadowRuns : metrics.canaryRuns;
117
+ const stageName = args.currentMode;
118
+ let ready = true;
119
+ if (stageRuns < thresholds.minStageRuns) {
120
+ ready = false;
121
+ reasons.push(`${stageName} runs ${stageRuns} < min ${thresholds.minStageRuns}`);
122
+ }
123
+ if (metrics.attributionCoverage < thresholds.minAttributionCoverage) {
124
+ ready = false;
125
+ reasons.push(`attribution coverage ${metrics.attributionCoverage.toFixed(4)} < min ${thresholds.minAttributionCoverage}`);
126
+ }
127
+ if (args.currentMode === 'canary' && metrics.negativeEventRate > thresholds.maxNegativeEventRate) {
128
+ ready = false;
129
+ reasons.push(`negative event rate ${metrics.negativeEventRate.toFixed(4)} > max ${thresholds.maxNegativeEventRate}`);
130
+ }
131
+ if (!ready) {
132
+ return { decision: 'hold', from: args.currentMode, to: null, reasons, metrics };
133
+ }
134
+ reasons.push(`all ${stageName} stage gates met`);
135
+ return { decision: 'advance', from: args.currentMode, to: next, reasons, metrics };
136
+ }