dsh-subagent-profile 0.3.4 → 0.5.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/README.md +13 -8
  2. package/README.zh.md +13 -8
  3. package/index.mjs +99 -95
  4. package/lib/client.js +1288 -189
  5. package/lib/core/decision-trace.mjs +20 -1
  6. package/lib/core/delegation.mjs +3 -2
  7. package/lib/core/dispatch-gates.mjs +122 -9
  8. package/lib/core/dispatch-schema.mjs +45 -7
  9. package/lib/core/dispatch-tool.mjs +24 -25
  10. package/lib/core/evolution-advice.mjs +154 -34
  11. package/lib/core/evolution-assets.mjs +167 -0
  12. package/lib/core/evolution-canary.mjs +427 -0
  13. package/lib/core/evolution-draft.mjs +333 -0
  14. package/lib/core/evolution-engine.mjs +413 -0
  15. package/lib/core/evolution-generate.mjs +180 -0
  16. package/lib/core/evolution-lag.mjs +78 -0
  17. package/lib/core/evolution-ledger.mjs +135 -27
  18. package/lib/core/evolution-persistence.mjs +213 -0
  19. package/lib/core/evolution-renewal.mjs +64 -0
  20. package/lib/core/evolution-routes.mjs +234 -0
  21. package/lib/core/http-helpers.mjs +35 -0
  22. package/lib/core/http-routes.mjs +35 -39
  23. package/lib/core/model-policy.mjs +77 -0
  24. package/lib/core/presets-sync.mjs +24 -2
  25. package/lib/core/profile-directory.mjs +17 -0
  26. package/lib/core/profile-provider.mjs +9 -3
  27. package/lib/core/profiles-query.mjs +178 -0
  28. package/lib/core/profiles-store.mjs +56 -8
  29. package/lib/core/pure.mjs +1 -1
  30. package/lib/core/session-read.mjs +17 -0
  31. package/lib/core/shims.mjs +2 -1
  32. package/package.json +2 -2
  33. package/presets/orchestrator-v2/NOTICE +7 -0
  34. package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
  35. package/presets/orchestrator-v2/custom-bash.mjs +213 -0
  36. package/presets/orchestrator-v2/preset.yml +2 -0
  37. package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
  38. package/presets/orchestrator/preset.yml +0 -2
@@ -0,0 +1,427 @@
1
+ // lib/core/evolution-canary.mjs — 金丝雀防退化域:观察期计数、判定、人工编辑中止、
2
+ // 回滚建议派生与回滚/显式保留执行、冷启动状态、hold-out 回放契约位。判据初值
3
+ // (待实测后校准):跌破基线阈值 = 变更前加权分均值 − 1 标准差;观察期 K = 5 次
4
+ // 相关派发;滞后 N、回滚冷却见 evolution-lag.mjs。基线在 apply 时刻冻结(时间隔离
5
+ // 防自补偿);判定只读台账、不产生派发。回滚 = 撤销一次经确认的应用:不重过三道
6
+ // 闸(配置曾通过 apply 闸),保留 sanitize strict 校验;任何路径绝不自动写 profile
7
+ // ——回滚必须 human_confirmed + suggestion-rollback 审计。写失败纪律:资产/审计走
8
+ // 既有 fail-soft 高可见通道,本域不静默吞写失败。触发分离:本域只响应金丝雀触发
9
+ // (跌破基线自动判定);冷态沉淀(用户显式/指纹≥N)是独立线,本域不互触发。
10
+
11
+ import { sanitizeProfile } from './pure.mjs';
12
+ import { profileKeyOf, weightedSuccess } from './evolution-summary.mjs';
13
+ import { configIdentityKey, matchingRegistryProfiles, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
14
+ import { ROLLBACK_COOLDOWN_MS } from './evolution-lag.mjs';
15
+
16
+ // 观察期 K:apply 后观察多少次相关派发才判定(初值,待实测后校准)。
17
+ export const CANARY_K = 5;
18
+ // 观察期内 24h 无新派发 → stalled(判定延期,不误判好/坏)。
19
+ export const CANARY_STALL_MS = 24 * 3600 * 1000;
20
+ // 判定所需最少样本数(数据稀疏时判 inconclusive 继续等待)。
21
+ export const CANARY_MIN_N = 3;
22
+ // 基线逐条分序列长度上限(控制资产文件体积;初值待实测)。
23
+ const BASELINE_SCORE_CAP = 100;
24
+ // 审计携带的样本点上限(展示截断,不丢判定结论)。
25
+ const AUDIT_SAMPLE_CAP = 20;
26
+
27
+ // 单条派发是否已结算(settle 完成:outcome.status 存在;continuable 无 outcome 不计)。
28
+ export function settledDispatch(record) {
29
+ return record !== null && typeof record === 'object' && record.outcome !== null
30
+ && typeof record.outcome === 'object' && typeof record.outcome.status === 'string';
31
+ }
32
+
33
+ // 单条派发表现分(基线/样本同口径 0/1);confirmed-false −0.3 是聚合层惰性 join,逐条不可得——判据以聚合均值为准、逐条分只供标准差(初值待实测校准)。
34
+ export function sampleScoreOf(record) {
35
+ return settledDispatch(record) && record.outcome.status === 'completed' ? 1 : 0;
36
+ }
37
+
38
+ // 总体标准差(下沿构造输入;单样本时 σ=0,下沿等于均值)。
39
+ function stddevOf(values) {
40
+ if (values.length === 0) return 0;
41
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
42
+ const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
43
+ return Math.sqrt(variance);
44
+ }
45
+
46
+ // 基线下沿 = 加权分均值 − 1 标准差(初值,待实测后校准);基线缺失/无逐条分返回 null → 判定降级不判。
47
+ export function baselineLowerBound(baseline) {
48
+ const scores = Array.isArray(baseline?.scores) ? baseline.scores.filter((v) => Number.isFinite(v)) : [];
49
+ if (typeof baseline?.weighted_success !== 'number' || !Number.isFinite(baseline.weighted_success) || scores.length === 0) return null;
50
+ return baseline.weighted_success - stddevOf(scores);
51
+ }
52
+
53
+ // 变更前基线(apply 时刻冻结):聚合均值/样本数 + 最近 ≤cap 条逐条分(下沿构造用)。
54
+ export function captureBaseline({ summaries, records, profileKey, now }) {
55
+ const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
56
+ const group = l1[profileKey] !== null && typeof l1[profileKey] === 'object' ? l1[profileKey] : {};
57
+ const sampleN = Number.isFinite(group.confidence?.n) ? group.confidence.n : (Number.isFinite(group.deployments_total) ? group.deployments_total : null);
58
+ const weighted = Number.isFinite(group.score?.weighted_success) ? group.score.weighted_success : null;
59
+ const scores = (Array.isArray(records) ? records : [])
60
+ .filter((r) => r !== null && typeof r === 'object' && settledDispatch(r) && Number.isFinite(r.ts)
61
+ && r.ts < now && profileKeyOf(r.effective) === profileKey)
62
+ .sort((a, b) => a.ts - b.ts)
63
+ .slice(-BASELINE_SCORE_CAP)
64
+ .map(sampleScoreOf);
65
+ return { sampleN, weighted_success: weighted, scores };
66
+ }
67
+
68
+ // 变更前完整配置快照(回滚恢复依据):身份键匹配的全部注册表方案,含 name/description/
69
+ // model/provider/preset/persona/toolFilter 等全字段(不是「保存为方案」节选段);persisted 标记随条目留档。
70
+ export function captureFromSnapshot(store, profileKey, now) {
71
+ const cleanList = matchingRegistryProfiles(store, profileKey);
72
+ const values = store.profiles instanceof Map ? [...store.profiles.values()] : [];
73
+ const byId = new Map(values.map((p) => [p.id, p]));
74
+ return {
75
+ v: 1,
76
+ kind: 'full-profile-snapshots',
77
+ profileKey,
78
+ capturedAt: now,
79
+ profiles: cleanList.map((config) => ({ config, persisted: byId.get(config.id)?.persisted === true })),
80
+ };
81
+ }
82
+
83
+ // 观察样本的身份键:应用后方案配置重建(能力轴候选应用后身份键改变才计得到「应用后」派发);无 applied_config 回退资产身份键。
84
+ export function canaryPostKey(asset) {
85
+ const applied = asset !== null && typeof asset === 'object' ? asset.applied_config : null;
86
+ if (applied !== null && typeof applied === 'object') {
87
+ const key = configIdentityKey(applied);
88
+ if (key !== null && key !== '') return key;
89
+ }
90
+ return typeof asset.profile_key === 'string' ? asset.profile_key : '(inline)';
91
+ }
92
+
93
+ // 观察期样本记录:appliedAt 之后、应用后身份键匹配、已结算的派发(时间升序)。
94
+ export function canarySampleRecords({ records, postKey, appliedAt }) {
95
+ return (Array.isArray(records) ? records : [])
96
+ .filter((r) => r !== null && typeof r === 'object' && settledDispatch(r) && Number.isFinite(r.ts)
97
+ && r.ts >= appliedAt && profileKeyOf(r.effective) === postKey)
98
+ .sort((a, b) => a.ts - b.ts);
99
+ }
100
+
101
+ // 累计样本点:每点 { ts, sampleN, weighted_success }(与聚合层同口径 running 聚合)。
102
+ export function canarySamplesOf(records) {
103
+ let completed = 0;
104
+ let failed = 0;
105
+ let killed = 0;
106
+ const samples = [];
107
+ for (const record of records) {
108
+ if (record.outcome.status === 'completed') completed += 1;
109
+ else if (record.outcome.status === 'failed') failed += 1;
110
+ else if (record.outcome.status === 'killed') killed += 1;
111
+ samples.push({ ts: record.ts, sampleN: completed + failed + killed, weighted_success: weightedSuccess({ completed, failed, killed }) });
112
+ }
113
+ return samples;
114
+ }
115
+
116
+ // 观察期满判定(纯函数):跌破基线下沿 → regress;≥ 基线均值 → applied;
117
+ // ±1σ 内且样本 < 基线样本 → inconclusive(延长观察);样本不足/基线缺失不判;
118
+ // k<K 且 24h 无新派发 → stalled;k<K 正常等待 → pending。
119
+ export function decideCanary({ baseline, samples, appliedAt, now, kTotal = CANARY_K, minN = CANARY_MIN_N, stallMs = CANARY_STALL_MS } = {}) {
120
+ const b = baseline !== null && typeof baseline === 'object' ? baseline : {};
121
+ const lastTs = samples.length > 0 ? samples[samples.length - 1].ts : appliedAt;
122
+ const thresholds = { mean: typeof b.weighted_success === 'number' ? b.weighted_success : null, lowerBound: baselineLowerBound(b) };
123
+ if (samples.length < kTotal) {
124
+ if (Number.isFinite(lastTs) && now - lastTs > stallMs) {
125
+ return { verdict: 'stalled', reason: '观察期 24h 无新派发,判定延期(不误判好/坏)', thresholds };
126
+ }
127
+ return { verdict: 'pending', thresholds };
128
+ }
129
+ const post = samples[samples.length - 1];
130
+ const postMetrics = { sampleN: post.sampleN, weighted_success: post.weighted_success };
131
+ if (typeof b.sampleN !== 'number' || typeof b.weighted_success !== 'number') {
132
+ return { verdict: 'inconclusive', reason: '应用时刻聚合缺失(基线 unknown),金丝雀不判', thresholds, postMetrics };
133
+ }
134
+ if (post.sampleN < minN) {
135
+ return { verdict: 'inconclusive', reason: '样本不足,延长观察', insufficient: true, thresholds, postMetrics };
136
+ }
137
+ if (thresholds.lowerBound === null) {
138
+ return { verdict: 'inconclusive', reason: '基线逐条分缺失,无法构造下沿,不判', thresholds, postMetrics };
139
+ }
140
+ if (post.weighted_success < thresholds.lowerBound) {
141
+ return { verdict: 'regress', reason: '观察期表现较基线下降', thresholds, postMetrics };
142
+ }
143
+ if (post.weighted_success >= b.weighted_success) {
144
+ return { verdict: 'applied', reason: '观察期表现未低于基线', thresholds, postMetrics };
145
+ }
146
+ if (post.sampleN < b.sampleN) {
147
+ return { verdict: 'inconclusive', reason: '±1σ 内且样本少于基线,延长观察', insufficient: true, thresholds, postMetrics };
148
+ }
149
+ return { verdict: 'applied', reason: '观察期表现未跌破基线下沿', thresholds, postMetrics };
150
+ }
151
+
152
+ // 回滚审计闸位占位:回滚不重过三道闸(配置曾通过 apply 闸),sanitize strict 保留。
153
+ export function skippedGates(reason) {
154
+ const skipped = { verdict: 'skipped', reason };
155
+ return { whitelist: skipped, cost: skipped, intersection: skipped, approval: 'never' };
156
+ }
157
+
158
+ // 应用后方案是否仍与写入时一致(人工编辑过/被删除 → 不一致,回滚与判定中止)。
159
+ function appliedConfigMatches(deps, asset) {
160
+ const applied = asset.applied_config;
161
+ if (applied === null || typeof applied !== 'object' || typeof applied.id !== 'string' || applied.id === '') return false;
162
+ const current = deps.store?.profiles instanceof Map ? deps.store.profiles.get(applied.id) : undefined;
163
+ if (current === undefined) return false;
164
+ const { clean } = sanitizeProfile(current, { strict: false });
165
+ return JSON.stringify(clean) === JSON.stringify(applied);
166
+ }
167
+
168
+ // 观察期前变更前方案指纹是否未变(排除应用写入的方案自身);聚合缺失不中止(基线 unknown 由判定降级),组消失也不视为人工编辑。
169
+ function preApplyFingerprintMatches(deps, asset) {
170
+ const summaries = deps.getSummaries?.();
171
+ if (summaries === null || summaries === undefined) return true;
172
+ const l1 = summaries.l1;
173
+ if (l1 === null || typeof l1 !== 'object' || l1[asset.profile_key] === undefined) return true;
174
+ const appliedId = asset.applied_config !== null && typeof asset.applied_config === 'object' ? asset.applied_config.id : null;
175
+ const registry = matchingRegistryProfiles(deps.store, asset.profile_key).filter((p) => p.id !== appliedId);
176
+ const from = sourceConfigFromKey(asset.profile_key, summaries.l2);
177
+ return profileFingerprint({ profileKey: asset.profile_key, from, registryEntries: registry }) === asset.profile_fingerprint;
178
+ }
179
+
180
+ // 人工编辑中止判据:应用后方案被改 / 相关方案指纹变化 → 观测已脏,返回原因。
181
+ function canaryAbortReason(deps, asset) {
182
+ if (!appliedConfigMatches(deps, asset)) return '应用后方案已被人工编辑(配置与写入时不一致)';
183
+ if (!preApplyFingerprintMatches(deps, asset)) return '该身份键相关方案在观察期内被人工编辑';
184
+ return null;
185
+ }
186
+
187
+ // golden-canary 治理审计事件:判定结果留痕(写失败走治理审计三件套)。
188
+ function goldenCanaryAuditEntry(asset, { verdict, reason, samples, postMetrics, thresholds }) {
189
+ const baseline = asset.baseline_metrics !== null && typeof asset.baseline_metrics === 'object' ? asset.baseline_metrics : {};
190
+ const entry = {
191
+ event: 'golden-canary',
192
+ asset_id: asset.id,
193
+ profile_key: asset.profile_key,
194
+ baseline_metrics: { sampleN: baseline.sampleN ?? null, weighted_success: baseline.weighted_success ?? null },
195
+ samples: (Array.isArray(samples) ? samples : []).slice(-AUDIT_SAMPLE_CAP),
196
+ post_metrics: postMetrics ?? null,
197
+ verdict,
198
+ thresholds: thresholds ?? null,
199
+ };
200
+ if (reason !== undefined && reason !== null) entry.reason = reason;
201
+ return entry;
202
+ }
203
+
204
+ // 人工编辑中止落账:资产标 invalid + 金丝雀判定中止(不沿用旧基线)+ 审计。
205
+ function abortCanary(deps, asset, reason, now) {
206
+ const canary = { ...(asset.canary ?? {}), verdict: 'aborted-human-edit', abort_reason: reason, aborted_at: now };
207
+ deps.assets.transition(asset.id, 'proposed', { canary, invalid: true });
208
+ deps.evoLedger.recordGovernanceAudit(goldenCanaryAuditEntry(asset, {
209
+ verdict: 'aborted-human-edit', reason, samples: canary.samples ?? [], postMetrics: null, thresholds: null,
210
+ }));
211
+ return { evaluated: true, verdict: 'aborted-human-edit', reason };
212
+ }
213
+
214
+ // 金丝雀推进:中止检查 → 重算样本 → 判定 → 资产/金丝雀状态落账 → 审计(幂等:状态无变化不重复落账,verdict 或判定样本数变化才写 golden-canary)。
215
+ export function evaluateCanary(deps, asset, now = Date.now()) {
216
+ const canary = asset.canary;
217
+ if (asset.state !== 'proposed' || canary === null || typeof canary !== 'object') return { evaluated: false };
218
+ if (asset.invalid === true && canary.verdict === 'aborted-human-edit') return { evaluated: false };
219
+ const abortReason = canaryAbortReason(deps, asset);
220
+ if (abortReason !== null) return abortCanary(deps, asset, abortReason, now);
221
+ const appliedAt = Number.isFinite(canary.appliedAt) ? canary.appliedAt
222
+ : (Number.isFinite(asset.applied_at) ? asset.applied_at : (Number.isFinite(asset.updatedAt) ? asset.updatedAt : now));
223
+ const records = deps.getDispatchRecords?.() ?? [];
224
+ const samples = canarySamplesOf(canarySampleRecords({ records, postKey: canaryPostKey(asset), appliedAt }));
225
+ const decision = decideCanary({ baseline: canary.baseline ?? {}, samples, appliedAt, now });
226
+ if (decision.verdict === 'pending') {
227
+ if (samples.length === (canary.k ?? 0) && (canary.verdict ?? null) === null) return { evaluated: false };
228
+ deps.assets.transition(asset.id, 'proposed', { canary: { ...canary, k: samples.length, kTotal: canary.kTotal ?? CANARY_K, samples, verdict: null, stalled: false } });
229
+ return { evaluated: true, verdict: 'pending' };
230
+ }
231
+ const judged = decision.postMetrics !== undefined ? decision.postMetrics.sampleN : samples.length;
232
+ const next = {
233
+ ...canary,
234
+ k: samples.length,
235
+ kTotal: canary.kTotal ?? CANARY_K,
236
+ samples,
237
+ verdict: decision.verdict,
238
+ stalled: decision.verdict === 'stalled',
239
+ judged_sample_n: judged,
240
+ judged_at: now, rollback_reminder_sent: canary.rollback_reminder_sent === true || decision.verdict === 'regress',
241
+ ...(decision.insufficient === true ? { insufficient: true } : { insufficient: false }),
242
+ };
243
+ deps.assets.transition(asset.id, decision.verdict === 'applied' ? 'applied' : 'proposed', { canary: next });
244
+ const audited = decision.verdict !== (canary.verdict ?? null) || judged !== (canary.judged_sample_n ?? null);
245
+ if (audited) {
246
+ deps.evoLedger.recordGovernanceAudit(goldenCanaryAuditEntry(asset, {
247
+ verdict: decision.verdict, reason: decision.reason, samples, postMetrics: decision.postMetrics, thresholds: decision.thresholds,
248
+ }));
249
+ }
250
+ // 回滚建议生成时提醒一次(观察中/保留/中止不提醒);mint 失败不阻断判定(fail-soft)。
251
+ if (decision.verdict === 'regress' && canary.rollback_reminder_sent !== true) { try { deps.mintRollbackReminder?.(asset); } catch { /* 提醒 mint 失败不影响判定 */ } }
252
+ return { evaluated: true, verdict: decision.verdict, audited };
253
+ }
254
+
255
+ // 回滚建议条目派生(只读数据源,UI 由建议面板消费;绝不自动执行回滚)。
256
+ export function rollbackSuggestionOf(asset) {
257
+ const canary = asset !== null && typeof asset === 'object' ? asset.canary : null;
258
+ if (asset === null || typeof asset !== 'object' || asset.state !== 'proposed' || asset.invalid === true) return null;
259
+ if (canary === null || typeof canary !== 'object' || canary.verdict !== 'regress') return null;
260
+ const samples = Array.isArray(canary.samples) ? canary.samples : [];
261
+ const post = samples.length > 0 ? samples[samples.length - 1] : null;
262
+ const baseline = asset.baseline_metrics !== null && typeof asset.baseline_metrics === 'object' ? asset.baseline_metrics : {};
263
+ const delta = post !== null && Number.isFinite(baseline.weighted_success)
264
+ ? Number((post.weighted_success - baseline.weighted_success).toFixed(3)) : null;
265
+ return {
266
+ assetId: asset.id,
267
+ profileKey: asset.profile_key,
268
+ from: asset.applied_config ?? null,
269
+ to: asset.fromSnapshot ?? null,
270
+ reason: '观察期表现较基线下降',
271
+ evidence: {
272
+ baseline: { sampleN: baseline.sampleN ?? null, weighted_success: baseline.weighted_success ?? null },
273
+ post: post === null ? null : { sampleN: post.sampleN, weighted_success: post.weighted_success },
274
+ delta,
275
+ samples,
276
+ },
277
+ createdAt: canary.judged_at ?? asset.updatedAt ?? null,
278
+ axis: asset.axis,
279
+ direction: asset.direction,
280
+ };
281
+ }
282
+
283
+ // suggestion-rollback 审计条目(通过/拒绝双分支;gates 默认 skipped——回滚不重过
284
+ // 三道闸,配置曾通过 apply 闸)。
285
+ function rollbackAuditEntry(asset, humanConfirmed, reason, extra = {}) {
286
+ const entry = {
287
+ event: 'suggestion-rollback',
288
+ profile_id: asset.id,
289
+ from: asset.applied_config ?? null,
290
+ to: asset.fromSnapshot ?? null,
291
+ axis: asset.axis,
292
+ direction: asset.direction,
293
+ gates: extra.gates ?? skippedGates(reason ?? '未评估'),
294
+ human_confirmed: humanConfirmed === true,
295
+ };
296
+ if (reason !== undefined && reason !== null) entry.reason = reason;
297
+ return entry;
298
+ }
299
+
300
+ // suggestion-keep 审计条目:用户显式保留当前配置(不认同回滚判断)的留痕;拒绝分支 reason = 拒绝原因,通过分支 reason = 'user-kept'。
301
+ function keepAuditEntry(asset, humanConfirmed, reason) {
302
+ return {
303
+ event: 'suggestion-keep',
304
+ asset_id: asset.id,
305
+ profile_key: asset.profile_key,
306
+ human_confirmed: humanConfirmed === true,
307
+ reason: reason ?? 'user-kept',
308
+ };
309
+ }
310
+
311
+ // 恢复 fromSnapshot:逐条 sanitize strict(回滚不重过三道闸,strict 保留)→ 移除
312
+ // 应用写入的方案 → 恢复快照方案(含 persisted 语义)→ 持久化。
313
+ function restoreFromSnapshot(deps, asset) {
314
+ const snapshot = asset.fromSnapshot;
315
+ if (snapshot === null || typeof snapshot !== 'object' || !Array.isArray(snapshot.profiles)) {
316
+ return { ok: false, reason: '回滚快照缺失或形状不符,拒绝回滚' };
317
+ }
318
+ const entries = [];
319
+ for (const item of snapshot.profiles) {
320
+ if (item === null || typeof item !== 'object' || item.config === null || typeof item.config !== 'object'
321
+ || typeof item.config.id !== 'string' || item.config.id === '') {
322
+ return { ok: false, reason: '回滚快照条目形状不符,拒绝回滚' };
323
+ }
324
+ const { clean, warnings } = sanitizeProfile(item.config, { strict: true });
325
+ if (warnings.length > 0) {
326
+ return { ok: false, reason: '回滚快照未通过 sanitize strict 校验(' + String(warnings[0].field) + '),拒绝回滚' };
327
+ }
328
+ entries.push({ config: clean, persisted: item.persisted === true });
329
+ }
330
+ const appliedId = asset.applied_config !== null && typeof asset.applied_config === 'object' ? asset.applied_config.id : null;
331
+ if (typeof appliedId === 'string' && appliedId !== '') deps.store.profiles.delete(appliedId);
332
+ for (const entry of entries) deps.store.profiles.set(entry.config.id, { ...entry.config, persisted: entry.persisted });
333
+ const persisted = deps.store.persistProfiles();
334
+ return { ok: true, restored: entries.map((e) => e.config.id), persisted: persisted?.persisted === true };
335
+ }
336
+
337
+ // 决议公共校验(回滚与显式保留同口径):人工确认 → 观察期状态 → 判定 regress →
338
+ // 当前配置未被动过;返回 null = 通过,否则为拒绝原因。
339
+ function resolutionChecks(deps, asset, humanConfirmed, confirmVerb) {
340
+ if (humanConfirmed !== true) return `必须人工确认后才能${confirmVerb}(human_confirmed=true)`;
341
+ if (asset.state !== 'proposed') return `资产状态不允许${confirmVerb}(仅观察期 proposed 可执行)`;
342
+ if (asset.invalid === true || asset.canary?.verdict === 'aborted-human-edit') {
343
+ return '观察期已因人工编辑中止,不支持一键回滚(请手动编辑恢复)';
344
+ }
345
+ if (asset.canary?.verdict !== 'regress') return '金丝雀尚未判定为回退(verdict 非 regress),拒绝执行';
346
+ if (!appliedConfigMatches(deps, asset) || !preApplyFingerprintMatches(deps, asset)) {
347
+ return '当前方案已被编辑,不支持一键回滚';
348
+ }
349
+ return null;
350
+ }
351
+
352
+ // 显式保留(action=keep):用户不认同回滚判断,保留当前配置。不写 profile、不重过
353
+ // 三道闸;资产 proposed→applied + suggestion-keep 审计,同键冷却照常(与回滚同口径)。
354
+ function executeKeep(deps, asset, now) {
355
+ const transitioned = deps.assets.transition(asset.id, 'applied', {
356
+ keep_at: now,
357
+ cooldown_until: now + ROLLBACK_COOLDOWN_MS,
358
+ canary: { ...(asset.canary ?? {}), kept_at: now },
359
+ });
360
+ deps.evoLedger.recordGovernanceAudit(keepAuditEntry(asset, true, 'user-kept'));
361
+ try {
362
+ deps.injectRollbackCooldown?.(asset.profile_key, now + ROLLBACK_COOLDOWN_MS);
363
+ } catch { /* 冷却注入失败不阻断保留(fail-soft;候选生成侧另有资产级冷却判据) */ }
364
+ return { ok: true, action: 'keep', state: 'applied', assetPersisted: transitioned.persisted };
365
+ }
366
+
367
+ // 决议执行(受控路由专用;绝不自动落写):action='keep' 走显式保留,缺省走回滚。
368
+ // 人工确认 → 公共校验 → 回滚:恢复快照 + 持久化 → rolled_back;保留:applied。
369
+ // 两分支都写各自审计 + 同键冷却注入建议侧。
370
+ export function executeRollback(deps, asset, humanConfirmed, action = 'rollback', now = Date.now()) {
371
+ const keep = action === 'keep';
372
+ const audit = (entry) => deps.evoLedger.recordGovernanceAudit(entry);
373
+ const reject = (reason) => {
374
+ audit(keep ? keepAuditEntry(asset, humanConfirmed, reason) : rollbackAuditEntry(asset, humanConfirmed, reason));
375
+ return { ok: false, reason };
376
+ };
377
+ const check = resolutionChecks(deps, asset, humanConfirmed, keep ? '保留当前方案' : '回滚');
378
+ if (check !== null) return reject(check);
379
+ if (keep) return executeKeep(deps, asset, now);
380
+ const restore = restoreFromSnapshot(deps, asset);
381
+ if (!restore.ok) return reject(restore.reason);
382
+ const transitioned = deps.assets.transition(asset.id, 'rolled_back', {
383
+ rollback_at: now,
384
+ canary: { ...(asset.canary ?? {}), rollback_at: now },
385
+ });
386
+ audit(rollbackAuditEntry(asset, humanConfirmed, null, { gates: skippedGates('回滚不重过三道闸:配置曾通过 apply 闸,只做 sanitize strict 校验') }));
387
+ try {
388
+ deps.injectRollbackCooldown?.(asset.profile_key, now + ROLLBACK_COOLDOWN_MS);
389
+ } catch { /* 冷却注入失败不阻断回滚(fail-soft;候选生成侧另有资产级冷却判据) */ }
390
+ return { ok: true, state: 'rolled_back', restored: restore.restored, persisted: restore.persisted, assetPersisted: transitioned.persisted };
391
+ }
392
+
393
+ // 冷启动状态(只读暴露,UI 展示归设置面板):apply/防退化可用性 + 原因清单;minN
394
+ // 不足或聚合缺失 → 双禁用并给出原因,不清除既有建议/候选。
395
+ export function evolutionStatusOf({ getApplyEnabled, summaries, minN = CANARY_MIN_N }) {
396
+ const switchOn = getApplyEnabled?.() === true;
397
+ const reasons = [];
398
+ const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
399
+ const ready = Object.values(l1).some((g) => g !== null && typeof g === 'object' && (Number.isFinite(g.confidence?.n) ? g.confidence.n : 0) >= minN);
400
+ if (!switchOn) reasons.push('进化 apply 开关未开启(默认关,可在设置页开启)');
401
+ if (summaries === null || summaries === undefined) reasons.push('聚合数据缺失,暂不支持应用与金丝雀判定');
402
+ else if (!ready) reasons.push('派发数据不足(各配置样本 < ' + minN + '),暂不支持应用');
403
+ return { applyEnabled: switchOn && ready, canaryEnabled: ready, reasons };
404
+ }
405
+
406
+ // hold-out 回放契约位(只读 stub):真实回放引擎与阈值待实测后回填;本函数只
407
+ // 验收契约——结果不落写(纯函数零 IO)、规则窗口与验证窗口按时间隔离。
408
+ export function holdoutBacktest(records, candidateConfig, { splitTs = null } = {}) {
409
+ const list = Array.isArray(records) ? records : [];
410
+ let split = Number.isFinite(splitTs) ? splitTs : null;
411
+ if (split === null) {
412
+ const stamps = list.map((r) => (r !== null && typeof r === 'object' ? r.ts : null)).filter((ts) => Number.isFinite(ts)).sort((a, b) => a - b);
413
+ split = stamps.length > 0 ? stamps[Math.floor(stamps.length / 2)] : Date.now();
414
+ }
415
+ const withTs = list.filter((r) => r !== null && typeof r === 'object' && Number.isFinite(r.ts));
416
+ const trainingCount = withTs.filter((r) => r.ts < split).length;
417
+ const holdoutCount = withTs.filter((r) => r.ts >= split).length;
418
+ return {
419
+ v: 1,
420
+ engine: 'stub',
421
+ readOnly: true,
422
+ conclusion: '回放引擎与阈值待实测后回填;本结果只验收契约(只读不落写 + 规则/验证时间隔离)',
423
+ candidateKey: candidateConfig !== null && typeof candidateConfig === 'object' ? configIdentityKey(candidateConfig) : null,
424
+ boundary: { splitTs: split, trainingCount, holdoutCount, ruleWindow: 'ts < splitTs', validationWindow: 'ts >= splitTs' },
425
+ verdict: 'hold',
426
+ };
427
+ }