@peerbit/pubsub 5.2.10 → 5.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 (67) hide show
  1. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +3 -0
  2. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +1 -0
  3. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +90 -0
  4. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +1 -0
  5. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +2 -0
  6. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +1 -0
  7. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +712 -0
  8. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +1 -0
  9. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +2 -0
  10. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +1 -0
  11. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +1591 -0
  12. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +1 -0
  13. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +2 -0
  14. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +1 -0
  15. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +297 -0
  16. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +1 -0
  17. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +73 -0
  18. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +1 -0
  19. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +183 -0
  20. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +1 -0
  21. package/dist/benchmark/fanout-tree-sim-lib.d.ts +101 -0
  22. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -1
  23. package/dist/benchmark/fanout-tree-sim-lib.js +540 -38
  24. package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -1
  25. package/dist/benchmark/fanout-tree-sim.d.ts +0 -9
  26. package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -1
  27. package/dist/benchmark/fanout-tree-sim.js +369 -28
  28. package/dist/benchmark/fanout-tree-sim.js.map +1 -1
  29. package/dist/benchmark/index.js +20 -0
  30. package/dist/benchmark/index.js.map +1 -1
  31. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +4 -0
  32. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -1
  33. package/dist/benchmark/pubsub-topic-sim-lib.js +58 -3
  34. package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -1
  35. package/dist/benchmark/pubsub-topic-sim.js +18 -0
  36. package/dist/benchmark/pubsub-topic-sim.js.map +1 -1
  37. package/dist/src/debounced-set.d.ts +1 -1
  38. package/dist/src/debounced-set.d.ts.map +1 -1
  39. package/dist/src/debounced-set.js +5 -1
  40. package/dist/src/debounced-set.js.map +1 -1
  41. package/dist/src/fanout-tree-codec.d.ts +330 -0
  42. package/dist/src/fanout-tree-codec.d.ts.map +1 -0
  43. package/dist/src/fanout-tree-codec.js +1209 -0
  44. package/dist/src/fanout-tree-codec.js.map +1 -0
  45. package/dist/src/fanout-tree-parent-upgrade.d.ts +117 -0
  46. package/dist/src/fanout-tree-parent-upgrade.d.ts.map +1 -0
  47. package/dist/src/fanout-tree-parent-upgrade.js +135 -0
  48. package/dist/src/fanout-tree-parent-upgrade.js.map +1 -0
  49. package/dist/src/fanout-tree.d.ts +241 -73
  50. package/dist/src/fanout-tree.d.ts.map +1 -1
  51. package/dist/src/fanout-tree.js +2224 -1050
  52. package/dist/src/fanout-tree.js.map +1 -1
  53. package/dist/src/index.d.ts +57 -0
  54. package/dist/src/index.d.ts.map +1 -1
  55. package/dist/src/index.js +293 -41
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/topic-root-control-plane.d.ts +13 -0
  58. package/dist/src/topic-root-control-plane.d.ts.map +1 -1
  59. package/dist/src/topic-root-control-plane.js +45 -0
  60. package/dist/src/topic-root-control-plane.js.map +1 -1
  61. package/package.json +6 -6
  62. package/src/debounced-set.ts +5 -0
  63. package/src/fanout-tree-codec.ts +1580 -0
  64. package/src/fanout-tree-parent-upgrade.ts +353 -0
  65. package/src/fanout-tree.ts +4467 -2204
  66. package/src/index.ts +392 -56
  67. package/src/topic-root-control-plane.ts +48 -0
@@ -0,0 +1,1591 @@
1
+ import { PreHash, SignatureWithKey } from "@peerbit/crypto";
2
+ import { InMemoryNetwork, InMemorySession, } from "@peerbit/libp2p-test-utils/inmemory-libp2p.js";
3
+ import { delay } from "@peerbit/time";
4
+ import { anySignal } from "any-signal";
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import { FanoutTree } from "../src/index.js";
8
+ import { DEFAULT_PARENT_UPGRADE_SEEDS, PARENT_UPGRADE_HELP_TEXT, avgFinite, defaultEvidenceLimitsForPreset, fmt, maxFinite, parentUpgradeRuntimeOptions, parseBool01, parseCsvNumbers, parseParentUpgradePresetConfig, ratioLimit, } from "./fanout-tree-parent-upgrade-preset.js";
9
+ import { int, mulberry32, quantile, runWithConcurrency, } from "./sim/bench-utils.js";
10
+ /**
11
+ * Shared-network A/B evidence for proactive parent upgrades.
12
+ *
13
+ * The single-tree simulator is intentionally kept focused on one root/topic. This
14
+ * harness creates several writer roots and topics inside one in-memory network so
15
+ * probes, root reservations, tracker state, subscriber timers, and publish loops
16
+ * contend in the same process. That is the default-readiness risk this PR needs
17
+ * to measure: a policy can be quiet for one writer while still multiplying
18
+ * control-plane pressure across many independent writer trees.
19
+ */
20
+ class SimFanoutTree extends FanoutTree {
21
+ constructor(c, opts) {
22
+ super(c, opts);
23
+ this.sign = async () => new SignatureWithKey({
24
+ signature: new Uint8Array([0]),
25
+ publicKey: this.publicKey,
26
+ prehash: PreHash.NONE,
27
+ });
28
+ }
29
+ async verifyAndProcess(message) {
30
+ const from = message.header.signatures.publicKeys[0];
31
+ if (!this.peers.has(from.hashcode())) {
32
+ this.updateSession(from, Number(message.header.session));
33
+ }
34
+ return true;
35
+ }
36
+ }
37
+ const effectiveMaxChildrenForUpload = (params, ch, maxChildren) => {
38
+ const requested = Math.max(0, Math.floor(maxChildren));
39
+ const uploadLimitBps = Math.max(0, Math.floor(ch.uploadLimitBps ?? 0));
40
+ if (uploadLimitBps <= 0)
41
+ return 0;
42
+ const msgRate = Math.max(1, Math.floor(params.msgRate));
43
+ const msgSize = Math.max(1, Math.floor(params.msgSize));
44
+ const uploadOverheadBytes = Math.max(0, Math.floor(ch.uploadOverheadBytes ?? 128));
45
+ const perChildBytes = Math.max(1, 1 + msgSize + uploadOverheadBytes);
46
+ const perChildBps = Math.max(1, Math.floor(msgRate * perChildBytes));
47
+ return Math.max(0, Math.min(requested, Math.floor(uploadLimitBps / perChildBps)));
48
+ };
49
+ const SCENARIOS = {
50
+ "ci-multi-live": {
51
+ scenario: "ci-multi-live",
52
+ nodes: 40,
53
+ writers: 4,
54
+ bootstraps: 1,
55
+ subscribersPerTree: 28,
56
+ relayFraction: 0.5,
57
+ joinConcurrency: 12,
58
+ joinPhaseSettleMs: 500,
59
+ messages: 120,
60
+ secondBatchMessages: 0,
61
+ secondBatchSettleMs: 0,
62
+ msgRate: 60,
63
+ msgSize: 192,
64
+ settleMs: 1_000,
65
+ deadlineMs: 750,
66
+ timeoutMs: 120_000,
67
+ rootUploadLimitBps: 100_000_000,
68
+ rootMaxChildren: 2,
69
+ relayUploadLimitBps: 100_000_000,
70
+ relayMaxChildren: 4,
71
+ repair: true,
72
+ repairWindowMessages: 512,
73
+ repairIntervalMs: 200,
74
+ repairMaxPerReq: 64,
75
+ neighborRepair: true,
76
+ neighborRepairPeers: 3,
77
+ streamRxDelayMs: 2,
78
+ streamHighWaterMarkBytes: 256 * 1024,
79
+ dialDelayMs: 0,
80
+ candidateScoringMode: "weighted",
81
+ trackerQueryIntervalMs: 1_000,
82
+ lateRootConnectAfterMs: 700,
83
+ lateRootDuringPublish: true,
84
+ lateRootMaxChildren: 12,
85
+ lateRootConnectFraction: 0.5,
86
+ churnEveryMs: 0,
87
+ churnDownMs: 0,
88
+ churnFraction: 0,
89
+ },
90
+ "ci-multi-live-churn": {
91
+ scenario: "ci-multi-live-churn",
92
+ nodes: 60,
93
+ writers: 6,
94
+ bootstraps: 1,
95
+ subscribersPerTree: 42,
96
+ relayFraction: 0.5,
97
+ joinConcurrency: 12,
98
+ joinPhaseSettleMs: 500,
99
+ messages: 120,
100
+ secondBatchMessages: 0,
101
+ secondBatchSettleMs: 0,
102
+ msgRate: 60,
103
+ msgSize: 192,
104
+ settleMs: 1_000,
105
+ deadlineMs: 750,
106
+ timeoutMs: 150_000,
107
+ rootUploadLimitBps: 100_000_000,
108
+ rootMaxChildren: 2,
109
+ relayUploadLimitBps: 100_000_000,
110
+ relayMaxChildren: 4,
111
+ repair: true,
112
+ repairWindowMessages: 512,
113
+ repairIntervalMs: 200,
114
+ repairMaxPerReq: 64,
115
+ neighborRepair: true,
116
+ neighborRepairPeers: 3,
117
+ streamRxDelayMs: 2,
118
+ streamHighWaterMarkBytes: 256 * 1024,
119
+ dialDelayMs: 0,
120
+ candidateScoringMode: "weighted",
121
+ trackerQueryIntervalMs: 1_000,
122
+ lateRootConnectAfterMs: 700,
123
+ lateRootDuringPublish: true,
124
+ lateRootMaxChildren: 16,
125
+ lateRootConnectFraction: 0.5,
126
+ churnEveryMs: 500,
127
+ churnDownMs: 150,
128
+ churnFraction: 0.03,
129
+ },
130
+ "ci-multi-video-live": {
131
+ scenario: "ci-multi-video-live",
132
+ nodes: 48,
133
+ writers: 4,
134
+ bootstraps: 1,
135
+ subscribersPerTree: 32,
136
+ relayFraction: 0.5,
137
+ joinConcurrency: 12,
138
+ joinPhaseSettleMs: 500,
139
+ messages: 80,
140
+ secondBatchMessages: 0,
141
+ secondBatchSettleMs: 0,
142
+ msgRate: 24,
143
+ msgSize: 1200,
144
+ settleMs: 1_000,
145
+ deadlineMs: 1_500,
146
+ timeoutMs: 150_000,
147
+ rootUploadLimitBps: 150_000,
148
+ rootMaxChildren: 2,
149
+ relayUploadLimitBps: 150_000,
150
+ relayMaxChildren: 3,
151
+ repair: true,
152
+ repairWindowMessages: 512,
153
+ repairIntervalMs: 200,
154
+ repairMaxPerReq: 64,
155
+ neighborRepair: true,
156
+ neighborRepairPeers: 3,
157
+ streamRxDelayMs: 4,
158
+ streamHighWaterMarkBytes: 512 * 1024,
159
+ dialDelayMs: 0,
160
+ candidateScoringMode: "weighted",
161
+ trackerQueryIntervalMs: 1_000,
162
+ lateRootConnectAfterMs: 700,
163
+ lateRootDuringPublish: true,
164
+ lateRootMaxChildren: 6,
165
+ lateRootConnectFraction: 0.5,
166
+ churnEveryMs: 0,
167
+ churnDownMs: 0,
168
+ churnFraction: 0,
169
+ },
170
+ "ci-multi-idle": {
171
+ scenario: "ci-multi-idle",
172
+ nodes: 40,
173
+ writers: 4,
174
+ activeWriters: 4,
175
+ bootstraps: 1,
176
+ subscribersPerTree: 28,
177
+ relayFraction: 0.5,
178
+ joinConcurrency: 12,
179
+ joinPhaseSettleMs: 500,
180
+ messages: 12,
181
+ secondBatchMessages: 48,
182
+ secondBatchSettleMs: 1_000,
183
+ msgRate: 50,
184
+ msgSize: 96,
185
+ settleMs: 9_000,
186
+ deadlineMs: 750,
187
+ timeoutMs: 140_000,
188
+ rootUploadLimitBps: 100_000_000,
189
+ rootMaxChildren: 1,
190
+ relayUploadLimitBps: 100_000_000,
191
+ relayMaxChildren: 4,
192
+ repair: true,
193
+ repairWindowMessages: 512,
194
+ repairIntervalMs: 200,
195
+ repairMaxPerReq: 64,
196
+ neighborRepair: true,
197
+ neighborRepairPeers: 3,
198
+ streamRxDelayMs: 3,
199
+ streamHighWaterMarkBytes: 256 * 1024,
200
+ dialDelayMs: 0,
201
+ candidateScoringMode: "weighted",
202
+ trackerQueryIntervalMs: 1_000,
203
+ lateRootConnectAfterMs: 1_000,
204
+ lateRootDuringPublish: false,
205
+ lateRootMaxChildren: 10,
206
+ lateRootConnectFraction: 0.75,
207
+ churnEveryMs: 0,
208
+ churnDownMs: 0,
209
+ churnFraction: 0,
210
+ },
211
+ "ci-multi-sparse-idle": {
212
+ scenario: "ci-multi-sparse-idle",
213
+ nodes: 72,
214
+ writers: 12,
215
+ activeWriters: 4,
216
+ bootstraps: 1,
217
+ subscribersPerTree: 36,
218
+ relayFraction: 0.5,
219
+ joinConcurrency: 12,
220
+ joinPhaseSettleMs: 500,
221
+ messages: 12,
222
+ secondBatchMessages: 48,
223
+ secondBatchSettleMs: 1_000,
224
+ msgRate: 50,
225
+ msgSize: 96,
226
+ settleMs: 9_000,
227
+ deadlineMs: 750,
228
+ timeoutMs: 180_000,
229
+ rootUploadLimitBps: 100_000_000,
230
+ rootMaxChildren: 1,
231
+ relayUploadLimitBps: 100_000_000,
232
+ relayMaxChildren: 4,
233
+ repair: true,
234
+ repairWindowMessages: 512,
235
+ repairIntervalMs: 200,
236
+ repairMaxPerReq: 64,
237
+ neighborRepair: true,
238
+ neighborRepairPeers: 3,
239
+ streamRxDelayMs: 3,
240
+ streamHighWaterMarkBytes: 256 * 1024,
241
+ dialDelayMs: 0,
242
+ candidateScoringMode: "weighted",
243
+ trackerQueryIntervalMs: 1_000,
244
+ lateRootConnectAfterMs: 1_000,
245
+ lateRootDuringPublish: false,
246
+ lateRootMaxChildren: 10,
247
+ lateRootConnectFraction: 0.75,
248
+ churnEveryMs: 0,
249
+ churnDownMs: 0,
250
+ churnFraction: 0,
251
+ },
252
+ "ci-multi-hotspot-idle": {
253
+ scenario: "ci-multi-hotspot-idle",
254
+ nodes: 64,
255
+ writers: 4,
256
+ activeWriters: 4,
257
+ bootstraps: 1,
258
+ subscribersPerTree: 48,
259
+ relayFraction: 0.5,
260
+ joinConcurrency: 12,
261
+ joinPhaseSettleMs: 500,
262
+ messages: 12,
263
+ secondBatchMessages: 48,
264
+ secondBatchSettleMs: 1_000,
265
+ msgRate: 50,
266
+ msgSize: 96,
267
+ settleMs: 9_000,
268
+ deadlineMs: 750,
269
+ timeoutMs: 180_000,
270
+ rootUploadLimitBps: 100_000_000,
271
+ rootMaxChildren: 1,
272
+ relayUploadLimitBps: 100_000_000,
273
+ relayMaxChildren: 4,
274
+ repair: true,
275
+ repairWindowMessages: 512,
276
+ repairIntervalMs: 200,
277
+ repairMaxPerReq: 64,
278
+ neighborRepair: true,
279
+ neighborRepairPeers: 3,
280
+ streamRxDelayMs: 3,
281
+ streamHighWaterMarkBytes: 256 * 1024,
282
+ dialDelayMs: 0,
283
+ candidateScoringMode: "weighted",
284
+ trackerQueryIntervalMs: 1_000,
285
+ lateRootConnectAfterMs: 1_000,
286
+ lateRootDuringPublish: false,
287
+ lateRootMaxChildren: 12,
288
+ lateRootConnectFraction: 1,
289
+ churnEveryMs: 0,
290
+ churnDownMs: 0,
291
+ churnFraction: 0,
292
+ },
293
+ };
294
+ const HELP_TEXT = [
295
+ "fanout-tree-parent-upgrade-multi-eval.ts",
296
+ "",
297
+ "Options:",
298
+ " --scenario NAME ci-multi-live|ci-multi-live-churn|ci-multi-video-live|ci-multi-idle|ci-multi-sparse-idle|ci-multi-hotspot-idle|all (default: all)",
299
+ " --seeds CSV seeds to run for each scenario (default: 1,2,3)",
300
+ ...PARENT_UPGRADE_HELP_TEXT,
301
+ " --maxLiveChurnGuardSkipsPerSlot N max active guard skips per subscriber slot for ci-multi-live-churn (default: 1)",
302
+ " --nodes N override scenario node count",
303
+ " --writers N override scenario writer/root count",
304
+ " --activeWriters N override active publisher count; remaining joined writer trees stay idle",
305
+ " --subscribersPerTree N override scenario subscriber slots per writer",
306
+ " --secondBatchSettleMs MS override settle after the normal second batch",
307
+ " --thirdBatchMessages N publish an extra post-learning batch after second-batch settle (default: scenario value, usually 0)",
308
+ " --thirdBatchSettleMs MS settle after the extra post-learning batch (default: secondBatchSettleMs)",
309
+ " --timeoutMs MS override scenario timeout",
310
+ " --jsonOut FILE write compact JSON evidence summary",
311
+ " --maxUsefulIdleDeadlinePctDelta PCT max deadline pct jitter for idle runs with useful promotions (default: 0.15)",
312
+ " --maxGuardedIdleDeadlinePctDelta PCT max deadline pct jitter for low-probe idle guard trials (default: 0.15)",
313
+ " --maxGuardedIdleProbePerSlotForSlack R max probe/subscriber slot for guarded idle deadline slack (default: 0.02)",
314
+ " --maxSecondBatchLatencyP95DeltaMs N max idle second-batch p95 latency jitter tolerated (default: 3)",
315
+ " --maxSecondBatchLatencyP95DeltaRatio R max idle second-batch p95 latency relative jitter tolerated (default: 0.15)",
316
+ " --maxDataOverheadRatio R max treatment/base data payload overhead factor ratio (default: 1.05)",
317
+ " --minPromotedBranchGainMs MS min branch/second-batch p95 gain for promoted trees (default: 10)",
318
+ " --minPromotedTreeLevelAvgGain N min avg tree-level gain for promoted trees (default: 0.1)",
319
+ " --sameRunAB 0|1 run baseline and treatment concurrently for idle scenarios (default: 1 for default-candidate, otherwise 0)",
320
+ " --strict 0|1 exit non-zero on evidence failure (default: 0)",
321
+ "",
322
+ "Examples:",
323
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-live --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 1",
324
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-live-churn --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 1",
325
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-video-live --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 1",
326
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-idle --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 1",
327
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-sparse-idle --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 1",
328
+ " pnpm -C packages/transport/pubsub run bench -- fanout-tree-parent-upgrade-multi-eval --scenario ci-multi-hotspot-idle --seeds 1,2,3 --parentUpgradePreset default-candidate --strict 0",
329
+ ].join("\n");
330
+ const usage = () => {
331
+ console.log(HELP_TEXT);
332
+ };
333
+ const parseScenarios = (value) => {
334
+ if (!value || value === "all") {
335
+ return [
336
+ "ci-multi-live",
337
+ "ci-multi-live-churn",
338
+ "ci-multi-video-live",
339
+ "ci-multi-idle",
340
+ "ci-multi-sparse-idle",
341
+ "ci-multi-hotspot-idle",
342
+ ];
343
+ }
344
+ const scenarios = value.split(",").map((part) => part.trim());
345
+ for (const scenario of scenarios) {
346
+ if (scenario !== "ci-multi-live" &&
347
+ scenario !== "ci-multi-live-churn" &&
348
+ scenario !== "ci-multi-video-live" &&
349
+ scenario !== "ci-multi-idle" &&
350
+ scenario !== "ci-multi-sparse-idle" &&
351
+ scenario !== "ci-multi-hotspot-idle") {
352
+ throw new Error(`Unknown scenario: ${scenario}`);
353
+ }
354
+ }
355
+ return scenarios;
356
+ };
357
+ const isLiveScenario = (scenario) => scenario === "ci-multi-live" ||
358
+ scenario === "ci-multi-live-churn" ||
359
+ scenario === "ci-multi-video-live";
360
+ const isLiveChurnScenario = (scenario) => scenario === "ci-multi-live-churn";
361
+ const isPositiveIdleScenario = (scenario) => scenario === "ci-multi-idle";
362
+ const isHotspotIdleScenario = (scenario) => scenario === "ci-multi-hotspot-idle";
363
+ const isSparseIdleScenario = (scenario) => scenario === "ci-multi-sparse-idle";
364
+ const isIdleScenario = (scenario) => isPositiveIdleScenario(scenario) ||
365
+ isHotspotIdleScenario(scenario) ||
366
+ isSparseIdleScenario(scenario);
367
+ const parseArgs = (argv) => {
368
+ const get = (name) => {
369
+ const i = argv.indexOf(name);
370
+ return i >= 0 ? argv[i + 1] : undefined;
371
+ };
372
+ if (argv.includes("--help") || argv.includes("-h")) {
373
+ usage();
374
+ process.exit(0);
375
+ }
376
+ const parentUpgradeConfig = parseParentUpgradePresetConfig(get);
377
+ const defaultCandidate = parentUpgradeConfig.parentUpgradePreset === "default-candidate";
378
+ const evidenceDefaults = defaultEvidenceLimitsForPreset(parentUpgradeConfig.parentUpgradePreset, "multi");
379
+ return {
380
+ scenarios: parseScenarios(get("--scenario")),
381
+ seeds: parseCsvNumbers(get("--seeds"), DEFAULT_PARENT_UPGRADE_SEEDS),
382
+ ...parentUpgradeConfig,
383
+ nodes: get("--nodes") == null ? undefined : Number(get("--nodes")),
384
+ writers: get("--writers") == null ? undefined : Number(get("--writers")),
385
+ activeWriters: get("--activeWriters") == null
386
+ ? undefined
387
+ : Number(get("--activeWriters")),
388
+ subscribersPerTree: get("--subscribersPerTree") == null
389
+ ? undefined
390
+ : Number(get("--subscribersPerTree")),
391
+ secondBatchSettleMs: get("--secondBatchSettleMs") == null
392
+ ? undefined
393
+ : Number(get("--secondBatchSettleMs")),
394
+ thirdBatchMessages: get("--thirdBatchMessages") == null
395
+ ? undefined
396
+ : Number(get("--thirdBatchMessages")),
397
+ thirdBatchSettleMs: get("--thirdBatchSettleMs") == null
398
+ ? undefined
399
+ : Number(get("--thirdBatchSettleMs")),
400
+ streamRxDelayMs: get("--streamRxDelayMs") == null
401
+ ? undefined
402
+ : Number(get("--streamRxDelayMs")),
403
+ timeoutMs: get("--timeoutMs") == null ? undefined : Number(get("--timeoutMs")),
404
+ jsonOut: get("--jsonOut"),
405
+ maxCostRatio: Number(get("--maxCostRatio") ?? 1.15),
406
+ maxLiveDeadlinePctDelta: Number(get("--maxLiveDeadlinePctDelta") ?? 2),
407
+ maxUsefulIdleDeadlinePctDelta: Number(get("--maxUsefulIdleDeadlinePctDelta") ?? 0.15),
408
+ maxGuardedIdleDeadlinePctDelta: Number(get("--maxGuardedIdleDeadlinePctDelta") ?? 0.15),
409
+ maxGuardedIdleProbePerSlotForSlack: Number(get("--maxGuardedIdleProbePerSlotForSlack") ?? 0.02),
410
+ maxLiveChurnGuardSkipsPerSlot: Number(get("--maxLiveChurnGuardSkipsPerSlot") ?? 1),
411
+ maxSecondBatchLatencyP95DeltaMs: Number(get("--maxSecondBatchLatencyP95DeltaMs") ?? 3),
412
+ maxSecondBatchLatencyP95DeltaRatio: Number(get("--maxSecondBatchLatencyP95DeltaRatio") ?? 0.15),
413
+ maxDataOverheadRatio: Number(get("--maxDataOverheadRatio") ?? 1.05),
414
+ maxProbePerUpgrade: Number(get("--maxProbePerUpgrade") ?? evidenceDefaults.maxProbePerUpgrade),
415
+ maxRootChildrenDelta: Number(get("--maxRootChildrenDelta") ?? evidenceDefaults.maxRootChildrenDelta),
416
+ maxRootUploadPctDelta: Number(get("--maxRootUploadPctDelta") ?? evidenceDefaults.maxRootUploadPctDelta),
417
+ minPromotedBranchGainMs: Number(get("--minPromotedBranchGainMs") ?? 10),
418
+ minPromotedTreeLevelAvgGain: Number(get("--minPromotedTreeLevelAvgGain") ?? 0.1),
419
+ sameRunAB: parseBool01(get("--sameRunAB"), defaultCandidate),
420
+ strict: parseBool01(get("--strict"), false),
421
+ };
422
+ };
423
+ const resolveParams = (scenario, seed, args, upgradeEnabled) => {
424
+ const base = SCENARIOS[scenario];
425
+ const msgRate = Number(base.msgRate ?? 30);
426
+ const writers = Number(args.writers ?? base.writers ?? 4);
427
+ const activeWriters = Math.max(0, Math.min(writers, Math.floor(Number(args.activeWriters ?? base.activeWriters ?? writers))));
428
+ return {
429
+ scenario,
430
+ seed,
431
+ nodes: Number(args.nodes ?? base.nodes ?? 40),
432
+ writers,
433
+ activeWriters,
434
+ bootstraps: Number(base.bootstraps ?? 1),
435
+ subscribersPerTree: Number(args.subscribersPerTree ?? base.subscribersPerTree ?? 28),
436
+ relayFraction: Number(base.relayFraction ?? 0.5),
437
+ joinConcurrency: Number(base.joinConcurrency ?? 12),
438
+ joinPhaseSettleMs: Number(base.joinPhaseSettleMs ?? 500),
439
+ messages: Number(base.messages ?? 120),
440
+ secondBatchMessages: Number(base.secondBatchMessages ?? 0),
441
+ secondBatchSettleMs: Number(args.secondBatchSettleMs ?? base.secondBatchSettleMs ?? 0),
442
+ thirdBatchMessages: Number(args.thirdBatchMessages ?? 0),
443
+ thirdBatchSettleMs: Number(args.thirdBatchSettleMs ?? base.secondBatchSettleMs ?? 0),
444
+ msgRate,
445
+ msgSize: Number(base.msgSize ?? 128),
446
+ intervalMs: msgRate > 0 ? Math.floor(1000 / msgRate) : 0,
447
+ settleMs: Number(base.settleMs ?? 1_000),
448
+ deadlineMs: Number(base.deadlineMs ?? 750),
449
+ timeoutMs: Number(args.timeoutMs ?? base.timeoutMs ?? 120_000),
450
+ topicPrefix: `multi-${scenario}-${seed}`,
451
+ rootUploadLimitBps: Number(base.rootUploadLimitBps ?? 100_000_000),
452
+ rootMaxChildren: Number(base.rootMaxChildren ?? 2),
453
+ relayUploadLimitBps: Number(base.relayUploadLimitBps ?? 100_000_000),
454
+ relayMaxChildren: Number(base.relayMaxChildren ?? 4),
455
+ repair: Boolean(base.repair ?? true),
456
+ repairWindowMessages: Number(base.repairWindowMessages ?? 512),
457
+ repairIntervalMs: Number(base.repairIntervalMs ?? 200),
458
+ repairMaxPerReq: Number(base.repairMaxPerReq ?? 64),
459
+ neighborRepair: Boolean(base.neighborRepair ?? true),
460
+ neighborRepairPeers: Number(base.neighborRepairPeers ?? 3),
461
+ streamRxDelayMs: Number(args.streamRxDelayMs ?? base.streamRxDelayMs ?? 0),
462
+ streamHighWaterMarkBytes: Number(base.streamHighWaterMarkBytes ?? 256 * 1024),
463
+ dialDelayMs: Number(base.dialDelayMs ?? 0),
464
+ candidateScoringMode: base.candidateScoringMode === "ranked-strict" ||
465
+ base.candidateScoringMode === "weighted" ||
466
+ base.candidateScoringMode === "ranked-shuffle"
467
+ ? base.candidateScoringMode
468
+ : "weighted",
469
+ trackerQueryIntervalMs: Number(base.trackerQueryIntervalMs ?? 1_000),
470
+ ...parentUpgradeRuntimeOptions(args, upgradeEnabled),
471
+ lateRootConnectAfterMs: Number(base.lateRootConnectAfterMs ?? -1),
472
+ lateRootDuringPublish: Boolean(base.lateRootDuringPublish ?? false),
473
+ lateRootMaxChildren: Number(base.lateRootMaxChildren ?? 0),
474
+ lateRootConnectFraction: Number(base.lateRootConnectFraction ?? 1),
475
+ churnEveryMs: Number(base.churnEveryMs ?? 0),
476
+ churnDownMs: Number(base.churnDownMs ?? 0),
477
+ churnFraction: Number(base.churnFraction ?? 0),
478
+ };
479
+ };
480
+ const parseSimPeerIndex = (peerId) => {
481
+ const s = String(peerId?.toString?.() ?? "");
482
+ const m = s.match(/sim-(\d+)/);
483
+ if (!m)
484
+ return 0;
485
+ const n = Number(m[1]);
486
+ return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
487
+ };
488
+ const pickDistinct = (rng, values, count) => {
489
+ const target = Math.max(0, Math.min(values.length, Math.floor(count)));
490
+ const chosen = new Set();
491
+ while (chosen.size < target) {
492
+ chosen.add(values[int(rng, values.length)]);
493
+ }
494
+ return [...chosen];
495
+ };
496
+ const failIfGreater = (failures, metric, baseline, upgrade, limit) => {
497
+ if (!Number.isFinite(upgrade) || upgrade > limit) {
498
+ failures.push({ metric, baseline, upgrade, limit });
499
+ }
500
+ };
501
+ const failIfLess = (failures, metric, baseline, upgrade, limit) => {
502
+ if (!Number.isFinite(upgrade) || upgrade < limit) {
503
+ failures.push({ metric, baseline, upgrade, limit });
504
+ }
505
+ };
506
+ const peerLatencyP95For = (result, hashes) => {
507
+ const values = [];
508
+ for (const hash of hashes) {
509
+ const value = result.secondBatchLatencyP95ByHash[hash];
510
+ if (Number.isFinite(value))
511
+ values.push(value);
512
+ }
513
+ values.sort((a, b) => a - b);
514
+ return quantile(values, 0.95);
515
+ };
516
+ const secondBatchLatencySlackMs = (baseline, args) => Number.isFinite(baseline)
517
+ ? Math.max(0, args.maxSecondBatchLatencyP95DeltaMs, baseline * Math.max(0, args.maxSecondBatchLatencyP95DeltaRatio))
518
+ : Math.max(0, args.maxSecondBatchLatencyP95DeltaMs);
519
+ const runMultiWriterSim = async (params) => {
520
+ const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
521
+ const timeoutController = new AbortController();
522
+ const timeoutSignal = timeoutController.signal;
523
+ let timer;
524
+ if (timeoutMs > 0) {
525
+ timer = setTimeout(() => {
526
+ timeoutController.abort(new Error(`fanout-tree-parent-upgrade-multi-eval timed out after ${timeoutMs}ms`));
527
+ }, timeoutMs);
528
+ }
529
+ const rng = mulberry32(params.seed);
530
+ const rootIndices = Array.from({ length: params.writers }, (_, i) => i);
531
+ const bootstrapIndices = Array.from({ length: params.bootstraps }, (_, i) => params.writers + i).filter((i) => i < params.nodes);
532
+ const reserved = new Set([...rootIndices, ...bootstrapIndices]);
533
+ const subscriberPool = Array.from({ length: params.nodes }, (_, i) => i).filter((i) => !reserved.has(i));
534
+ const subscriberIndices = pickDistinct(rng, subscriberPool, params.subscribersPerTree).sort((a, b) => a - b);
535
+ const relayCount = Math.max(1, Math.floor(subscriberIndices.length * params.relayFraction));
536
+ const relaySet = new Set(pickDistinct(rng, subscriberIndices, relayCount));
537
+ const bootstrapIndexSet = new Set(bootstrapIndices);
538
+ const rootIndexSet = new Set(rootIndices);
539
+ const network = new InMemoryNetwork({
540
+ streamRxDelayMs: params.streamRxDelayMs,
541
+ streamHighWaterMarkBytes: params.streamHighWaterMarkBytes,
542
+ dialDelayMs: params.dialDelayMs,
543
+ dropSeed: params.seed,
544
+ });
545
+ const maxConnectionsFor = (index) => {
546
+ if (rootIndexSet.has(index)) {
547
+ return Math.max(256, params.lateRootMaxChildren * params.writers * 8);
548
+ }
549
+ if (bootstrapIndexSet.has(index))
550
+ return Math.max(512, params.nodes * 8);
551
+ if (relaySet.has(index)) {
552
+ return Math.max(128, params.relayMaxChildren * params.writers * 4);
553
+ }
554
+ return Math.max(32, params.writers * 8);
555
+ };
556
+ const session = await InMemorySession.disconnected(params.nodes, {
557
+ network,
558
+ basePort: 31_000,
559
+ services: {
560
+ fanout: (c) => {
561
+ const index = parseSimPeerIndex(c?.peerId);
562
+ return new SimFanoutTree(c, {
563
+ connectionManager: {
564
+ minConnections: 0,
565
+ maxConnections: maxConnectionsFor(index),
566
+ dialer: false,
567
+ pruner: { interval: 1_000 },
568
+ },
569
+ seenCacheMax: rootIndexSet.has(index)
570
+ ? 200_000
571
+ : bootstrapIndexSet.has(index)
572
+ ? 100_000
573
+ : relaySet.has(index)
574
+ ? 50_000
575
+ : 20_000,
576
+ seenCacheTtlMs: rootIndexSet.has(index) || bootstrapIndexSet.has(index)
577
+ ? 120_000
578
+ : 60_000,
579
+ random: mulberry32((params.seed >>> 0) ^ index),
580
+ });
581
+ },
582
+ },
583
+ });
584
+ try {
585
+ const bootstrapAddrs = bootstrapIndices.flatMap((i) => session.peers[i].getMultiaddrs());
586
+ if (bootstrapAddrs.length === 0) {
587
+ throw new Error("No bootstrap addrs; scenario needs bootstraps >= 1");
588
+ }
589
+ for (const p of session.peers) {
590
+ p.services.fanout.setBootstraps(bootstrapAddrs);
591
+ }
592
+ const trees = rootIndices.map((rootIndex, tree) => {
593
+ const root = session.peers[rootIndex].services.fanout;
594
+ const rootHash = root.publicKeyHash;
595
+ const topic = `${params.topicPrefix}-${tree}`;
596
+ root.openChannel(topic, rootHash, {
597
+ role: "root",
598
+ msgRate: params.msgRate,
599
+ msgSize: params.msgSize,
600
+ uploadLimitBps: params.rootUploadLimitBps,
601
+ maxChildren: params.rootMaxChildren,
602
+ repair: params.repair,
603
+ repairWindowMessages: params.repairWindowMessages,
604
+ repairIntervalMs: params.repairIntervalMs,
605
+ repairMaxPerReq: params.repairMaxPerReq,
606
+ neighborRepair: params.neighborRepair,
607
+ neighborRepairPeers: params.neighborRepairPeers,
608
+ });
609
+ return {
610
+ tree,
611
+ active: tree < params.activeWriters,
612
+ rootIndex,
613
+ root,
614
+ rootHash,
615
+ topic,
616
+ joinedIndices: new Set(),
617
+ };
618
+ });
619
+ const activeTrees = trees.filter((tree) => tree.active);
620
+ const joinOne = async (tree, index) => {
621
+ const node = session.peers[index].services.fanout;
622
+ const isRelay = relaySet.has(index);
623
+ try {
624
+ await node.joinChannel(tree.topic, tree.rootHash, {
625
+ msgRate: params.msgRate,
626
+ msgSize: params.msgSize,
627
+ uploadLimitBps: isRelay ? params.relayUploadLimitBps : 0,
628
+ maxChildren: isRelay ? params.relayMaxChildren : 0,
629
+ repair: params.repair,
630
+ repairWindowMessages: params.repairWindowMessages,
631
+ repairIntervalMs: params.repairIntervalMs,
632
+ repairMaxPerReq: params.repairMaxPerReq,
633
+ neighborRepair: params.neighborRepair,
634
+ neighborRepairPeers: params.neighborRepairPeers,
635
+ }, {
636
+ timeoutMs: Math.max(10_000, Math.min(120_000, timeoutMs || 120_000)),
637
+ candidateScoringMode: params.candidateScoringMode,
638
+ trackerQueryIntervalMs: params.trackerQueryIntervalMs,
639
+ ...parentUpgradeRuntimeOptions(params),
640
+ signal: timeoutSignal,
641
+ });
642
+ tree.joinedIndices.add(index);
643
+ return true;
644
+ }
645
+ catch {
646
+ return false;
647
+ }
648
+ };
649
+ const relayTasks = [];
650
+ const leafTasks = [];
651
+ for (const tree of trees) {
652
+ for (const index of subscriberIndices) {
653
+ const task = () => joinOne(tree, index);
654
+ if (relaySet.has(index))
655
+ relayTasks.push(task);
656
+ else
657
+ leafTasks.push(task);
658
+ }
659
+ }
660
+ await runWithConcurrency(relayTasks, params.joinConcurrency);
661
+ if (params.joinPhaseSettleMs > 0) {
662
+ await delay(params.joinPhaseSettleMs, { signal: timeoutSignal });
663
+ }
664
+ await runWithConcurrency(leafTasks, params.joinConcurrency);
665
+ const computeTreeShape = (tree) => {
666
+ const levels = [];
667
+ const childrenByHash = new Map();
668
+ let treeRootChildren = 0;
669
+ let treeOrphans = 0;
670
+ for (const p of session.peers) {
671
+ const hash = p.services.fanout.publicKeyHash;
672
+ const s = p.services.fanout.getChannelStats(tree.topic, tree.rootHash);
673
+ if (!s)
674
+ continue;
675
+ if (!childrenByHash.has(hash))
676
+ childrenByHash.set(hash, []);
677
+ if (Number.isFinite(s.level))
678
+ levels.push(s.level);
679
+ if (s.parent) {
680
+ const children = childrenByHash.get(s.parent) ?? [];
681
+ children.push(hash);
682
+ childrenByHash.set(s.parent, children);
683
+ }
684
+ if (s.level === 0) {
685
+ treeRootChildren = s.children;
686
+ }
687
+ else if (Number.isFinite(s.level) && !s.parent) {
688
+ treeOrphans += 1;
689
+ }
690
+ }
691
+ levels.sort((a, b) => a - b);
692
+ return {
693
+ treeMaxLevel: levels.length > 0 ? levels[levels.length - 1] : 0,
694
+ treeLevelP95: levels.length > 0 ? quantile(levels, 0.95) : 0,
695
+ treeLevelAvg: levels.length > 0
696
+ ? levels.reduce((sum, value) => sum + value, 0) / levels.length
697
+ : 0,
698
+ treeRootChildren,
699
+ treeOrphans,
700
+ childrenByHash,
701
+ };
702
+ };
703
+ const formationByTree = trees.map((tree) => computeTreeShape(tree));
704
+ const totalMessages = params.messages + params.secondBatchMessages + params.thirdBatchMessages;
705
+ const secondBatchStartSeq = params.messages;
706
+ const thirdBatchStartSeq = secondBatchStartSeq + params.secondBatchMessages;
707
+ const payload = new Uint8Array(Math.max(0, params.msgSize));
708
+ for (let i = 0; i < payload.length; i++)
709
+ payload[i] = i & 0xff;
710
+ const deliveryByTree = trees.map((tree) => {
711
+ const joinedHashes = [...tree.joinedIndices].map((i) => session.peers[i].services.fanout.publicKeyHash);
712
+ const hashToIndex = new Map();
713
+ for (let i = 0; i < joinedHashes.length; i++) {
714
+ hashToIndex.set(joinedHashes[i], i);
715
+ }
716
+ const bitsetBytes = Math.ceil(totalMessages / 8);
717
+ return {
718
+ publishAt: new Map(),
719
+ joinedHashes,
720
+ hashToIndex,
721
+ receivedBits: joinedHashes.map(() => new Uint8Array(bitsetBytes)),
722
+ receivedCounts: new Uint32Array(joinedHashes.length),
723
+ secondBatchReceivedCounts: new Uint32Array(joinedHashes.length),
724
+ deliveredWithinDeadline: 0,
725
+ secondBatchDeliveredWithinDeadline: 0,
726
+ duplicates: 0,
727
+ secondBatchLatencySamples: [],
728
+ secondBatchLatencySamplesByPeer: joinedHashes.map(() => []),
729
+ };
730
+ });
731
+ const treeByKey = new Map();
732
+ for (const tree of trees)
733
+ treeByKey.set(`${tree.topic}:${tree.rootHash}`, tree.tree);
734
+ const makeOnData = (localHash) => (ev) => {
735
+ const d = ev?.detail;
736
+ if (!d)
737
+ return;
738
+ const treeIndex = treeByKey.get(`${d.topic}:${d.root}`);
739
+ if (treeIndex == null)
740
+ return;
741
+ const delivery = deliveryByTree[treeIndex];
742
+ const peerIndex = delivery.hashToIndex.get(localHash);
743
+ if (peerIndex == null)
744
+ return;
745
+ const seq = d.seq >>> 0;
746
+ if (seq >= totalMessages)
747
+ return;
748
+ const bits = delivery.receivedBits[peerIndex];
749
+ const byteIndex = seq >>> 3;
750
+ const mask = 1 << (seq & 7);
751
+ if ((bits[byteIndex] & mask) !== 0) {
752
+ delivery.duplicates += 1;
753
+ return;
754
+ }
755
+ bits[byteIndex] |= mask;
756
+ delivery.receivedCounts[peerIndex] += 1;
757
+ if (seq >= secondBatchStartSeq) {
758
+ delivery.secondBatchReceivedCounts[peerIndex] += 1;
759
+ }
760
+ const sentAt = delivery.publishAt.get(seq);
761
+ if (sentAt == null)
762
+ return;
763
+ const latency = Date.now() - sentAt;
764
+ if (params.deadlineMs > 0 && latency <= params.deadlineMs) {
765
+ delivery.deliveredWithinDeadline += 1;
766
+ if (seq >= secondBatchStartSeq) {
767
+ delivery.secondBatchDeliveredWithinDeadline += 1;
768
+ }
769
+ }
770
+ if (seq >= secondBatchStartSeq) {
771
+ delivery.secondBatchLatencySamples.push(latency);
772
+ delivery.secondBatchLatencySamplesByPeer[peerIndex].push(latency);
773
+ }
774
+ };
775
+ for (const index of subscriberIndices) {
776
+ const node = session.peers[index].services.fanout;
777
+ node.addEventListener("fanout:data", makeOnData(node.publicKeyHash));
778
+ }
779
+ const collectParentUpgradeActivity = (tree) => {
780
+ const out = {
781
+ reparentUpgrade: 0,
782
+ reparentUpgradeSkipData: 0,
783
+ reparentUpgradeSkipRepair: 0,
784
+ reparentUpgradeSkipQuiet: 0,
785
+ parentProbeReqSent: 0,
786
+ parentShadowStart: 0,
787
+ parentShadowPromote: 0,
788
+ };
789
+ for (const p of session.peers) {
790
+ const m = p.services.fanout.getChannelMetrics(tree.topic, tree.rootHash);
791
+ out.reparentUpgrade += m.reparentUpgrade;
792
+ out.reparentUpgradeSkipData += m.reparentUpgradeSkipData;
793
+ out.reparentUpgradeSkipRepair += m.reparentUpgradeSkipRepair;
794
+ out.reparentUpgradeSkipQuiet += m.reparentUpgradeSkipQuiet;
795
+ out.parentProbeReqSent += m.parentProbeReqSent;
796
+ out.parentShadowStart += m.parentShadowStart;
797
+ out.parentShadowPromote += m.parentShadowPromote;
798
+ }
799
+ return out;
800
+ };
801
+ const diffActivity = (after, before) => ({
802
+ reparentUpgrade: Math.max(0, after.reparentUpgrade - before.reparentUpgrade),
803
+ reparentUpgradeSkipData: Math.max(0, after.reparentUpgradeSkipData - before.reparentUpgradeSkipData),
804
+ reparentUpgradeSkipRepair: Math.max(0, after.reparentUpgradeSkipRepair - before.reparentUpgradeSkipRepair),
805
+ reparentUpgradeSkipQuiet: Math.max(0, after.reparentUpgradeSkipQuiet - before.reparentUpgradeSkipQuiet),
806
+ parentProbeReqSent: Math.max(0, after.parentProbeReqSent - before.parentProbeReqSent),
807
+ parentShadowStart: Math.max(0, after.parentShadowStart - before.parentShadowStart),
808
+ parentShadowPromote: Math.max(0, after.parentShadowPromote - before.parentShadowPromote),
809
+ });
810
+ const applyLateRootTopology = async () => {
811
+ if (params.lateRootMaxChildren > 0) {
812
+ for (const tree of trees) {
813
+ const id = tree.root.getChannelId(tree.topic, tree.rootHash);
814
+ const ch = tree.root.channelsBySuffixKey?.get?.(id.suffixKey);
815
+ if (ch) {
816
+ ch.maxChildren = Math.max(ch.maxChildren ?? 0, params.lateRootMaxChildren);
817
+ const uploadBoundedMaxChildren = effectiveMaxChildrenForUpload(params, ch, params.lateRootMaxChildren);
818
+ ch.effectiveMaxChildren = Math.max(ch.effectiveMaxChildren ?? 0, uploadBoundedMaxChildren);
819
+ void tree.root
820
+ .announceToTrackers?.(ch, timeoutSignal)
821
+ ?.catch?.(() => { });
822
+ }
823
+ }
824
+ }
825
+ const target = Math.min(subscriberIndices.length, Math.max(0, Math.ceil(subscriberIndices.length * params.lateRootConnectFraction)));
826
+ for (const tree of trees) {
827
+ for (let i = 0; i < target; i++) {
828
+ const idx = subscriberIndices[(i + tree.tree * 3) % subscriberIndices.length];
829
+ try {
830
+ await session.peers[idx].dial(session.peers[tree.rootIndex].getMultiaddrs());
831
+ }
832
+ catch {
833
+ // best-effort late underlay shortcut
834
+ }
835
+ }
836
+ }
837
+ };
838
+ let lateRootApplied = false;
839
+ const applyLateRootOnce = async () => {
840
+ if (lateRootApplied || params.lateRootConnectAfterMs < 0)
841
+ return;
842
+ lateRootApplied = true;
843
+ await applyLateRootTopology();
844
+ };
845
+ const churnController = new AbortController();
846
+ const churnSignal = anySignal([
847
+ timeoutSignal,
848
+ churnController.signal,
849
+ ]);
850
+ const churnLoop = async () => {
851
+ if (params.churnEveryMs <= 0 ||
852
+ params.churnDownMs <= 0 ||
853
+ params.churnFraction <= 0) {
854
+ return;
855
+ }
856
+ for (;;) {
857
+ if (churnSignal.aborted)
858
+ return;
859
+ try {
860
+ await delay(params.churnEveryMs, { signal: churnSignal });
861
+ }
862
+ catch (err) {
863
+ if (churnSignal.aborted)
864
+ return;
865
+ throw err;
866
+ }
867
+ const target = Math.max(1, Math.floor(subscriberIndices.length * params.churnFraction));
868
+ const chosen = pickDistinct(rng, subscriberIndices, target);
869
+ const now = Date.now();
870
+ await Promise.all(chosen.map(async (idx) => {
871
+ const peer = session.peers[idx];
872
+ network.setPeerOffline(peer.peerId, params.churnDownMs, now);
873
+ await network.disconnectPeer(peer.peerId);
874
+ }));
875
+ }
876
+ };
877
+ const churnPromise = churnLoop().catch(() => { });
878
+ const publishStartActivity = trees.map((tree) => collectParentUpgradeActivity(tree));
879
+ let activeActivity = publishStartActivity.map((activity) => diffActivity(activity, activity));
880
+ const lateRootDuringPublishPromise = params.lateRootDuringPublish && params.lateRootConnectAfterMs >= 0
881
+ ? (async () => {
882
+ if (params.lateRootConnectAfterMs > 0) {
883
+ await delay(params.lateRootConnectAfterMs, {
884
+ signal: timeoutSignal,
885
+ });
886
+ }
887
+ await applyLateRootOnce();
888
+ })()
889
+ : undefined;
890
+ const publishRange = async (from, to) => {
891
+ await Promise.all(activeTrees.map(async (tree) => {
892
+ const delivery = deliveryByTree[tree.tree];
893
+ for (let seq = from; seq < to; seq++) {
894
+ if (timeoutSignal.aborted) {
895
+ throw timeoutSignal.reason ?? new Error("multi eval aborted");
896
+ }
897
+ delivery.publishAt.set(seq, Date.now());
898
+ await tree.root.publishData(tree.topic, tree.rootHash, payload);
899
+ if (params.intervalMs > 0) {
900
+ await delay(params.intervalMs, { signal: timeoutSignal });
901
+ }
902
+ }
903
+ }));
904
+ };
905
+ try {
906
+ await publishRange(0, params.messages);
907
+ }
908
+ finally {
909
+ activeActivity = trees.map((tree, i) => diffActivity(collectParentUpgradeActivity(tree), publishStartActivity[i]));
910
+ await lateRootDuringPublishPromise;
911
+ }
912
+ if (params.repair && params.messages > 0) {
913
+ await Promise.all(activeTrees.map((tree) => tree.root.publishEnd(tree.topic, tree.rootHash, params.messages)));
914
+ }
915
+ if (params.settleMs > 0) {
916
+ if (!params.lateRootDuringPublish &&
917
+ params.lateRootConnectAfterMs >= 0 &&
918
+ params.lateRootConnectAfterMs < params.settleMs) {
919
+ if (params.lateRootConnectAfterMs > 0) {
920
+ await delay(params.lateRootConnectAfterMs, { signal: timeoutSignal });
921
+ }
922
+ await applyLateRootOnce();
923
+ const remaining = Math.max(0, params.settleMs - params.lateRootConnectAfterMs);
924
+ if (remaining > 0)
925
+ await delay(remaining, { signal: timeoutSignal });
926
+ }
927
+ else {
928
+ await delay(params.settleMs, { signal: timeoutSignal });
929
+ }
930
+ }
931
+ if (params.secondBatchMessages > 0) {
932
+ await publishRange(secondBatchStartSeq, secondBatchStartSeq + params.secondBatchMessages);
933
+ if (params.repair) {
934
+ await Promise.all(activeTrees.map((tree) => tree.root.publishEnd(tree.topic, tree.rootHash, secondBatchStartSeq + params.secondBatchMessages)));
935
+ }
936
+ if (params.secondBatchSettleMs > 0) {
937
+ await delay(params.secondBatchSettleMs, { signal: timeoutSignal });
938
+ }
939
+ }
940
+ if (params.thirdBatchMessages > 0) {
941
+ await publishRange(thirdBatchStartSeq, thirdBatchStartSeq + params.thirdBatchMessages);
942
+ if (params.repair) {
943
+ await Promise.all(activeTrees.map((tree) => tree.root.publishEnd(tree.topic, tree.rootHash, thirdBatchStartSeq + params.thirdBatchMessages)));
944
+ }
945
+ if (params.thirdBatchSettleMs > 0) {
946
+ await delay(params.thirdBatchSettleMs, { signal: timeoutSignal });
947
+ }
948
+ }
949
+ churnController.abort();
950
+ await churnPromise;
951
+ churnSignal.clear?.();
952
+ const finalShapeByTree = trees.map((tree) => computeTreeShape(tree));
953
+ const treeResults = [];
954
+ for (const tree of trees) {
955
+ const formation = formationByTree[tree.tree];
956
+ const shape = finalShapeByTree[tree.tree];
957
+ const delivery = deliveryByTree[tree.tree];
958
+ const joinedCount = delivery.joinedHashes.length;
959
+ const treeMessageCount = tree.active ? totalMessages : 0;
960
+ const treeSecondBatchMessages = tree.active
961
+ ? params.secondBatchMessages + params.thirdBatchMessages
962
+ : 0;
963
+ let delivered = 0;
964
+ for (const c of delivery.receivedCounts)
965
+ delivered += c;
966
+ let secondBatchDelivered = 0;
967
+ for (const c of delivery.secondBatchReceivedCounts) {
968
+ secondBatchDelivered += c;
969
+ }
970
+ const expected = joinedCount * treeMessageCount;
971
+ const secondBatchExpected = joinedCount * treeSecondBatchMessages;
972
+ const idealPayloadBytes = expected * Math.max(0, params.msgSize);
973
+ const secondBatchLatencySamples = delivery.secondBatchLatencySamples.sort((a, b) => a - b);
974
+ const secondBatchLatencyP95ByHash = {};
975
+ for (let i = 0; i < delivery.joinedHashes.length; i++) {
976
+ const samples = delivery.secondBatchLatencySamplesByPeer[i];
977
+ if (samples.length === 0)
978
+ continue;
979
+ samples.sort((a, b) => a - b);
980
+ secondBatchLatencyP95ByHash[delivery.joinedHashes[i]] = quantile(samples, 0.95);
981
+ }
982
+ let controlBytesSent = 0;
983
+ let controlBytesSentRepair = 0;
984
+ let controlBytesSentTracker = 0;
985
+ let dataPayloadBytesSent = 0;
986
+ let reparentUpgradeTotal = 0;
987
+ let parentProbeReqSentTotal = 0;
988
+ let parentShadowStartTotal = 0;
989
+ let parentShadowPromoteTotal = 0;
990
+ let maxReparentUpgradePerPeer = 0;
991
+ const upgradedPeerHashes = [];
992
+ for (const p of session.peers) {
993
+ const nodeHash = p.services.fanout.publicKeyHash;
994
+ const m = p.services.fanout.getChannelMetrics(tree.topic, tree.rootHash);
995
+ controlBytesSent += m.controlBytesSent;
996
+ controlBytesSentRepair += m.controlBytesSentRepair;
997
+ controlBytesSentTracker += m.controlBytesSentTracker;
998
+ dataPayloadBytesSent += m.dataPayloadBytesSent;
999
+ reparentUpgradeTotal += m.reparentUpgrade;
1000
+ parentProbeReqSentTotal += m.parentProbeReqSent;
1001
+ parentShadowStartTotal += m.parentShadowStart;
1002
+ parentShadowPromoteTotal += m.parentShadowPromote;
1003
+ maxReparentUpgradePerPeer = Math.max(maxReparentUpgradePerPeer, m.reparentUpgrade);
1004
+ if (m.reparentUpgrade > 0)
1005
+ upgradedPeerHashes.push(nodeHash);
1006
+ }
1007
+ const upgradedBranchHashSet = new Set();
1008
+ const formationChildrenByHash = formation.childrenByHash;
1009
+ for (const hash of upgradedPeerHashes) {
1010
+ const stack = [hash];
1011
+ while (stack.length > 0) {
1012
+ const next = stack.pop();
1013
+ if (upgradedBranchHashSet.has(next))
1014
+ continue;
1015
+ if (delivery.hashToIndex.has(next))
1016
+ upgradedBranchHashSet.add(next);
1017
+ for (const child of formationChildrenByHash.get(next) ?? []) {
1018
+ stack.push(child);
1019
+ }
1020
+ }
1021
+ }
1022
+ const active = activeActivity[tree.tree];
1023
+ const rootUploadBps = session.network.peerMetricsByHash.get(tree.rootHash)
1024
+ ?.maxBytesPerSecond ?? 0;
1025
+ treeResults.push({
1026
+ tree: tree.tree,
1027
+ active: tree.active,
1028
+ topic: tree.topic,
1029
+ rootHash: tree.rootHash,
1030
+ subscriberCount: subscriberIndices.length,
1031
+ joinedCount,
1032
+ formationTreeLevelP95: formation.treeLevelP95,
1033
+ formationTreeLevelAvg: formation.treeLevelAvg,
1034
+ formationRootChildren: formation.treeRootChildren,
1035
+ treeLevelP95: shape.treeLevelP95,
1036
+ treeLevelAvg: shape.treeLevelAvg,
1037
+ treeRootChildren: shape.treeRootChildren,
1038
+ treeOrphans: shape.treeOrphans,
1039
+ expected,
1040
+ delivered,
1041
+ deliveredPct: expected === 0 ? 100 : (100 * delivered) / expected,
1042
+ deliveredWithinDeadlinePct: expected === 0
1043
+ ? 100
1044
+ : (100 * delivery.deliveredWithinDeadline) / expected,
1045
+ secondBatchExpected,
1046
+ secondBatchDeliveredWithinDeadlinePct: secondBatchExpected === 0
1047
+ ? 100
1048
+ : (100 * delivery.secondBatchDeliveredWithinDeadline) /
1049
+ secondBatchExpected,
1050
+ secondBatchLatencyP95: secondBatchLatencySamples.length > 0
1051
+ ? quantile(secondBatchLatencySamples, 0.95)
1052
+ : NaN,
1053
+ secondBatchLatencyP95ByHash,
1054
+ reparentUpgradeTotal,
1055
+ publishActiveReparentUpgradeTotal: active.reparentUpgrade,
1056
+ publishActiveReparentUpgradeSkipDataTotal: active.reparentUpgradeSkipData,
1057
+ publishActiveReparentUpgradeSkipRepairTotal: active.reparentUpgradeSkipRepair,
1058
+ publishActiveReparentUpgradeSkipQuietTotal: active.reparentUpgradeSkipQuiet,
1059
+ publishActiveParentProbeReqSentTotal: active.parentProbeReqSent,
1060
+ publishActiveParentShadowStartTotal: active.parentShadowStart,
1061
+ publishActiveParentShadowPromoteTotal: active.parentShadowPromote,
1062
+ parentProbeReqSentTotal,
1063
+ parentShadowStartTotal,
1064
+ parentShadowPromoteTotal,
1065
+ maxReparentUpgradePerPeer,
1066
+ upgradedPeerHashes,
1067
+ upgradedBranchPeerHashes: [...upgradedBranchHashSet],
1068
+ rootUploadFracPct: params.rootUploadLimitBps > 0
1069
+ ? (100 * rootUploadBps) / params.rootUploadLimitBps
1070
+ : 0,
1071
+ controlBytesSent,
1072
+ controlBytesSentRepair,
1073
+ controlBytesSentTracker,
1074
+ dataPayloadBytesSent,
1075
+ duplicates: delivery.duplicates,
1076
+ dataOverheadFactor: idealPayloadBytes <= 0
1077
+ ? 1
1078
+ : dataPayloadBytesSent / idealPayloadBytes,
1079
+ });
1080
+ }
1081
+ const expected = treeResults.reduce((sum, tree) => sum + tree.expected, 0);
1082
+ const delivered = treeResults.reduce((sum, tree) => sum + tree.delivered, 0);
1083
+ const secondBatchExpected = treeResults.reduce((sum, tree) => sum + tree.secondBatchExpected, 0);
1084
+ const deliveredWithinDeadline = treeResults.reduce((sum, tree) => sum + (tree.deliveredWithinDeadlinePct * tree.expected) / 100, 0);
1085
+ const secondBatchDeliveredWithinDeadline = treeResults.reduce((sum, tree) => sum +
1086
+ (tree.secondBatchDeliveredWithinDeadlinePct *
1087
+ tree.secondBatchExpected) /
1088
+ 100, 0);
1089
+ const secondBatchLatencies = treeResults
1090
+ .map((tree) => tree.secondBatchLatencyP95)
1091
+ .filter((value) => Number.isFinite(value))
1092
+ .sort((a, b) => a - b);
1093
+ const deliveredPayloadBytes = delivered * Math.max(0, params.msgSize);
1094
+ const idealPayloadBytes = expected * Math.max(0, params.msgSize);
1095
+ const dataPayloadBytesSent = treeResults.reduce((sum, tree) => sum + tree.dataPayloadBytesSent, 0);
1096
+ const controlBytesSent = treeResults.reduce((sum, tree) => sum + tree.controlBytesSent, 0);
1097
+ const controlBytesSentTracker = treeResults.reduce((sum, tree) => sum + tree.controlBytesSentTracker, 0);
1098
+ const controlBytesSentRepair = treeResults.reduce((sum, tree) => sum + tree.controlBytesSentRepair, 0);
1099
+ return {
1100
+ params,
1101
+ trees: treeResults,
1102
+ joinedCount: treeResults.reduce((sum, tree) => sum + tree.joinedCount, 0),
1103
+ subscriberSlots: params.writers * subscriberIndices.length,
1104
+ expected,
1105
+ delivered,
1106
+ deliveredPct: expected === 0 ? 100 : (100 * delivered) / expected,
1107
+ deliveredWithinDeadlinePct: expected === 0 ? 100 : (100 * deliveredWithinDeadline) / expected,
1108
+ secondBatchExpected,
1109
+ secondBatchDeliveredWithinDeadlinePct: secondBatchExpected === 0
1110
+ ? 100
1111
+ : (100 * secondBatchDeliveredWithinDeadline) / secondBatchExpected,
1112
+ secondBatchLatencyP95: secondBatchLatencies.length > 0
1113
+ ? quantile(secondBatchLatencies, 0.95)
1114
+ : NaN,
1115
+ reparentUpgradeTotal: treeResults.reduce((sum, tree) => sum + tree.reparentUpgradeTotal, 0),
1116
+ parentProbeReqSentTotal: treeResults.reduce((sum, tree) => sum + tree.parentProbeReqSentTotal, 0),
1117
+ parentShadowStartTotal: treeResults.reduce((sum, tree) => sum + tree.parentShadowStartTotal, 0),
1118
+ parentShadowPromoteTotal: treeResults.reduce((sum, tree) => sum + tree.parentShadowPromoteTotal, 0),
1119
+ publishActiveReparentUpgradeTotal: treeResults.reduce((sum, tree) => sum + tree.publishActiveReparentUpgradeTotal, 0),
1120
+ publishActiveParentProbeReqSentTotal: treeResults.reduce((sum, tree) => sum + tree.publishActiveParentProbeReqSentTotal, 0),
1121
+ publishActiveParentShadowStartTotal: treeResults.reduce((sum, tree) => sum + tree.publishActiveParentShadowStartTotal, 0),
1122
+ publishActiveParentShadowPromoteTotal: treeResults.reduce((sum, tree) => sum + tree.publishActiveParentShadowPromoteTotal, 0),
1123
+ publishActiveGuardSkipsTotal: treeResults.reduce((sum, tree) => sum +
1124
+ tree.publishActiveReparentUpgradeSkipDataTotal +
1125
+ tree.publishActiveReparentUpgradeSkipRepairTotal +
1126
+ tree.publishActiveReparentUpgradeSkipQuietTotal, 0),
1127
+ activeGuardedTrees: treeResults.filter((tree) => tree.publishActiveReparentUpgradeSkipDataTotal +
1128
+ tree.publishActiveReparentUpgradeSkipRepairTotal +
1129
+ tree.publishActiveReparentUpgradeSkipQuietTotal >
1130
+ 0).length,
1131
+ treeLevelAvg: avgFinite(treeResults.map((tree) => tree.treeLevelAvg)),
1132
+ treeLevelP95: avgFinite(treeResults.map((tree) => tree.treeLevelP95)),
1133
+ rootChildrenSum: treeResults.reduce((sum, tree) => sum + tree.treeRootChildren, 0),
1134
+ rootChildrenMax: Math.max(0, ...treeResults.map((tree) => tree.treeRootChildren)),
1135
+ rootUploadPctMax: Math.max(0, ...treeResults.map((tree) => tree.rootUploadFracPct)),
1136
+ maxReparentUpgradePerPeer: Math.max(0, ...treeResults.map((tree) => tree.maxReparentUpgradePerPeer)),
1137
+ duplicates: treeResults.reduce((sum, tree) => sum + tree.duplicates, 0),
1138
+ dataOverheadFactor: idealPayloadBytes <= 0 ? 1 : dataPayloadBytesSent / idealPayloadBytes,
1139
+ controlBpp: deliveredPayloadBytes <= 0
1140
+ ? 0
1141
+ : controlBytesSent / deliveredPayloadBytes,
1142
+ trackerBpp: deliveredPayloadBytes <= 0
1143
+ ? 0
1144
+ : controlBytesSentTracker / deliveredPayloadBytes,
1145
+ repairBpp: deliveredPayloadBytes <= 0
1146
+ ? 0
1147
+ : controlBytesSentRepair / deliveredPayloadBytes,
1148
+ network: session.network.metrics,
1149
+ };
1150
+ }
1151
+ finally {
1152
+ if (timer)
1153
+ clearTimeout(timer);
1154
+ try {
1155
+ await session.stop();
1156
+ }
1157
+ catch {
1158
+ // ignore teardown aborts in the shim
1159
+ }
1160
+ }
1161
+ };
1162
+ const formatResult = (result) => {
1163
+ const p = result.params;
1164
+ return [
1165
+ "fanout-tree-parent-upgrade-multi-eval",
1166
+ `scenario=${p.scenario} seed=${p.seed} writers=${p.writers} activeWriters=${p.activeWriters} nodes=${p.nodes} subscribersPerTree=${p.subscribersPerTree}`,
1167
+ `joinedSlots=${result.joinedCount}/${result.subscriberSlots} delivered=${result.delivered}/${result.expected} (${fmt(result.deliveredPct)}%) deadline=${fmt(result.deliveredWithinDeadlinePct)}%`,
1168
+ ...(result.secondBatchExpected > 0
1169
+ ? [
1170
+ `secondBatch deadline=${fmt(result.secondBatchDeliveredWithinDeadlinePct)}% p95=${fmt(result.secondBatchLatencyP95, 1)}ms`,
1171
+ ]
1172
+ : []),
1173
+ `tree avgLevel=${fmt(result.treeLevelAvg)} p95Level=${fmt(result.treeLevelP95, 1)} rootChildren sum=${result.rootChildrenSum} max=${result.rootChildrenMax} rootUploadPctMax=${fmt(result.rootUploadPctMax)}`,
1174
+ `parentUpgrade upgrades=${result.reparentUpgradeTotal} probes=${result.parentProbeReqSentTotal} shadowStart=${result.parentShadowStartTotal} shadowPromote=${result.parentShadowPromoteTotal} maxPerPeer=${result.maxReparentUpgradePerPeer}`,
1175
+ `publishActive upgrades=${result.publishActiveReparentUpgradeTotal} probes=${result.publishActiveParentProbeReqSentTotal} shadowStart=${result.publishActiveParentShadowStartTotal} shadowPromote=${result.publishActiveParentShadowPromoteTotal} guardSkips=${result.publishActiveGuardSkipsTotal} guardedTrees=${result.activeGuardedTrees}/${p.writers}`,
1176
+ `redundancy dataFactor=${fmt(result.dataOverheadFactor, 3)} dup=${result.duplicates}`,
1177
+ `cost controlBpp=${fmt(result.controlBpp, 4)} trackerBpp=${fmt(result.trackerBpp, 4)} repairBpp=${fmt(result.repairBpp, 4)}`,
1178
+ `network dials=${result.network.dials} connsOpened=${result.network.connectionsOpened} streamsOpened=${result.network.streamsOpened} bytesSent=${result.network.bytesSent}`,
1179
+ ...result.trees.map((tree) => `tree[${tree.tree}${tree.active ? "" : ":idle"}] joined=${tree.joinedCount}/${tree.subscriberCount} upgrades=${tree.reparentUpgradeTotal} probes=${tree.parentProbeReqSentTotal} active(probes/upgrades/guards)=${tree.publishActiveParentProbeReqSentTotal}/${tree.publishActiveReparentUpgradeTotal}/${tree.publishActiveReparentUpgradeSkipDataTotal + tree.publishActiveReparentUpgradeSkipRepairTotal + tree.publishActiveReparentUpgradeSkipQuietTotal} rootChildren=${tree.treeRootChildren} rootUploadPct=${fmt(tree.rootUploadFracPct)} secondP95=${fmt(tree.secondBatchLatencyP95, 1)}`),
1180
+ ].join("\n");
1181
+ };
1182
+ const analyzeUsefulPromotions = (scenario, baseline, upgrade, args) => {
1183
+ let usefulPromotedTrees = 0;
1184
+ const branchGains = [];
1185
+ const treeSecondBatchRegressions = [];
1186
+ const branchSecondBatchRegressions = [];
1187
+ const minBranchGainMs = Math.max(1, args.minPromotedBranchGainMs);
1188
+ const minTreeLevelAvgGain = Math.max(0, args.minPromotedTreeLevelAvgGain);
1189
+ const backgroundSecondBatchLatencyP95RegressionMax = maxFinite(upgrade.trees
1190
+ .filter((tree) => tree.active && tree.reparentUpgradeTotal <= 0)
1191
+ .map((tree) => {
1192
+ const baseTree = baseline.trees[tree.tree];
1193
+ if (!baseTree)
1194
+ return NaN;
1195
+ if (!Number.isFinite(baseTree.secondBatchLatencyP95) ||
1196
+ !Number.isFinite(tree.secondBatchLatencyP95)) {
1197
+ return NaN;
1198
+ }
1199
+ return tree.secondBatchLatencyP95 - baseTree.secondBatchLatencyP95;
1200
+ }));
1201
+ const backgroundLatencySlackMs = isHotspotIdleScenario(scenario)
1202
+ ? Math.max(0, backgroundSecondBatchLatencyP95RegressionMax)
1203
+ : 0;
1204
+ for (const tree of upgrade.trees) {
1205
+ if (tree.reparentUpgradeTotal <= 0)
1206
+ continue;
1207
+ const baseTree = baseline.trees[tree.tree];
1208
+ if (!baseTree)
1209
+ continue;
1210
+ const latencySlackMs = secondBatchLatencySlackMs(baseTree.secondBatchLatencyP95, args);
1211
+ const treeLevelAvgGain = baseTree.treeLevelAvg - tree.treeLevelAvg;
1212
+ const secondBatchP95Gain = baseTree.secondBatchLatencyP95 - tree.secondBatchLatencyP95;
1213
+ if (Number.isFinite(baseTree.secondBatchLatencyP95) &&
1214
+ Number.isFinite(tree.secondBatchLatencyP95)) {
1215
+ treeSecondBatchRegressions.push(tree.secondBatchLatencyP95 - baseTree.secondBatchLatencyP95);
1216
+ }
1217
+ const branchBase = peerLatencyP95For(baseTree, tree.upgradedBranchPeerHashes);
1218
+ const branchUpgrade = peerLatencyP95For(tree, tree.upgradedBranchPeerHashes);
1219
+ const branchGain = branchBase - branchUpgrade;
1220
+ if (Number.isFinite(branchGain))
1221
+ branchGains.push(branchGain);
1222
+ if (Number.isFinite(branchBase) && Number.isFinite(branchUpgrade)) {
1223
+ branchSecondBatchRegressions.push(branchUpgrade - branchBase);
1224
+ }
1225
+ const branchLatencyOk = !Number.isFinite(branchGain) ||
1226
+ branchGain >= -(latencySlackMs + backgroundLatencySlackMs);
1227
+ const usefulLatencyGain = Math.max(secondBatchP95Gain, branchGain) >= minBranchGainMs;
1228
+ const usefulDepthGain = treeLevelAvgGain >= minTreeLevelAvgGain;
1229
+ if (branchLatencyOk && (usefulLatencyGain || usefulDepthGain)) {
1230
+ usefulPromotedTrees += 1;
1231
+ }
1232
+ }
1233
+ return {
1234
+ usefulPromotedTrees,
1235
+ promotedBranchGainAvg: avgFinite(branchGains),
1236
+ promotedTreeSecondBatchLatencyP95RegressionMax: maxFinite(treeSecondBatchRegressions),
1237
+ promotedBranchSecondBatchLatencyP95RegressionMax: maxFinite(branchSecondBatchRegressions),
1238
+ backgroundSecondBatchLatencyP95RegressionMax,
1239
+ };
1240
+ };
1241
+ const evaluateRun = (scenario, baseline, upgrade, args) => {
1242
+ const failures = [];
1243
+ const useful = analyzeUsefulPromotions(scenario, baseline, upgrade, args);
1244
+ const costRatio = isHotspotIdleScenario(scenario)
1245
+ ? Math.max(args.maxCostRatio, 1.2)
1246
+ : args.maxCostRatio;
1247
+ const sentProactiveUpgradeTraffic = upgrade.reparentUpgradeTotal > 0 ||
1248
+ upgrade.parentProbeReqSentTotal > 0 ||
1249
+ upgrade.parentShadowStartTotal > 0;
1250
+ const usefulIdleDeadlineSlack = isIdleScenario(scenario) && useful.usefulPromotedTrees > 0
1251
+ ? Math.max(0, args.maxUsefulIdleDeadlinePctDelta)
1252
+ : 0;
1253
+ const guardedIdleProbePerSlot = isIdleScenario(scenario) &&
1254
+ useful.usefulPromotedTrees === 0 &&
1255
+ upgrade.reparentUpgradeTotal === 0
1256
+ ? upgrade.parentProbeReqSentTotal / Math.max(1, upgrade.subscriberSlots)
1257
+ : 0;
1258
+ const guardedIdleDeadlineSlack = guardedIdleProbePerSlot > 0 &&
1259
+ guardedIdleProbePerSlot <=
1260
+ Math.max(0, args.maxGuardedIdleProbePerSlotForSlack)
1261
+ ? Math.max(0, args.maxGuardedIdleDeadlinePctDelta)
1262
+ : 0;
1263
+ const idleDeadlineSlack = Math.max(usefulIdleDeadlineSlack, guardedIdleDeadlineSlack);
1264
+ // Live scenarios are no-work safety gates. Independent baseline/treatment
1265
+ // runs can still move delivery, root pressure, or whether a local guard timer
1266
+ // happens to fire. If the policy sends no proactive traffic, those values are
1267
+ // observability only; the hard contract is zero probes/shadows/upgrades.
1268
+ const compareIndependentRunShape = !isLiveChurnScenario(scenario) && sentProactiveUpgradeTraffic;
1269
+ if (compareIndependentRunShape) {
1270
+ failIfLess(failures, "deliveredPct", baseline.deliveredPct, upgrade.deliveredPct, baseline.deliveredPct);
1271
+ failIfLess(failures, "deliveredWithinDeadlinePct", baseline.deliveredWithinDeadlinePct, upgrade.deliveredWithinDeadlinePct, isLiveScenario(scenario)
1272
+ ? baseline.deliveredWithinDeadlinePct -
1273
+ Math.max(0, args.maxLiveDeadlinePctDelta)
1274
+ : baseline.deliveredWithinDeadlinePct - idleDeadlineSlack);
1275
+ failIfGreater(failures, "dataOverheadFactor", baseline.dataOverheadFactor, upgrade.dataOverheadFactor, ratioLimit(baseline.dataOverheadFactor, args.maxDataOverheadRatio, 0.01));
1276
+ failIfGreater(failures, "controlBpp", baseline.controlBpp, upgrade.controlBpp, ratioLimit(baseline.controlBpp, costRatio, 0.001));
1277
+ failIfGreater(failures, "trackerBpp", baseline.trackerBpp, upgrade.trackerBpp, ratioLimit(baseline.trackerBpp, costRatio, 0.001));
1278
+ failIfGreater(failures, "repairBpp", baseline.repairBpp, upgrade.repairBpp, ratioLimit(baseline.repairBpp, costRatio, 0.001));
1279
+ }
1280
+ if (isLiveScenario(scenario)) {
1281
+ failIfGreater(failures, "activeProbes", 0, upgrade.publishActiveParentProbeReqSentTotal, 0);
1282
+ failIfGreater(failures, "activeShadowStarts", 0, upgrade.publishActiveParentShadowStartTotal, 0);
1283
+ failIfGreater(failures, "activeReparentUpgrades", 0, upgrade.publishActiveReparentUpgradeTotal, 0);
1284
+ failIfGreater(failures, "totalProbes", 0, upgrade.parentProbeReqSentTotal, 0);
1285
+ failIfGreater(failures, "totalReparentUpgrades", 0, upgrade.reparentUpgradeTotal, 0);
1286
+ if (isLiveChurnScenario(scenario)) {
1287
+ failIfGreater(failures, "activeGuardSkipsPerSlot", 0, upgrade.publishActiveGuardSkipsTotal /
1288
+ Math.max(1, upgrade.subscriberSlots), Math.max(0, args.maxLiveChurnGuardSkipsPerSlot));
1289
+ }
1290
+ }
1291
+ if (isIdleScenario(scenario) && upgrade.reparentUpgradeTotal > 0) {
1292
+ const latencySlackMs = secondBatchLatencySlackMs(baseline.secondBatchLatencyP95, args);
1293
+ failIfLess(failures, "usefulPromotedTrees", 0, useful.usefulPromotedTrees, 1);
1294
+ if (isHotspotIdleScenario(scenario)) {
1295
+ failIfGreater(failures, "probePerSubscriberSlot", 0, upgrade.parentProbeReqSentTotal / Math.max(1, upgrade.subscriberSlots), 0.125);
1296
+ }
1297
+ else {
1298
+ failIfGreater(failures, "probePerUpgrade", 0, upgrade.parentProbeReqSentTotal / upgrade.reparentUpgradeTotal, args.maxProbePerUpgrade);
1299
+ }
1300
+ failIfGreater(failures, "maxReparentUpgradePerPeer", baseline.maxReparentUpgradePerPeer, upgrade.maxReparentUpgradePerPeer, 1);
1301
+ failIfLess(failures, "secondBatchDeadlinePct", baseline.secondBatchDeliveredWithinDeadlinePct, upgrade.secondBatchDeliveredWithinDeadlinePct, baseline.secondBatchDeliveredWithinDeadlinePct - idleDeadlineSlack);
1302
+ failIfGreater(failures, "promotedBranchSecondBatchLatencyP95Regression", 0, useful.promotedBranchSecondBatchLatencyP95RegressionMax, latencySlackMs +
1303
+ (isHotspotIdleScenario(scenario)
1304
+ ? Math.max(0, useful.backgroundSecondBatchLatencyP95RegressionMax)
1305
+ : 0));
1306
+ failIfLess(failures, "secondBatchOrBranchGain", 0, Math.max(baseline.secondBatchLatencyP95 - upgrade.secondBatchLatencyP95, useful.promotedBranchGainAvg), Math.max(1, args.minPromotedBranchGainMs));
1307
+ }
1308
+ if (isSparseIdleScenario(scenario)) {
1309
+ const inactiveTrees = upgrade.trees.filter((tree) => !tree.active);
1310
+ const inactiveUpgrades = inactiveTrees.reduce((sum, tree) => sum + tree.reparentUpgradeTotal, 0);
1311
+ const inactiveProbes = inactiveTrees.reduce((sum, tree) => sum + tree.parentProbeReqSentTotal, 0);
1312
+ const inactiveShadowStarts = inactiveTrees.reduce((sum, tree) => sum + tree.parentShadowStartTotal, 0);
1313
+ failIfGreater(failures, "inactiveTreeUpgrades", 0, inactiveUpgrades, 0);
1314
+ failIfGreater(failures, "inactiveTreeProbes", 0, inactiveProbes, 0);
1315
+ failIfGreater(failures, "inactiveTreeShadowStarts", 0, inactiveShadowStarts, 0);
1316
+ }
1317
+ if (compareIndependentRunShape) {
1318
+ for (const tree of upgrade.trees) {
1319
+ const baselineTree = baseline.trees[tree.tree];
1320
+ if (!baselineTree)
1321
+ continue;
1322
+ failIfGreater(failures, `tree${tree.tree}RootChildrenDelta`, baselineTree.treeRootChildren, tree.treeRootChildren, baselineTree.treeRootChildren + Math.max(0, args.maxRootChildrenDelta));
1323
+ failIfGreater(failures, `tree${tree.tree}RootUploadPctDelta`, baselineTree.rootUploadFracPct, tree.rootUploadFracPct, baselineTree.rootUploadFracPct +
1324
+ Math.max(0, args.maxRootUploadPctDelta));
1325
+ }
1326
+ }
1327
+ return { failures, ...useful };
1328
+ };
1329
+ const printComparison = (scenario, seed, baseline, upgrade, failures, usefulPromotedTrees, promotedBranchGainAvg, promotedTreeSecondBatchLatencyP95RegressionMax, promotedBranchSecondBatchLatencyP95RegressionMax) => {
1330
+ const rootDeltas = upgrade.trees.map((tree) => {
1331
+ const base = baseline.trees[tree.tree];
1332
+ return tree.treeRootChildren - base.treeRootChildren;
1333
+ });
1334
+ const rootUploadDeltas = upgrade.trees.map((tree) => {
1335
+ const base = baseline.trees[tree.tree];
1336
+ return tree.rootUploadFracPct - base.rootUploadFracPct;
1337
+ });
1338
+ console.log([
1339
+ `parent-upgrade-multi-eval scenario=${scenario} seed=${seed} viable=${failures.length === 0}`,
1340
+ ` joinedSlots ${baseline.joinedCount}/${baseline.subscriberSlots} -> ${upgrade.joinedCount}/${upgrade.subscriberSlots}`,
1341
+ ` deadline ${fmt(baseline.deliveredWithinDeadlinePct)} -> ${fmt(upgrade.deliveredWithinDeadlinePct)} secondP95 ${fmt(baseline.secondBatchLatencyP95, 1)} -> ${fmt(upgrade.secondBatchLatencyP95, 1)}`,
1342
+ ` upgrades=${upgrade.reparentUpgradeTotal} usefulPromotedTrees=${usefulPromotedTrees} probes=${upgrade.parentProbeReqSentTotal} shadowStart=${upgrade.parentShadowStartTotal} branchGainAvg=${fmt(promotedBranchGainAvg, 1)}`,
1343
+ ` promotedLatencyRegressionMax tree=${fmt(promotedTreeSecondBatchLatencyP95RegressionMax, 1)}ms branch=${fmt(promotedBranchSecondBatchLatencyP95RegressionMax, 1)}ms`,
1344
+ ` active upgrades=${upgrade.publishActiveReparentUpgradeTotal} probes=${upgrade.publishActiveParentProbeReqSentTotal} shadowStart=${upgrade.publishActiveParentShadowStartTotal} guardSkips=${upgrade.publishActiveGuardSkipsTotal} guardedTrees=${upgrade.activeGuardedTrees}/${upgrade.params.writers}`,
1345
+ ` rootChildrenDelta sum=${rootDeltas.reduce((sum, value) => sum + value, 0)} max=${Math.max(...rootDeltas)} rootUploadPctDeltaMax=${fmt(Math.max(...rootUploadDeltas), 2)}`,
1346
+ ` redundancy dataFactor ${fmt(baseline.dataOverheadFactor, 3)} -> ${fmt(upgrade.dataOverheadFactor, 3)} dup ${baseline.duplicates} -> ${upgrade.duplicates}`,
1347
+ ` cost controlBpp ${fmt(baseline.controlBpp, 4)} -> ${fmt(upgrade.controlBpp, 4)} trackerBpp ${fmt(baseline.trackerBpp, 4)} -> ${fmt(upgrade.trackerBpp, 4)} repairBpp ${fmt(baseline.repairBpp, 4)} -> ${fmt(upgrade.repairBpp, 4)}`,
1348
+ ...(failures.length > 0
1349
+ ? [
1350
+ ` failures ${failures
1351
+ .map((f) => `${f.metric}: baseline=${fmt(f.baseline)} upgrade=${fmt(f.upgrade)} limit=${fmt(f.limit)}`)
1352
+ .join("; ")}`,
1353
+ ]
1354
+ : []),
1355
+ ].join("\n"));
1356
+ };
1357
+ const printSummary = (samples) => {
1358
+ if (samples.length === 0)
1359
+ return;
1360
+ const groups = new Map();
1361
+ for (const sample of samples) {
1362
+ const group = groups.get(sample.scenario) ?? [];
1363
+ group.push(sample);
1364
+ groups.set(sample.scenario, group);
1365
+ }
1366
+ console.log([
1367
+ "",
1368
+ "parent-upgrade-multi-summary",
1369
+ "scenario seeds viable usefulPromotedTrees upgrades probes activeUpgrades activeProbes activeGuardSkips branchGainAvg secondBatchP95DeltaAvg/Max promotedLatencyRegressionMax(tree/branch) dataFactorDeltaPctAvg controlBppDeltaPctAvg rootChildrenDeltaMax rootChildrenDeltaSumMax rootUploadPctDeltaMax maxPerPeer failures",
1370
+ ...[...groups.entries()].map(([scenario, group]) => {
1371
+ const dataFactorDeltas = group.map((sample) => sample.baseline.dataOverheadFactor > 0
1372
+ ? (100 *
1373
+ (sample.upgrade.dataOverheadFactor -
1374
+ sample.baseline.dataOverheadFactor)) /
1375
+ sample.baseline.dataOverheadFactor
1376
+ : NaN);
1377
+ const controlDeltas = group.map((sample) => sample.baseline.controlBpp > 0
1378
+ ? (100 * (sample.upgrade.controlBpp - sample.baseline.controlBpp)) /
1379
+ sample.baseline.controlBpp
1380
+ : NaN);
1381
+ const secondBatchP95Deltas = group.map((sample) => sample.baseline.secondBatchExpected > 0 ||
1382
+ sample.upgrade.secondBatchExpected > 0
1383
+ ? sample.upgrade.secondBatchLatencyP95 -
1384
+ sample.baseline.secondBatchLatencyP95
1385
+ : NaN);
1386
+ const rootChildrenDeltaMax = Math.max(...group.flatMap((sample) => sample.upgrade.trees.map((tree) => tree.treeRootChildren -
1387
+ sample.baseline.trees[tree.tree].treeRootChildren)));
1388
+ const rootChildrenDeltaSumMax = Math.max(...group.map((sample) => sample.upgrade.trees.reduce((sum, tree) => sum +
1389
+ tree.treeRootChildren -
1390
+ sample.baseline.trees[tree.tree].treeRootChildren, 0)));
1391
+ const rootUploadDeltaMax = Math.max(...group.flatMap((sample) => sample.upgrade.trees.map((tree) => tree.rootUploadFracPct -
1392
+ sample.baseline.trees[tree.tree].rootUploadFracPct)));
1393
+ return [
1394
+ scenario,
1395
+ group.length,
1396
+ `${group.filter((sample) => sample.failures.length === 0).length}/${group.length}`,
1397
+ group.reduce((sum, sample) => sum + sample.usefulPromotedTrees, 0),
1398
+ group.reduce((sum, sample) => sum + sample.upgrade.reparentUpgradeTotal, 0),
1399
+ group.reduce((sum, sample) => sum + sample.upgrade.parentProbeReqSentTotal, 0),
1400
+ group.reduce((sum, sample) => sum + sample.upgrade.publishActiveReparentUpgradeTotal, 0),
1401
+ group.reduce((sum, sample) => sum + sample.upgrade.publishActiveParentProbeReqSentTotal, 0),
1402
+ group.reduce((sum, sample) => sum + sample.upgrade.publishActiveGuardSkipsTotal, 0),
1403
+ fmt(avgFinite(group.map((sample) => sample.promotedBranchGainAvg)), 1),
1404
+ `${fmt(avgFinite(secondBatchP95Deltas), 1)}/${fmt(maxFinite(secondBatchP95Deltas), 1)}`,
1405
+ `${fmt(maxFinite(group.map((sample) => sample.promotedTreeSecondBatchLatencyP95RegressionMax)), 1)}/${fmt(maxFinite(group.map((sample) => sample.promotedBranchSecondBatchLatencyP95RegressionMax)), 1)}`,
1406
+ fmt(avgFinite(dataFactorDeltas), 1),
1407
+ fmt(avgFinite(controlDeltas), 1),
1408
+ rootChildrenDeltaMax,
1409
+ rootChildrenDeltaSumMax,
1410
+ fmt(rootUploadDeltaMax, 2),
1411
+ Math.max(...group.map((sample) => sample.upgrade.maxReparentUpgradePerPeer)),
1412
+ group.reduce((sum, sample) => sum + sample.failures.length, 0),
1413
+ ].join(" ");
1414
+ }),
1415
+ ].join("\n"));
1416
+ };
1417
+ const evaluateAggregate = (samples) => {
1418
+ const failures = [];
1419
+ const groups = new Map();
1420
+ for (const sample of samples) {
1421
+ const group = groups.get(sample.scenario) ?? [];
1422
+ group.push(sample);
1423
+ groups.set(sample.scenario, group);
1424
+ }
1425
+ return failures;
1426
+ };
1427
+ const printAggregateFailures = (failures) => {
1428
+ if (failures.length === 0)
1429
+ return;
1430
+ console.log([
1431
+ "",
1432
+ "parent-upgrade-multi-aggregate-failures",
1433
+ ...failures.map((f) => `${f.metric}: observed=${fmt(f.upgrade)} required=${fmt(f.limit)}`),
1434
+ ].join("\n"));
1435
+ };
1436
+ const compactTreeResult = (tree) => ({
1437
+ tree: tree.tree,
1438
+ active: tree.active,
1439
+ joinedCount: tree.joinedCount,
1440
+ subscriberCount: tree.subscriberCount,
1441
+ expected: tree.expected,
1442
+ delivered: tree.delivered,
1443
+ deliveredPct: tree.deliveredPct,
1444
+ deliveredWithinDeadlinePct: tree.deliveredWithinDeadlinePct,
1445
+ secondBatchExpected: tree.secondBatchExpected,
1446
+ secondBatchDeliveredWithinDeadlinePct: tree.secondBatchDeliveredWithinDeadlinePct,
1447
+ secondBatchLatencyP95: tree.secondBatchLatencyP95,
1448
+ reparentUpgradeTotal: tree.reparentUpgradeTotal,
1449
+ parentProbeReqSentTotal: tree.parentProbeReqSentTotal,
1450
+ parentShadowStartTotal: tree.parentShadowStartTotal,
1451
+ parentShadowPromoteTotal: tree.parentShadowPromoteTotal,
1452
+ publishActiveGuardSkipsTotal: tree.publishActiveReparentUpgradeSkipDataTotal +
1453
+ tree.publishActiveReparentUpgradeSkipRepairTotal +
1454
+ tree.publishActiveReparentUpgradeSkipQuietTotal,
1455
+ rootChildren: tree.treeRootChildren,
1456
+ rootUploadFracPct: tree.rootUploadFracPct,
1457
+ maxReparentUpgradePerPeer: tree.maxReparentUpgradePerPeer,
1458
+ duplicates: tree.duplicates,
1459
+ dataOverheadFactor: tree.dataOverheadFactor,
1460
+ controlBytesSent: tree.controlBytesSent,
1461
+ controlBytesSentTracker: tree.controlBytesSentTracker,
1462
+ controlBytesSentRepair: tree.controlBytesSentRepair,
1463
+ });
1464
+ const compactResult = (result) => ({
1465
+ params: {
1466
+ scenario: result.params.scenario,
1467
+ seed: result.params.seed,
1468
+ nodes: result.params.nodes,
1469
+ writers: result.params.writers,
1470
+ activeWriters: result.params.activeWriters,
1471
+ subscribersPerTree: result.params.subscribersPerTree,
1472
+ streamRxDelayMs: result.params.streamRxDelayMs,
1473
+ deadlineMs: result.params.deadlineMs,
1474
+ secondBatchMessages: result.params.secondBatchMessages,
1475
+ secondBatchSettleMs: result.params.secondBatchSettleMs,
1476
+ },
1477
+ joinedCount: result.joinedCount,
1478
+ subscriberSlots: result.subscriberSlots,
1479
+ expected: result.expected,
1480
+ delivered: result.delivered,
1481
+ deliveredPct: result.deliveredPct,
1482
+ deliveredWithinDeadlinePct: result.deliveredWithinDeadlinePct,
1483
+ secondBatchExpected: result.secondBatchExpected,
1484
+ secondBatchDeliveredWithinDeadlinePct: result.secondBatchDeliveredWithinDeadlinePct,
1485
+ secondBatchLatencyP95: result.secondBatchLatencyP95,
1486
+ reparentUpgradeTotal: result.reparentUpgradeTotal,
1487
+ parentProbeReqSentTotal: result.parentProbeReqSentTotal,
1488
+ parentShadowStartTotal: result.parentShadowStartTotal,
1489
+ parentShadowPromoteTotal: result.parentShadowPromoteTotal,
1490
+ publishActiveGuardSkipsTotal: result.publishActiveGuardSkipsTotal,
1491
+ activeGuardedTrees: result.activeGuardedTrees,
1492
+ treeLevelAvg: result.treeLevelAvg,
1493
+ treeLevelP95: result.treeLevelP95,
1494
+ rootChildrenSum: result.rootChildrenSum,
1495
+ rootChildrenMax: result.rootChildrenMax,
1496
+ rootUploadPctMax: result.rootUploadPctMax,
1497
+ maxReparentUpgradePerPeer: result.maxReparentUpgradePerPeer,
1498
+ duplicates: result.duplicates,
1499
+ dataOverheadFactor: result.dataOverheadFactor,
1500
+ controlBpp: result.controlBpp,
1501
+ trackerBpp: result.trackerBpp,
1502
+ repairBpp: result.repairBpp,
1503
+ network: result.network,
1504
+ trees: result.trees.map(compactTreeResult),
1505
+ });
1506
+ const writeJsonSummary = async (args, samples, aggregateFailures) => {
1507
+ if (!args.jsonOut)
1508
+ return;
1509
+ await mkdir(dirname(args.jsonOut), { recursive: true });
1510
+ await writeFile(args.jsonOut, JSON.stringify({
1511
+ kind: "fanout-tree-parent-upgrade-multi-eval",
1512
+ generatedAt: new Date().toISOString(),
1513
+ args,
1514
+ aggregateFailures,
1515
+ samples: samples.map((sample) => ({
1516
+ scenario: sample.scenario,
1517
+ seed: sample.seed,
1518
+ viable: sample.failures.length === 0,
1519
+ failures: sample.failures,
1520
+ usefulPromotedTrees: sample.usefulPromotedTrees,
1521
+ promotedBranchGainAvg: sample.promotedBranchGainAvg,
1522
+ promotedTreeSecondBatchLatencyP95RegressionMax: sample.promotedTreeSecondBatchLatencyP95RegressionMax,
1523
+ promotedBranchSecondBatchLatencyP95RegressionMax: sample.promotedBranchSecondBatchLatencyP95RegressionMax,
1524
+ backgroundSecondBatchLatencyP95RegressionMax: sample.backgroundSecondBatchLatencyP95RegressionMax,
1525
+ baseline: compactResult(sample.baseline),
1526
+ upgrade: compactResult(sample.upgrade),
1527
+ })),
1528
+ }, null, 2) + "\n");
1529
+ };
1530
+ const main = async () => {
1531
+ const args = parseArgs(process.argv.slice(2));
1532
+ const samples = [];
1533
+ let failureCount = 0;
1534
+ for (const scenario of args.scenarios) {
1535
+ for (const seed of args.seeds) {
1536
+ const baselineParams = resolveParams(scenario, seed, args, false);
1537
+ const upgradeParams = resolveParams(scenario, seed, args, true);
1538
+ let baseline;
1539
+ let upgrade;
1540
+ if (args.sameRunAB && isIdleScenario(scenario)) {
1541
+ console.log(`\n[multi-same-run-a/b] scenario=${scenario} seed=${seed}`);
1542
+ [baseline, upgrade] = await Promise.all([
1543
+ runMultiWriterSim(baselineParams),
1544
+ runMultiWriterSim(upgradeParams),
1545
+ ]);
1546
+ console.log(`\n[multi-baseline] scenario=${scenario} seed=${seed}`);
1547
+ console.log(formatResult(baseline));
1548
+ console.log(`\n[multi-parent-upgrade] scenario=${scenario} seed=${seed}`);
1549
+ }
1550
+ else {
1551
+ console.log(`\n[multi-baseline] scenario=${scenario} seed=${seed}`);
1552
+ baseline = await runMultiWriterSim(baselineParams);
1553
+ console.log(formatResult(baseline));
1554
+ console.log(`\n[multi-parent-upgrade] scenario=${scenario} seed=${seed}`);
1555
+ upgrade = await runMultiWriterSim(upgradeParams);
1556
+ }
1557
+ console.log(formatResult(upgrade));
1558
+ const evaluated = evaluateRun(scenario, baseline, upgrade, args);
1559
+ printComparison(scenario, seed, baseline, upgrade, evaluated.failures, evaluated.usefulPromotedTrees, evaluated.promotedBranchGainAvg, evaluated.promotedTreeSecondBatchLatencyP95RegressionMax, evaluated.promotedBranchSecondBatchLatencyP95RegressionMax);
1560
+ failureCount += evaluated.failures.length;
1561
+ samples.push({
1562
+ scenario,
1563
+ seed,
1564
+ baseline,
1565
+ upgrade,
1566
+ failures: evaluated.failures,
1567
+ usefulPromotedTrees: evaluated.usefulPromotedTrees,
1568
+ promotedBranchGainAvg: evaluated.promotedBranchGainAvg,
1569
+ promotedTreeSecondBatchLatencyP95RegressionMax: evaluated.promotedTreeSecondBatchLatencyP95RegressionMax,
1570
+ promotedBranchSecondBatchLatencyP95RegressionMax: evaluated.promotedBranchSecondBatchLatencyP95RegressionMax,
1571
+ backgroundSecondBatchLatencyP95RegressionMax: evaluated.backgroundSecondBatchLatencyP95RegressionMax,
1572
+ });
1573
+ }
1574
+ }
1575
+ printSummary(samples);
1576
+ const aggregateFailures = evaluateAggregate(samples);
1577
+ printAggregateFailures(aggregateFailures);
1578
+ await writeJsonSummary(args, samples, aggregateFailures);
1579
+ failureCount += aggregateFailures.length;
1580
+ if (args.strict && failureCount > 0) {
1581
+ process.exit(2);
1582
+ }
1583
+ };
1584
+ try {
1585
+ await main();
1586
+ }
1587
+ catch (err) {
1588
+ console.error(err?.stack || err?.message || String(err));
1589
+ process.exit(1);
1590
+ }
1591
+ //# sourceMappingURL=fanout-tree-parent-upgrade-multi-eval.js.map