@jonathangu/openclawbrain 0.3.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/bin/openclawbrain.js +15 -0
  4. package/docs/END_STATE.md +244 -0
  5. package/docs/EVIDENCE.md +128 -0
  6. package/docs/RELEASE_CONTRACT.md +91 -0
  7. package/docs/agent-tools.md +106 -0
  8. package/docs/architecture.md +224 -0
  9. package/docs/configuration.md +178 -0
  10. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
  11. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
  12. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
  13. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
  14. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
  15. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
  16. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
  17. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
  18. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
  19. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
  20. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
  21. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
  22. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
  23. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
  24. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
  25. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
  26. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
  27. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
  28. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
  29. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
  30. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
  31. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
  32. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
  33. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
  34. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
  35. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
  36. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
  37. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
  38. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
  39. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
  40. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
  41. package/docs/evidence/README.md +16 -0
  42. package/docs/fts5.md +161 -0
  43. package/docs/tui.md +506 -0
  44. package/index.ts +1372 -0
  45. package/openclaw.plugin.json +136 -0
  46. package/package.json +66 -0
  47. package/src/assembler.ts +804 -0
  48. package/src/brain-cli.ts +316 -0
  49. package/src/brain-core/decay.ts +35 -0
  50. package/src/brain-core/episode.ts +82 -0
  51. package/src/brain-core/graph.ts +321 -0
  52. package/src/brain-core/health.ts +116 -0
  53. package/src/brain-core/mutator.ts +281 -0
  54. package/src/brain-core/pack.ts +117 -0
  55. package/src/brain-core/policy.ts +153 -0
  56. package/src/brain-core/replay.ts +1 -0
  57. package/src/brain-core/teacher.ts +105 -0
  58. package/src/brain-core/trace.ts +40 -0
  59. package/src/brain-core/traverse.ts +230 -0
  60. package/src/brain-core/types.ts +405 -0
  61. package/src/brain-core/update.ts +123 -0
  62. package/src/brain-harvest/human.ts +46 -0
  63. package/src/brain-harvest/scanner.ts +98 -0
  64. package/src/brain-harvest/self.ts +147 -0
  65. package/src/brain-runtime/assembler-extension.ts +230 -0
  66. package/src/brain-runtime/evidence-detectors.ts +68 -0
  67. package/src/brain-runtime/graph-io.ts +72 -0
  68. package/src/brain-runtime/harvester-extension.ts +98 -0
  69. package/src/brain-runtime/service.ts +659 -0
  70. package/src/brain-runtime/tools.ts +109 -0
  71. package/src/brain-runtime/worker-state.ts +106 -0
  72. package/src/brain-runtime/worker-supervisor.ts +169 -0
  73. package/src/brain-store/embedding.ts +179 -0
  74. package/src/brain-store/init.ts +347 -0
  75. package/src/brain-store/migrations.ts +188 -0
  76. package/src/brain-store/store.ts +816 -0
  77. package/src/brain-worker/child-runner.ts +321 -0
  78. package/src/brain-worker/jobs.ts +12 -0
  79. package/src/brain-worker/mutation-job.ts +5 -0
  80. package/src/brain-worker/promotion-job.ts +5 -0
  81. package/src/brain-worker/protocol.ts +79 -0
  82. package/src/brain-worker/teacher-job.ts +5 -0
  83. package/src/brain-worker/update-job.ts +5 -0
  84. package/src/brain-worker/worker.ts +422 -0
  85. package/src/compaction.ts +1332 -0
  86. package/src/db/config.ts +265 -0
  87. package/src/db/connection.ts +72 -0
  88. package/src/db/features.ts +42 -0
  89. package/src/db/migration.ts +561 -0
  90. package/src/engine.ts +1995 -0
  91. package/src/expansion-auth.ts +351 -0
  92. package/src/expansion-policy.ts +303 -0
  93. package/src/expansion.ts +383 -0
  94. package/src/integrity.ts +600 -0
  95. package/src/large-files.ts +527 -0
  96. package/src/openclaw-bridge.ts +22 -0
  97. package/src/retrieval.ts +357 -0
  98. package/src/store/conversation-store.ts +748 -0
  99. package/src/store/fts5-sanitize.ts +29 -0
  100. package/src/store/full-text-fallback.ts +74 -0
  101. package/src/store/index.ts +29 -0
  102. package/src/store/summary-store.ts +918 -0
  103. package/src/summarize.ts +847 -0
  104. package/src/tools/common.ts +53 -0
  105. package/src/tools/lcm-conversation-scope.ts +76 -0
  106. package/src/tools/lcm-describe-tool.ts +234 -0
  107. package/src/tools/lcm-expand-query-tool.ts +594 -0
  108. package/src/tools/lcm-expand-tool.delegation.ts +556 -0
  109. package/src/tools/lcm-expand-tool.ts +448 -0
  110. package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
  111. package/src/tools/lcm-grep-tool.ts +200 -0
  112. package/src/transcript-repair.ts +301 -0
  113. package/src/types.ts +149 -0
@@ -0,0 +1,422 @@
1
+ import type { BrainConfig, BrainEdge, BrainEvidence, Episode } from "../brain-core/types.js";
2
+ import { trustRank } from "../brain-core/types.js";
3
+ import type { BrainStore } from "../brain-store/store.js";
4
+ import type { BrainGraph } from "../brain-core/graph.js";
5
+ import type { BrainTeacher } from "../brain-core/teacher.js";
6
+ import type { BrainMutator } from "../brain-core/mutator.js";
7
+ import type { PackManager } from "../brain-core/pack.js";
8
+ import { computeReinforceUpdates, updateBaseline, applyWeightUpdates } from "../brain-core/update.js";
9
+ import { decayAllWeights } from "../brain-core/decay.js";
10
+ import { computeHealth } from "../brain-core/health.js";
11
+
12
+ function compareEvidencePriority(left: BrainEvidence, right: BrainEvidence): number {
13
+ const trustDelta = trustRank(left.source) - trustRank(right.source);
14
+ if (trustDelta !== 0) {
15
+ return trustDelta;
16
+ }
17
+
18
+ const confidenceDelta = left.confidence - right.confidence;
19
+ if (confidenceDelta !== 0) {
20
+ return confidenceDelta;
21
+ }
22
+
23
+ return left.createdAt - right.createdAt;
24
+ }
25
+
26
+ function losingEvidenceResolution(
27
+ winner: BrainEvidence,
28
+ loser: BrainEvidence,
29
+ ): { resolution: "discarded_lower_trust" | "discarded_duplicate"; note: string } {
30
+ const winnerTrust = trustRank(winner.source);
31
+ const loserTrust = trustRank(loser.source);
32
+ if (winnerTrust > loserTrust) {
33
+ return {
34
+ resolution: "discarded_lower_trust",
35
+ note: `pending evidence from ${winner.source} outranks ${loser.source}`,
36
+ };
37
+ }
38
+
39
+ if (winner.value === loser.value) {
40
+ return {
41
+ resolution: "discarded_duplicate",
42
+ note: `matching ${loser.source} evidence already queued`,
43
+ };
44
+ }
45
+
46
+ if (winner.confidence !== loser.confidence) {
47
+ return {
48
+ resolution: "discarded_duplicate",
49
+ note: `same-trust evidence superseded by higher-confidence ${winner.source} evidence`,
50
+ };
51
+ }
52
+
53
+ return {
54
+ resolution: "discarded_duplicate",
55
+ note: `same-trust evidence superseded by newer ${winner.source} evidence`,
56
+ };
57
+ }
58
+
59
+ function classifyEvidenceAgainstEpisode(
60
+ episode: Episode,
61
+ evidence: BrainEvidence,
62
+ ): { resolution: "discarded_lower_trust" | "discarded_duplicate"; note: string } | null {
63
+ if (episode.reward === null || episode.rewardSource === null) {
64
+ return null;
65
+ }
66
+
67
+ const existingTrust = trustRank(episode.rewardSource);
68
+ const newTrust = trustRank(evidence.source);
69
+ if (existingTrust > newTrust) {
70
+ return {
71
+ resolution: "discarded_lower_trust",
72
+ note: `existing reward from ${episode.rewardSource} outranks ${evidence.source}`,
73
+ };
74
+ }
75
+
76
+ if (existingTrust === newTrust) {
77
+ if (episode.reward === evidence.value) {
78
+ return {
79
+ resolution: "discarded_duplicate",
80
+ note: "matching reward already present",
81
+ };
82
+ }
83
+
84
+ return {
85
+ resolution: "discarded_duplicate",
86
+ note: `existing ${episode.rewardSource} reward already present; equal-trust override is not applied automatically`,
87
+ };
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ export class BrainWorker {
94
+ private interval: ReturnType<typeof setInterval> | null = null;
95
+ private running = false;
96
+
97
+ constructor(
98
+ private store: BrainStore,
99
+ private graph: BrainGraph,
100
+ private teacher: BrainTeacher | null,
101
+ private mutator: BrainMutator,
102
+ private packManager: PackManager,
103
+ private config: BrainConfig,
104
+ private log: { info: (msg: string) => void; error: (msg: string) => void; warn: (msg: string) => void },
105
+ private hooks: {
106
+ isEnabled?: () => boolean;
107
+ onPromotionReady?: (params: { healthJson: string }) => Promise<void> | void;
108
+ onTickResult?: (params: { ok: boolean; at: number; error?: string }) => void;
109
+ } = {},
110
+ ) {}
111
+
112
+ start(): void {
113
+ if (this.interval || !this.config.enabled || this.hooks.isEnabled?.() === false) {
114
+ return;
115
+ }
116
+
117
+ this.interval = setInterval(() => {
118
+ void this.tick()
119
+ .then(() => {
120
+ this.hooks.onTickResult?.({ ok: true, at: Date.now() });
121
+ })
122
+ .catch((error) => {
123
+ const message = (error as Error).message;
124
+ this.log.error(`[brain] Worker tick failed: ${message}`);
125
+ this.hooks.onTickResult?.({ ok: false, at: Date.now(), error: message });
126
+ });
127
+ }, this.config.trainerIntervalMs);
128
+ this.log.info(`[brain] Worker started (interval=${this.config.trainerIntervalMs}ms)`);
129
+ }
130
+
131
+ stop(): void {
132
+ if (!this.interval) {
133
+ return;
134
+ }
135
+ clearInterval(this.interval);
136
+ this.interval = null;
137
+ this.log.info("[brain] Worker stopped");
138
+ }
139
+
140
+ async tick(): Promise<void> {
141
+ if (this.running) {
142
+ return;
143
+ }
144
+ if (this.hooks.isEnabled?.() === false) {
145
+ return;
146
+ }
147
+
148
+ this.running = true;
149
+ try {
150
+ this.store.setTrainingState("worker_last_tick_at", Date.now());
151
+ await this.processEvidence();
152
+ await this.processLabels();
153
+ await this.runTeacher();
154
+ await this.applyUpdates();
155
+ this.runDecay();
156
+ this.proposeMutations();
157
+ await this.checkPromotion();
158
+ } finally {
159
+ this.running = false;
160
+ }
161
+ }
162
+
163
+ private async processEvidence(): Promise<void> {
164
+ const pending = this.store.getPendingEvidence(100);
165
+ const candidatesByEpisode = new Map<string, { episode: Episode; evidence: BrainEvidence[] }>();
166
+
167
+ for (const evidence of pending) {
168
+ const episode = this.store.getEpisode(evidence.episodeId);
169
+ if (!episode) {
170
+ this.store.resolveEvidence({
171
+ evidenceId: evidence.id,
172
+ episodeId: evidence.episodeId,
173
+ source: evidence.source,
174
+ value: evidence.value,
175
+ confidence: evidence.confidence,
176
+ resolution: "discarded_missing_episode",
177
+ note: evidence.reason ?? "episode missing",
178
+ });
179
+ continue;
180
+ }
181
+
182
+ const episodeClassification = classifyEvidenceAgainstEpisode(episode, evidence);
183
+ if (episodeClassification) {
184
+ this.store.resolveEvidence({
185
+ evidenceId: evidence.id,
186
+ episodeId: episode.id,
187
+ source: evidence.source,
188
+ value: evidence.value,
189
+ confidence: evidence.confidence,
190
+ resolution: episodeClassification.resolution,
191
+ note: episodeClassification.note,
192
+ });
193
+ continue;
194
+ }
195
+
196
+ const staged = candidatesByEpisode.get(episode.id);
197
+ if (staged) {
198
+ staged.evidence.push(evidence);
199
+ } else {
200
+ candidatesByEpisode.set(episode.id, { episode, evidence: [evidence] });
201
+ }
202
+ }
203
+
204
+ for (const { episode, evidence } of candidatesByEpisode.values()) {
205
+ let winner: BrainEvidence | null = null;
206
+ const losers: Array<{ evidence: BrainEvidence; resolution: "discarded_lower_trust" | "discarded_duplicate"; note: string }> = [];
207
+
208
+ for (const candidate of evidence) {
209
+ if (!winner) {
210
+ winner = candidate;
211
+ continue;
212
+ }
213
+
214
+ if (compareEvidencePriority(candidate, winner) > 0) {
215
+ losers.push({
216
+ evidence: winner,
217
+ ...losingEvidenceResolution(candidate, winner),
218
+ });
219
+ winner = candidate;
220
+ continue;
221
+ }
222
+
223
+ losers.push({
224
+ evidence: candidate,
225
+ ...losingEvidenceResolution(winner, candidate),
226
+ });
227
+ }
228
+
229
+ for (const loser of losers) {
230
+ this.store.resolveEvidence({
231
+ evidenceId: loser.evidence.id,
232
+ episodeId: episode.id,
233
+ source: loser.evidence.source,
234
+ value: loser.evidence.value,
235
+ confidence: loser.evidence.confidence,
236
+ resolution: loser.resolution,
237
+ note: loser.note,
238
+ });
239
+ }
240
+
241
+ if (!winner) {
242
+ continue;
243
+ }
244
+
245
+ const label = this.store.insertLabel({
246
+ episodeId: episode.id,
247
+ source: winner.source,
248
+ value: winner.value,
249
+ confidence: winner.confidence,
250
+ reason: winner.reason ?? undefined,
251
+ });
252
+ this.store.resolveEvidence({
253
+ evidenceId: winner.id,
254
+ episodeId: episode.id,
255
+ source: winner.source,
256
+ value: winner.value,
257
+ confidence: winner.confidence,
258
+ resolution: "promoted_to_label",
259
+ labelId: label.id,
260
+ note: winner.kind,
261
+ });
262
+ }
263
+ }
264
+
265
+ private async processLabels(): Promise<void> {
266
+ const pending = this.store.getPendingLabels();
267
+ for (const label of pending) {
268
+ const episode = this.store.getEpisode(label.episodeId);
269
+ if (!episode) {
270
+ this.store.markLabelApplied(label.id);
271
+ continue;
272
+ }
273
+
274
+ if (episode.reward !== null && episode.rewardSource !== null) {
275
+ if (trustRank(episode.rewardSource) >= trustRank(label.source)) {
276
+ this.store.markLabelApplied(label.id);
277
+ continue;
278
+ }
279
+ }
280
+
281
+ this.store.setEpisodeReward(episode.id, label.value, label.source);
282
+ this.store.markLabelApplied(label.id);
283
+ }
284
+ }
285
+
286
+ private async runTeacher(): Promise<void> {
287
+ if (!this.teacher || !this.config.teacherEnabled) {
288
+ return;
289
+ }
290
+
291
+ const unlabeled = this.store.getUnlabeledEpisodes(3);
292
+ for (const episode of unlabeled) {
293
+ const { score, reason } = await this.teacher.evaluate(episode);
294
+ if (Math.abs(score) > 0.05) {
295
+ this.store.insertEvidence({
296
+ episodeId: episode.id,
297
+ conversationId: episode.conversationId,
298
+ source: "teacher",
299
+ kind: "teacher_review",
300
+ value: score,
301
+ confidence: 0.6,
302
+ reason,
303
+ contentSnippet: episode.queryText.slice(0, 240),
304
+ metadata: { queryText: episode.queryText },
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ private async applyUpdates(): Promise<void> {
311
+ const episodes = this.store.getEpisodesForUpdate(20);
312
+ if (episodes.length === 0) {
313
+ return;
314
+ }
315
+
316
+ const baselineStr = this.store.getTrainingState("baseline_reward");
317
+ let baseline = baselineStr ? Number.parseFloat(baselineStr) : 0;
318
+
319
+ for (const episode of episodes) {
320
+ if (episode.reward === null) {
321
+ continue;
322
+ }
323
+
324
+ const updates = computeReinforceUpdates(episode, this.config.learningRate, baseline);
325
+ applyWeightUpdates(this.graph, updates);
326
+
327
+ for (const update of updates) {
328
+ if (update.kind === "seed") {
329
+ this.store.setSeedWeight(update.nodeId, this.graph.getSeedWeight(update.nodeId));
330
+ continue;
331
+ }
332
+
333
+ const edge = this.graph.getEdge(update.source, update.target);
334
+ if (edge) {
335
+ this.store.updateEdgeWeight(edge.source, edge.target, edge.kind, edge.weight);
336
+ continue;
337
+ }
338
+
339
+ const now = Date.now();
340
+ const createdEdge: BrainEdge = {
341
+ source: update.source,
342
+ target: update.target,
343
+ kind: "learned",
344
+ weight: Math.max(-10, Math.min(10, update.delta)),
345
+ prior: 0.5,
346
+ metadata: { createdBy: "reinforce" },
347
+ decayedAt: now,
348
+ createdAt: now,
349
+ };
350
+ this.graph.addEdge(createdEdge);
351
+ this.store.insertEdge(createdEdge);
352
+ }
353
+
354
+ this.store.markEpisodeUpdated(episode.id);
355
+ baseline = updateBaseline(baseline, episode.reward, this.config.baselineAlpha);
356
+ }
357
+
358
+ this.store.setTrainingState("baseline_reward", baseline);
359
+ this.store.setTrainingState("last_update_at", Date.now());
360
+ }
361
+
362
+ private runDecay(): void {
363
+ const lastDecay = Number.parseInt(this.store.getTrainingState("last_decay_at") ?? "0", 10);
364
+ if (Date.now() - lastDecay < 60_000) {
365
+ return;
366
+ }
367
+
368
+ decayAllWeights(this.graph, this.config.decayRate, Date.now());
369
+ this.store.decayAllWeights(this.config.decayRate);
370
+ this.store.setTrainingState("last_decay_at", Date.now());
371
+ }
372
+
373
+ private proposeMutations(): void {
374
+ if (!this.config.mutationsEnabled) {
375
+ return;
376
+ }
377
+
378
+ const proposals = this.mutator.proposeMutations(this.store.getRecentEpisodes(50));
379
+ for (const proposal of proposals) {
380
+ this.store.insertMutation(proposal);
381
+ }
382
+ }
383
+
384
+ private async checkPromotion(): Promise<void> {
385
+ const recentEpisodes = this.store.getRecentEpisodes(this.config.replayEpisodeCount);
386
+ const pendingMutations = this.store.getMutationsByStatus("pending", 5);
387
+ const candidateGraph = this.graph.clone();
388
+ const candidateMutations = pendingMutations.filter((proposal) =>
389
+ proposal.kind === "connect" || proposal.kind === "prune" || proposal.kind === "inject",
390
+ );
391
+ for (const proposal of candidateMutations) {
392
+ this.mutator.applyToCandidateGraph(candidateGraph, proposal);
393
+ }
394
+
395
+ const gate = this.packManager.replayGate(recentEpisodes, {
396
+ minFiredPerQuery: this.config.minFiredPerQuery,
397
+ maxDormantPercent: this.config.maxDormantPercent,
398
+ maxOrphanCount: this.config.maxOrphanCount,
399
+ }, candidateGraph);
400
+
401
+ if (!gate.passed) {
402
+ this.store.setTrainingState("last_replay_failure_reason", gate.reason);
403
+ this.log.warn(`[brain] Replay gate blocked promotion: ${gate.reason}`);
404
+ for (const proposal of candidateMutations) {
405
+ this.store.resolveMutation(proposal.id, "rejected");
406
+ }
407
+ return;
408
+ }
409
+
410
+ for (const proposal of candidateMutations) {
411
+ this.mutator.applyMutation(proposal);
412
+ }
413
+ const health = computeHealth(this.graph, recentEpisodes, this.store.getCurrentPackVersion() ?? 0);
414
+ this.store.setTrainingState("last_promotion_reason", candidateMutations.length > 0
415
+ ? `candidate graph promoted with ${candidateMutations.length} mutation(s)`
416
+ : "weights and decay passed replay gate");
417
+ this.store.setTrainingState("last_replay_failure_reason", "");
418
+ await this.hooks.onPromotionReady?.({
419
+ healthJson: JSON.stringify(health),
420
+ });
421
+ }
422
+ }