@steve02081504/fount-p2p 0.0.30 → 0.0.32

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 (64) hide show
  1. package/AGENTS.md +79 -0
  2. package/docs/evfs.md +32 -0
  3. package/docs/infra.md +25 -0
  4. package/docs/mesh.md +67 -0
  5. package/docs/reputation.md +22 -0
  6. package/docs/runtime.md +62 -0
  7. package/docs/signaling.md +23 -0
  8. package/docs/transports.md +92 -0
  9. package/docs/wire.md +21 -0
  10. package/link/providers/webrtc.mjs +5 -2
  11. package/link/rtc/ice_local_hostname.mjs +15 -47
  12. package/link/rtc/polyfill.mjs +3 -15
  13. package/package.json +2 -9
  14. package/sim/AGENTS.md +29 -0
  15. package/sim/admission.mjs +56 -0
  16. package/sim/anomaly.mjs +39 -0
  17. package/sim/apply.mjs +77 -0
  18. package/sim/attack_space.mjs +113 -0
  19. package/sim/attacks.mjs +614 -0
  20. package/sim/behavior.mjs +78 -0
  21. package/sim/cli.mjs +282 -0
  22. package/sim/coevolution.mjs +245 -0
  23. package/sim/cold_start.md +13 -0
  24. package/sim/constants.mjs +44 -0
  25. package/sim/discovery.mjs +259 -0
  26. package/sim/duration.mjs +64 -0
  27. package/sim/graph_adj.mjs +28 -0
  28. package/sim/integrity.mjs +58 -0
  29. package/sim/metrics.mjs +423 -0
  30. package/sim/model.mjs +1176 -0
  31. package/sim/optimizer.mjs +250 -0
  32. package/sim/propagation.mjs +57 -0
  33. package/sim/report.mjs +176 -0
  34. package/sim/reputation_social.tunables.json +6 -0
  35. package/sim/rng.mjs +62 -0
  36. package/sim/scenarios.mjs +345 -0
  37. package/sim/sim_pool.mjs +169 -0
  38. package/sim/sim_worker.mjs +31 -0
  39. package/sim/social_reputation.mjs +30 -0
  40. package/sim/space.mjs +312 -0
  41. package/sim/test/admission.test.mjs +25 -0
  42. package/sim/test/attack_space.test.mjs +39 -0
  43. package/sim/test/behavior_vector.test.mjs +37 -0
  44. package/sim/test/coevolution.test.mjs +32 -0
  45. package/sim/test/cold_start.test.mjs +46 -0
  46. package/sim/test/defense.test.mjs +320 -0
  47. package/sim/test/discovery.test.mjs +68 -0
  48. package/sim/test/duration.test.mjs +20 -0
  49. package/sim/test/fidelity.test.mjs +151 -0
  50. package/sim/test/integrity.test.mjs +56 -0
  51. package/sim/test/optimizer.test.mjs +24 -0
  52. package/sim/test/propagation.test.mjs +29 -0
  53. package/sim/test/sim_model_regressions.test.mjs +110 -0
  54. package/sim/test/slow_drip.test.mjs +28 -0
  55. package/sim/test/smoke.test.mjs +52 -0
  56. package/sim/test/transport.test.mjs +55 -0
  57. package/sim/test/tunables_apply.test.mjs +64 -0
  58. package/sim/test/tunables_param_space.test.mjs +74 -0
  59. package/sim/test/vulnerability.test.mjs +127 -0
  60. package/sim/transport.mjs +135 -0
  61. package/sim/tunables_bundle.mjs +71 -0
  62. package/sim/vulnerability.mjs +532 -0
  63. package/deno.json +0 -7
  64. package/link/rtc/w3c_bridge.mjs +0 -152
package/sim/model.mjs ADDED
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * P2P 节点群体模拟回合引擎。
3
+ */
4
+ import {
5
+ applyDecayCollusionAfterSlashPure,
6
+ applyReputationResetToScoresPure,
7
+ applySubjectiveSlashPure,
8
+ bumpChunkStorageReputationPure,
9
+ bumpReputationOnRelayPure,
10
+ ensureReputationShape,
11
+ penalizeArchiveServeMismatchPure,
12
+ penalizeChunkStorageFailurePure,
13
+ recordGossipAllUnknownWantPure,
14
+ recordMessageRateViolationPure,
15
+ seedMemberReputationFromIntroducerPure,
16
+ } from '../reputation/engine.mjs'
17
+ import { REP_MAX } from '../reputation/math.mjs'
18
+ import { pickTop } from '../trust_graph/engine.mjs'
19
+ import {
20
+ resolveArchiveQuorumPeerMin,
21
+ resolveArchiveQuorumPeerStrictMin,
22
+ resolveMailboxRelayFanout,
23
+ resolveMailboxWantFanout,
24
+ } from '../trust_graph/resolve.mjs'
25
+
26
+ import { capMaliciousByPowBudget, honestJoinDelayPenalty, resolvePowFloorBits } from './admission.mjs'
27
+ import { attackReachesObserver, simIsPeerQuarantined, simObservePeerBehavior } from './anomaly.mjs'
28
+ import { normalizeAttackGenome } from './attack_space.mjs'
29
+ import { runAttack } from './attacks.mjs'
30
+ import { behaviorRoll, isQuietHonestBehavior, sampleBehavior } from './behavior.mjs'
31
+ import {
32
+ FEDERATION_SINGLE_PEER_CAP,
33
+ FEDERATION_SINGLE_PEER_SCALE,
34
+ MAILBOX_EXCESS_HOP_PENALTY,
35
+ MAILBOX_FANOUT_COST_SCALE,
36
+ MAILBOX_HOP_COST_EXPONENT,
37
+ MAILBOX_HOP_COST_SCALE,
38
+ MAILBOX_REACH_LOW_HOP_FACTOR,
39
+ MAILBOX_REACH_MID_HOP_FACTOR,
40
+ MAILBOX_RELAY_COST_DIVISOR,
41
+ } from './constants.mjs'
42
+ import { createDiscoveryState, coldStartDiscoveryJoin, discoveryReach, initObserverDiscovery, recoverDiscoveryFromAnchors } from './discovery.mjs'
43
+ import { buildRankedNeighborAdj } from './graph_adj.mjs'
44
+ import { blendArchiveQuorumAccuracy, integrityDefendsAgainst, observerHasLocalReplica } from './integrity.mjs'
45
+ import { createPropagationState, tickPropagation } from './propagation.mjs'
46
+ import { createRng, fakeNodeHash, pickMany, pickOne, randInt } from './rng.mjs'
47
+ import { applyFollowedBlockSignalPure } from './social_reputation.mjs'
48
+ import { createTransportState, transportMetrics } from './transport.mjs'
49
+
50
+ /** @typedef {import('./attacks.mjs').AttackKind} AttackKind */
51
+ /** @typedef {import('./tunables_bundle.mjs').TunablesBundle} TunablesBundle */
52
+
53
+ /** @typedef {import('./behavior.mjs').NodeBehavior} NodeBehavior */
54
+
55
+ /** reach 型攻击:仅此三类写入 reachCollapse 伤害 */
56
+ const REACH_ATTACK_KINDS = new Set(['eclipse', 'targeted_eclipse', 'hint_poisoner'])
57
+
58
+ /** 会修改 discovery 拓扑的攻击(同回合内需刷新 reach 缓存) */
59
+ const DISCOVERY_MUTATING_ATTACKS = new Set(['eclipse', 'targeted_eclipse'])
60
+
61
+ /**
62
+ * @param {object} simulationContext 仿真上下文
63
+ * @param {import('./model.mjs').SimObserver} obs 观察者
64
+ * @param {import('./model.mjs').SimNode} m 恶意节点
65
+ * @param {number} reachHarm cleanReach 减 discReach
66
+ * @returns {number} 归因 reach 伤害
67
+ */
68
+ function attackReachHarmForNode(simulationContext, obs, m, reachHarm) {
69
+ if (!REACH_ATTACK_KINDS.has(m.attack ?? '') || reachHarm <= 0) return 0
70
+ if (m.attack === 'hint_poisoner')
71
+ return obs.injectedHints.some(h => h.nodeHash === m.id && h.source === 'poison') ? reachHarm : 0
72
+ const poisonSet = simulationContext.discovery?.poisonedByAttacker?.get(obs.id)
73
+ return poisonSet?.has(m.id) ? reachHarm : 0
74
+ }
75
+
76
+ /**
77
+ * @typedef {{
78
+ * id: string,
79
+ * kind: 'honest' | 'relay' | 'lurker' | 'malicious',
80
+ * behavior?: NodeBehavior,
81
+ * attack?: AttackKind,
82
+ * clusterId?: string,
83
+ * introducerId?: string,
84
+ * whitewashStage?: number,
85
+ * newcomer?: boolean,
86
+ * stolenFromId?: string,
87
+ * sleeperTurnRound?: number,
88
+ * eclipseTargetId?: string,
89
+ * compromised?: boolean,
90
+ * }} SimNode
91
+ */
92
+
93
+ /**
94
+ * @typedef {{
95
+ * id: string,
96
+ * reputation: import('../node/reputation_store.mjs').ReputationFile,
97
+ * trustedPeers: string[],
98
+ * explorePeers: string[],
99
+ * injectedHints: Array<{ nodeHash: string, source: string, weight?: number }>,
100
+ * }} SimObserver
101
+ */
102
+
103
+ /** @type {import('./behavior.mjs').BehaviorDist} */
104
+ const DEFAULT_SCENARIO_BEHAVIOR = {}
105
+
106
+ /**
107
+ * @param {NodeBehavior} behavior 行为向量
108
+ * @param {() => number} rng 随机源
109
+ * @returns {'social' | 'chat'} 本回合活跃侧
110
+ */
111
+ function activeSideFromBehavior(behavior, rng) {
112
+ const socialWeight = behavior.postRate + behavior.likeRate + behavior.replyRate + behavior.mentionRate
113
+ const chatWeight = behavior.relayRate + behavior.chunkServeRate + behavior.archiveSubmitRate
114
+ const total = socialWeight + chatWeight
115
+ if (total <= 0) return rng() < 0.5 ? 'social' : 'chat'
116
+ return rng() < socialWeight / total ? 'social' : 'chat'
117
+ }
118
+
119
+ /**
120
+ * 每回合更新 churn/掉线集合。
121
+ * @param {object} simulationContext 仿真上下文
122
+ * @param {SimNode[]} nodes 全部节点
123
+ * @param {() => number} rng 随机源
124
+ * @param {import('./scenarios.mjs').SimScenario} scenario 场景
125
+ * @returns {void}
126
+ */
127
+ function updateChurnOffline(simulationContext, nodes, rng, scenario) {
128
+ const churnRate = scenario.churnRate ?? 0
129
+ const offlineRate = scenario.offlineRate ?? 0
130
+ if (!churnRate && !offlineRate) return
131
+
132
+ for (const id of [...simulationContext.offlineSet])
133
+ if (rng() < churnRate * 0.6)
134
+ simulationContext.offlineSet.delete(id)
135
+
136
+ for (const n of nodes) {
137
+ if (n.kind === 'malicious' || n.newcomer) continue
138
+ if (rng() < offlineRate)
139
+ simulationContext.offlineSet.add(n.id)
140
+ }
141
+
142
+ // 定向 eclipse 分区:受害节点强制离线
143
+ if (simulationContext.eclipseVictims)
144
+ for (const id of simulationContext.eclipseVictims)
145
+ simulationContext.offlineSet.add(id)
146
+ }
147
+
148
+ /**
149
+ * 给诚实节点注入瞬时误伤信号(hide 阈值抬高时 falsePositive 自然升高)。
150
+ * @param {object} simulationContext 仿真上下文
151
+ * @param {SimObserver} obs 观察者
152
+ * @param {SimNode[]} activeHonest 活跃诚实节点
153
+ * @param {() => number} rng 随机源
154
+ * @param {TunablesBundle} tunables 参数
155
+ * @returns {void}
156
+ */
157
+ function injectTransientFalsePositive(simulationContext, obs, activeHonest, rng, tunables) {
158
+ if (rng() > 0.12) return
159
+ const victim = pickOne(rng, activeHonest.filter(n => n.id !== obs.id))
160
+ if (!victim) return
161
+ applySubjectiveSlashPure(
162
+ obs.reputation,
163
+ victim.id,
164
+ obs.id,
165
+ tunables.reputation.slashUnverifiedDefaultClaim * 0.25,
166
+ false,
167
+ tunables.reputation,
168
+ )
169
+ }
170
+
171
+ /**
172
+ * @param {import('./scenarios.mjs').SimScenario} scenario 场景
173
+ * @param {number} seed 种子
174
+ * @param {TunablesBundle} tunables 候选参数
175
+ * @param {import('./attack_space.mjs').AttackGenome} [attackGenome] 攻击基因
176
+ * @returns {{ nodes: SimNode[], observers: SimObserver[], inviteEdges: Array<{ from: string, to: string }>, simulationContext: object }} 初始世界状态
177
+ */
178
+ export function buildWorld(scenario, seed, tunables, attackGenome) {
179
+ const rng = createRng(seed)
180
+ const behaviorDist = scenario.behaviorDist ?? DEFAULT_SCENARIO_BEHAVIOR
181
+ /** @type {SimNode[]} */
182
+ const nodes = []
183
+ /** @type {Array<{ from: string, to: string }>} */
184
+ const inviteEdges = []
185
+ let nodeIndex = 1
186
+ /** @type {SimNode[]} */
187
+ const honestSoFar = []
188
+
189
+ for (let i = 0; i < scenario.honestCount; i++) {
190
+ const id = fakeNodeHash(nodeIndex++)
191
+ const introducer = honestSoFar[randInt(rng, 0, Math.max(1, honestSoFar.length))]?.id
192
+ const node = {
193
+ id,
194
+ kind: 'honest',
195
+ behavior: sampleBehavior(rng, behaviorDist),
196
+ introducerId: introducer,
197
+ }
198
+ nodes.push(node)
199
+ honestSoFar.push(node)
200
+ if (introducer)
201
+ inviteEdges.push({ from: introducer, to: id })
202
+ }
203
+
204
+ for (let i = 0; i < (scenario.relayCount ?? 0); i++) {
205
+ const id = fakeNodeHash(nodeIndex++)
206
+ const introducer = pickOne(rng, honestSoFar)?.id
207
+ nodes.push({ id, kind: 'relay', introducerId: introducer })
208
+ if (introducer)
209
+ inviteEdges.push({ from: introducer, to: id })
210
+ }
211
+
212
+ for (let i = 0; i < (scenario.lurkerCount ?? 0); i++) {
213
+ const id = fakeNodeHash(nodeIndex++)
214
+ const introducer = pickOne(rng, honestSoFar)?.id
215
+ nodes.push({
216
+ id,
217
+ kind: 'lurker',
218
+ behavior: sampleBehavior(rng, { postRate: { mean: 0.05, max: 0.12 }, chunkServeRate: { mean: 0.45, min: 0.2 } }),
219
+ introducerId: introducer,
220
+ })
221
+ if (introducer)
222
+ inviteEdges.push({ from: introducer, to: id })
223
+ }
224
+
225
+ // 共谋 / 洗白簇用**链式邀请**:簇首由诚实节点引入,其余成员由同簇上一位引入,
226
+ // 形成深度 ≥3 的邀请链,这样 applyDecayCollusionAfterSlash 的多跳衰减才有意义。
227
+ /** @type {Map<string, string>} */
228
+ const clusterLast = new Map()
229
+ /** @type {SimNode[]} */
230
+ const eclipseTargets = pickMany(
231
+ rng,
232
+ nodes.filter(n => n.kind === 'honest' && !n.compromised),
233
+ scenario.eclipseTargetCount ?? 0,
234
+ )
235
+
236
+ for (const [attack, count] of Object.entries(scenario.attacks)) {
237
+ const rounds = scenario.rounds ?? 40
238
+ const powBits = resolvePowFloorBits(tunables.admission)
239
+ const powCapped = attack === 'sybil' || attack === 'whitewasher' || attack === 'rep_pump'
240
+ const allowed = powCapped ? capMaliciousByPowBudget(count || 0, powBits, rounds) : count || 0
241
+ for (let i = 0; i < allowed; i++) {
242
+ const clusterId = `${attack}-${Math.floor(i / 4)}`
243
+ const chained = attack === 'collusion' || attack === 'whitewasher' || attack === 'rep_pump'
244
+
245
+ // key_thief:盗用高信誉诚实节点的 nodeHash(同 id 身份)
246
+ if (attack === 'key_thief') {
247
+ const victim = pickOne(rng, nodes.filter(n => n.kind === 'honest' && !n.compromised && !n.newcomer))
248
+ if (!victim) continue
249
+ victim.compromised = true
250
+ victim.kind = 'malicious'
251
+ victim.attack = 'key_thief'
252
+ victim.stolenFromId = victim.id
253
+ continue
254
+ }
255
+
256
+ const id = fakeNodeHash(nodeIndex++)
257
+ const introducer = chained && clusterLast.has(clusterId)
258
+ ? clusterLast.get(clusterId)
259
+ : pickOne(rng, nodes.filter(n => n.kind === 'honest' && !n.compromised))?.id || pickOne(rng, nodes)?.id
260
+
261
+ /** @type {SimNode} */
262
+ const malNode = {
263
+ id,
264
+ kind: 'malicious',
265
+ attack: /** @type {AttackKind} */ attack,
266
+ clusterId,
267
+ introducerId: introducer,
268
+ }
269
+
270
+ if (attack === 'sleeper')
271
+ malNode.sleeperTurnRound = scenario.sleeperTurnRound ?? 15
272
+ if (attack === 'targeted_eclipse')
273
+ malNode.eclipseTargetId = eclipseTargets[i % Math.max(1, eclipseTargets.length)]?.id
274
+
275
+ nodes.push(malNode)
276
+ if (introducer)
277
+ inviteEdges.push({ from: introducer, to: id })
278
+ if (chained)
279
+ clusterLast.set(clusterId, id)
280
+ }
281
+ }
282
+
283
+ // 新人节点:诚实、无介绍人、不参与任何回合交互,因此观察者始终对其「没有打过分」,
284
+ // 用来检验 rosterDefaultScore(名册里对陌生人默认信任)这一旋钮的两面性。
285
+ for (let i = 0; i < (scenario.newcomerCount ?? 0); i++)
286
+ nodes.push({ id: fakeNodeHash(nodeIndex++), kind: 'honest', behavior: sampleBehavior(rng, behaviorDist), newcomer: true })
287
+
288
+ const observers = nodes
289
+ .filter(n => n.kind === 'honest' && !n.newcomer)
290
+ .map(n => ({
291
+ id: n.id,
292
+ reputation: ensureReputationShape({ byNodeHash: {}, wantUnknownHits: [], relayBumpSeen: [] }),
293
+ trustedPeers: nodes.filter(x => (x.kind === 'honest' || x.kind === 'relay') && !x.newcomer && x.id !== n.id).slice(0, 4).map(x => x.id),
294
+ explorePeers: nodes.filter(x => !x.newcomer && x.id !== n.id).slice(0, 8).map(x => x.id),
295
+ injectedHints: [],
296
+ }))
297
+
298
+ // K=0 冷启动观察者(scenario.coldStartObserver 开启时):无 trusted,仅靠 explore 槽入网
299
+ if (scenario.coldStartObserver)
300
+ observers.push({
301
+ id: fakeNodeHash(nodeIndex++),
302
+ reputation: ensureReputationShape({ byNodeHash: {}, wantUnknownHits: [], relayBumpSeen: [] }),
303
+ trustedPeers: [],
304
+ explorePeers: [],
305
+ injectedHints: [],
306
+ coldStart: true,
307
+ })
308
+
309
+ // 观察者对自己满信任;直信对端从自己按 introducerSeedEdge 继承初始信誉
310
+ // (旧实现里 introducer=观察者自身且其分=0,导致 0.8 边权被乘成 0、整段播种是空操作)。
311
+ for (const obs of observers) {
312
+ obs.reputation.byNodeHash[obs.id] = { score: REP_MAX }
313
+ for (const peer of obs.trustedPeers)
314
+ seedMemberReputationFromIntroducerPure(obs.reputation, peer, obs.id, undefined, tunables.reputation)
315
+ }
316
+
317
+ /** @type {Map<string, SimNode[]>} */
318
+ const collusionRingByCluster = new Map()
319
+ for (const n of nodes) {
320
+ if (n.attack !== 'collusion') continue
321
+ const key = n.clusterId ?? n.id
322
+ if (!collusionRingByCluster.has(key)) collusionRingByCluster.set(key, [])
323
+ collusionRingByCluster.get(key).push(n)
324
+ }
325
+
326
+ /** @type {Map<string, SimNode[]>} */
327
+ const sybilClusterByCluster = new Map()
328
+ for (const n of nodes) {
329
+ if (n.attack !== 'sybil' && n.attack !== 'rep_pump') continue
330
+ const key = n.clusterId ?? n.id
331
+ if (!sybilClusterByCluster.has(key)) sybilClusterByCluster.set(key, [])
332
+ sybilClusterByCluster.get(key).push(n)
333
+ }
334
+
335
+ /** @type {Map<string, SimNode[]>} */
336
+ const eclipseClusterByCluster = new Map()
337
+ for (const n of nodes) {
338
+ if (n.attack !== 'eclipse' && n.attack !== 'targeted_eclipse') continue
339
+ const key = n.clusterId ?? n.id
340
+ if (!eclipseClusterByCluster.has(key)) eclipseClusterByCluster.set(key, [])
341
+ eclipseClusterByCluster.get(key).push(n)
342
+ }
343
+
344
+ const honestNodes = nodes.filter(n => n.kind === 'honest')
345
+
346
+ /**
347
+ * @param {SimNode} node Sybil 节点
348
+ * @returns {SimNode[]} 同簇 Sybil 节点
349
+ */
350
+ function sybilCluster(node) {
351
+ return sybilClusterByCluster.get(node.clusterId ?? node.id) ?? []
352
+ }
353
+
354
+ /**
355
+ * @param {SimNode} node eclipse 节点
356
+ * @returns {SimNode[]} 同簇 eclipse 节点
357
+ */
358
+ function eclipseCluster(node) {
359
+ return eclipseClusterByCluster.get(node.clusterId ?? node.id) ?? [node]
360
+ }
361
+
362
+ /**
363
+ * @param {SimNode} node 共谋节点
364
+ * @returns {SimNode[]} 同环共谋节点
365
+ */
366
+ function collusionRing(node) {
367
+ return collusionRingByCluster.get(node.clusterId ?? node.id) ?? []
368
+ }
369
+
370
+ const simulationContext = {
371
+ nodes,
372
+ observers,
373
+ inviteEdges,
374
+ now: Date.now(),
375
+ offlineSet: new Set(),
376
+ eclipseVictims: new Set(),
377
+ equivocationByObserver: new Map(),
378
+ churnReachAccum: 0,
379
+ churnMailboxCostAccum: 0,
380
+ churnReachRounds: 0,
381
+ sleeperTurnRound: scenario.sleeperTurnRound ?? 15,
382
+ scenario,
383
+ attackGenome: normalizeAttackGenome(attackGenome),
384
+ discovery: createDiscoveryState(),
385
+ transportByObserver: new Map(),
386
+ verifiedForgeryByObserver: new Map(),
387
+ propagationByObserver: new Map(),
388
+ engine: {
389
+ bumpReputationOnRelayPure,
390
+ bumpChunkStorageReputationPure,
391
+ penalizeArchiveServeMismatchPure,
392
+ penalizeChunkStorageFailurePure,
393
+ recordMessageRateViolationPure,
394
+ recordGossipAllUnknownWantPure,
395
+ applySubjectiveSlashPure,
396
+ applyDecayCollusionAfterSlashPure,
397
+ seedMemberReputationFromIntroducerPure,
398
+ },
399
+ socialEngine: { applyFollowedBlockSignalPure },
400
+ honestNodes,
401
+ sybilCluster,
402
+ eclipseCluster,
403
+ collusionRing,
404
+ sybilClusterByCluster,
405
+ eclipseClusterByCluster,
406
+ collusionRingByCluster,
407
+ }
408
+
409
+ const roster = nodes.filter(n => !n.newcomer).map(n => n.id)
410
+ for (const obs of observers) {
411
+ simulationContext.propagationByObserver.set(obs.id, createPropagationState())
412
+ initObserverDiscovery(simulationContext.discovery, obs.id, obs.trustedPeers, roster, rng, 8, !!obs.coldStart)
413
+ const transport = createTransportState()
414
+ for (const peerId of obs.trustedPeers)
415
+ transport.trustedPeers.add(peerId)
416
+ simulationContext.transportByObserver.set(obs.id, transport)
417
+ }
418
+
419
+ return { nodes, observers, inviteEdges, simulationContext }
420
+ }
421
+
422
+ /**
423
+ * @param {object} simulationContext 仿真上下文
424
+ * @param {SimNode} peer 友善节点
425
+ * @param {SimObserver} observer 观察者
426
+ * @param {'social' | 'chat'} side 活跃侧
427
+ * @param {number} round 回合
428
+ * @param {TunablesBundle} tunables 参数
429
+ * @param {() => number} rng 随机源
430
+ * @returns {void}
431
+ */
432
+ function runFriendlyBehavior(simulationContext, peer, observer, side, round, tunables, rng) {
433
+ if (peer.kind === 'relay') {
434
+ bumpReputationOnRelayPure(observer.reputation, peer.id, `relay:${round}:${peer.id}`, simulationContext.now, tunables.reputation)
435
+ return
436
+ }
437
+ if (peer.kind === 'lurker') {
438
+ if (peer.behavior && behaviorRoll(rng, peer.behavior, 'chunkServeRate'))
439
+ bumpChunkStorageReputationPure(observer.reputation, peer.id, tunables.reputation)
440
+ return
441
+ }
442
+ if (peer.kind !== 'honest' || !peer.behavior) return
443
+
444
+ if (side === 'social') {
445
+ if (behaviorRoll(rng, peer.behavior, 'burstPostRate'))
446
+ recordMessageRateViolationPure(observer.reputation, peer.id, tunables.reputation, 0.35)
447
+ const other = simulationContext.nodes.find(n => n.kind === 'honest' && n.id !== peer.id && n.id !== observer.id)
448
+ if (other && behaviorRoll(rng, peer.behavior, 'blockProneness'))
449
+ applyFollowedBlockSignalPure(
450
+ observer.reputation,
451
+ {
452
+ followerNodeHash: peer.id,
453
+ targetNodeHash: other.id,
454
+ voterKey: `${peer.id}entity`,
455
+ action: 'unblock',
456
+ selfTrust: false,
457
+ },
458
+ simulationContext.now,
459
+ tunables.social,
460
+ )
461
+ }
462
+ else {
463
+ if (behaviorRoll(rng, peer.behavior, 'relayRate'))
464
+ bumpReputationOnRelayPure(observer.reputation, peer.id, `chat:${round}:${peer.id}`, simulationContext.now, tunables.reputation)
465
+ if (behaviorRoll(rng, peer.behavior, 'chunkServeRate'))
466
+ bumpChunkStorageReputationPure(observer.reputation, peer.id, tunables.reputation)
467
+ }
468
+ }
469
+
470
+ /**
471
+ * @param {SimObserver} obs 观察者
472
+ * @param {SimNode[]} nodes 全部节点
473
+ * @param {TunablesBundle} tunables 参数
474
+ * @param {(id: string) => number} scoreOf 信誉分
475
+ * @param {Set<string>} [offlineSet] 当前离线节点
476
+ * @returns {{ reach: number, cost: number, overload: number }} 可达率、成本比、带宽超载率
477
+ */
478
+ function simulateMailbox(obs, nodes, tunables, scoreOf, offlineSet = new Set()) {
479
+ const { maxHop } = tunables.mailbox
480
+ const online = nodes.filter(n => !offlineSet.has(n.id))
481
+ const fanout = resolveMailboxRelayFanout(online.length, tunables.mailbox)
482
+ const wantFanout = resolveMailboxWantFanout(online.length, tunables.mailbox)
483
+ const onlineIds = online.map(n => n.id)
484
+ const friendly = new Set(online.filter(n => n.kind === 'honest' || n.kind === 'relay' || n.kind === 'lurker').map(n => n.id))
485
+ const adj = buildRankedNeighborAdj(onlineIds, id => scoreOf(id), fanout)
486
+
487
+ if (offlineSet.has(obs.id))
488
+ return { reach: 0, cost: 0, overload: 0 }
489
+
490
+ const visited = new Set([obs.id])
491
+ const reachedFriendly = new Set()
492
+ /** @type {string[]} */
493
+ let frontier = [obs.id]
494
+ let hops = 0
495
+ let relaySteps = 0
496
+ while (frontier.length && hops < maxHop) {
497
+ /** @type {string[]} */
498
+ const next = []
499
+ for (const id of frontier) {
500
+ const peers = adj.get(id) ?? []
501
+ for (const peer of peers.slice(0, wantFanout)) {
502
+ relaySteps++
503
+ if (visited.has(peer)) continue
504
+ visited.add(peer)
505
+ next.push(peer)
506
+ if (friendly.has(peer))
507
+ reachedFriendly.add(peer)
508
+ }
509
+ }
510
+ frontier = next
511
+ hops++
512
+ }
513
+
514
+ const totalFriendly = Math.max(1, friendly.size - (friendly.has(obs.id) ? 1 : 0))
515
+ let reach = Math.min(1, reachedFriendly.size / totalFriendly)
516
+ const offlineFraction = offlineSet.size / Math.max(1, nodes.length)
517
+ let minHopsNeeded = maxHop <= 1 ? 2 : 1
518
+ if (offlineFraction > 0.05) {
519
+ minHopsNeeded = Math.max(minHopsNeeded, Math.ceil(1 + offlineFraction * 6))
520
+ if (maxHop < minHopsNeeded)
521
+ reach *= maxHop / minHopsNeeded
522
+ }
523
+ if (maxHop <= 1)
524
+ reach *= MAILBOX_REACH_LOW_HOP_FACTOR
525
+ else if (maxHop === 2)
526
+ reach *= MAILBOX_REACH_MID_HOP_FACTOR
527
+ const hopFactor = hops > 0 ? 1 + MAILBOX_HOP_COST_SCALE * Math.pow(hops / Math.max(1, maxHop), MAILBOX_HOP_COST_EXPONENT) * maxHop : 1
528
+ const fanoutFactor = 1 + MAILBOX_FANOUT_COST_SCALE * Math.max(0, wantFanout - 3)
529
+ const excessHopPenalty = 1 + MAILBOX_EXCESS_HOP_PENALTY * Math.max(0, maxHop - minHopsNeeded - 1)
530
+ const cost = Math.min(3, (visited.size / totalFriendly) * hopFactor * fanoutFactor * excessHopPenalty * (1 + relaySteps / (totalFriendly * MAILBOX_RELAY_COST_DIVISOR)))
531
+ const budget = Math.max(1, Number(tunables.mailbox.networkBudgetPerRound ?? 64)
532
+ + Math.ceil(online.length * Number(tunables.mailbox.networkBudgetPerPeerRatio ?? 2)))
533
+ const overload = relaySteps > budget ? Math.min(1, (relaySteps - budget) / budget) : 0
534
+ if (overload > 0)
535
+ reach *= Math.max(0, 1 - overload)
536
+ return { reach, cost, overload }
537
+ }
538
+
539
+ /**
540
+ * 归档摘要仲裁正确率,两面建模:
541
+ * - **安全性**:防御弱时伪造者未被扣分 → 排进 quorum top → 正确票不足 → 准确率下降。
542
+ * - **活性**:小群里能提交摘要的诚实节点有限(≤ groupSize),strictMin 定得过高就永远
543
+ * 凑不齐 quorum → 准确率随缺口线性下降。
544
+ * - **单点独裁**:strictMin=1 时高信誉伪造者/等价欺骗者可独断 digest。
545
+ * @param {SimNode[]} nodes 全部节点
546
+ * @param {TunablesBundle} tunables 参数
547
+ * @param {(id: string) => number} scoreOf 信誉分
548
+ * @param {number} groupSize 群规模
549
+ * @param {boolean} [hasEquivocation] 是否存在 digest 等价欺骗
550
+ * @returns {number} 仲裁正确率 0..1
551
+ */
552
+ function simulateArchiveQuorum(nodes, tunables, scoreOf, groupSize, hasEquivocation = false) {
553
+ const honestSubmitters = nodes.filter(n => (n.kind === 'honest' && !n.newcomer && !n.compromised) || n.kind === 'relay')
554
+ const forgers = nodes.filter(n => n.attack === 'archive_forger' || n.attack === 'equivocator')
555
+ if (!honestSubmitters.length && !forgers.length) return 1
556
+
557
+ const peerMin = resolveArchiveQuorumPeerMin(groupSize, tunables.archive)
558
+ const strictMin = resolveArchiveQuorumPeerStrictMin(groupSize, tunables.archive)
559
+
560
+ const reachable = Math.max(2, Math.round(groupSize / 2))
561
+ const availHonest = Math.min(honestSubmitters.length, reachable)
562
+ const availForgers = Math.min(forgers.length, reachable)
563
+
564
+ const quorum = [...honestSubmitters.slice(0, availHonest), ...forgers.slice(0, availForgers)]
565
+ .sort((a, b) => scoreOf(b.id) - scoreOf(a.id))
566
+ .slice(0, strictMin)
567
+ const correct = quorum.filter(n => n.attack !== 'archive_forger' && n.attack !== 'equivocator').length
568
+
569
+ const liveness = Math.min(1, quorum.length / strictMin)
570
+ const peerOk = Math.min(1, correct / Math.max(1, peerMin))
571
+ let safety = quorum.length ? correct / quorum.length : 1
572
+
573
+ const forgerPool = availHonest + availForgers
574
+ const forgerDensity = forgerPool > 0 ? availForgers / forgerPool : 0
575
+ const byzantineNeed = Math.max(2, Math.ceil(strictMin * 0.6))
576
+ const crossVerify = strictMin >= byzantineNeed
577
+ ? 1
578
+ : Math.max(0.05, strictMin / byzantineNeed)
579
+ safety *= crossVerify
580
+
581
+ if (forgers.length) {
582
+ const unsafeRatio = forgerDensity * (3 / Math.max(1, strictMin))
583
+ safety *= Math.max(0.05, 1 - unsafeRatio)
584
+ }
585
+
586
+ // strictMin=1 单点独裁:quorum 仅一人且为伪造者 → 安全性归零
587
+ if (strictMin === 1 && quorum.length === 1) {
588
+ const sole = quorum[0]
589
+ if (sole.attack === 'archive_forger' || sole.attack === 'equivocator')
590
+ safety = 0
591
+ }
592
+
593
+ // digest 等价欺骗:strictMin 过低时无法交叉验证
594
+ if (hasEquivocation && strictMin < 2)
595
+ safety *= 0.15
596
+ else if (hasEquivocation && quorum.some(n => n.attack === 'equivocator'))
597
+ safety *= Math.max(0.2, correct / Math.max(1, quorum.length))
598
+
599
+ return liveness * peerOk * safety
600
+ }
601
+
602
+ /**
603
+ * @param {SimNode} node 节点
604
+ * @param {(id: string) => number} scoreOf 信誉分
605
+ * @param {number} hideThreshold 隐藏阈值
606
+ * @returns {boolean} 信誉是否被保全(仅看分数,不看 fanout 名额)
607
+ */
608
+ function isNodePreserved(node, scoreOf, hideThreshold) {
609
+ return scoreOf(node.id) >= hideThreshold
610
+ }
611
+
612
+ /**
613
+ * @param {SimNode} node 节点
614
+ * @param {(id: string) => number} scoreOf 信誉分
615
+ * @param {number} hideThreshold 隐藏阈值
616
+ * @param {number} honestMedian 诚实节点信誉中位数
617
+ * @returns {boolean} 是否被恶意抑制(仅看主观信誉,与 topK 名额解耦)
618
+ */
619
+ function isMaliciousSuppressed(node, scoreOf, hideThreshold, honestMedian) {
620
+ const score = scoreOf(node.id)
621
+ return score < hideThreshold && score < honestMedian
622
+ }
623
+
624
+ /** 理想 mailbox 参考(与候选参数解耦,用于 churn 归一化分母) */
625
+ const IDEAL_MAILBOX_ONLINE_REF = 20
626
+
627
+ /**
628
+ * @param {TunablesBundle} tunables 候选参数
629
+ * @returns {TunablesBundle} 理想可达用的 mailbox 参考
630
+ */
631
+ function idealReachTunables(tunables) {
632
+ return {
633
+ ...tunables,
634
+ mailbox: {
635
+ ...tunables.mailbox,
636
+ maxHop: 6,
637
+ relayFanoutTrusted: resolveMailboxRelayFanout(IDEAL_MAILBOX_ONLINE_REF, tunables.mailbox),
638
+ wantFanout: resolveMailboxWantFanout(IDEAL_MAILBOX_ONLINE_REF, tunables.mailbox),
639
+ },
640
+ }
641
+ }
642
+
643
+ /**
644
+ * 联邦扇出冗余:在线诚实/relay 名额越多,饱和式覆盖越高;K=0 则归零。
645
+ * @param {number} topLiveCount 在线且诚实/relay 的 top 名额数
646
+ * @param {number} honestRelayOnline 在线诚实+relay 总数
647
+ * @param {number} [churnStress=0] churn+offline 强度 0..1
648
+ * @returns {number} 0..1 覆盖度
649
+ */
650
+ function federationSaturatingReach(topLiveCount, honestRelayOnline, churnStress = 0) {
651
+ if (topLiveCount <= 0) return 0
652
+ const pSingle = Math.min(FEDERATION_SINGLE_PEER_CAP, FEDERATION_SINGLE_PEER_SCALE / Math.max(1, honestRelayOnline))
653
+ let reach = 1 - (1 - pSingle) ** topLiveCount
654
+ const minSlots = Math.max(2, Math.ceil(1 + churnStress * 10))
655
+ if (topLiveCount < minSlots)
656
+ reach *= topLiveCount / minSlots
657
+ return reach
658
+ }
659
+
660
+ /**
661
+ * @param {import('./scenarios.mjs').SimScenario} scenario 场景
662
+ * @param {number} seed 种子
663
+ * @param {TunablesBundle} tunables 候选参数
664
+ * @param {import('./attack_space.mjs').AttackGenome} [attackGenome] 攻击基因
665
+ * @returns {import('./metrics.mjs').SimSnapshot} 仿真结束快照
666
+ */
667
+ export function runSimulation(scenario, seed, tunables, attackGenome) {
668
+ const { nodes, observers, inviteEdges, simulationContext } = buildWorld(scenario, seed, tunables, attackGenome)
669
+ const rng = createRng((seed + 0x9E3779B9) >>> 0)
670
+ const rounds = scenario.rounds ?? 40
671
+ const groupSize = scenario.groupSize ?? 8
672
+ const malicious = nodes.filter(n => n.kind === 'malicious')
673
+ const honest = nodes.filter(n => n.kind === 'honest')
674
+ const activeHonest = honest.filter(n => !n.newcomer)
675
+ const friendly = nodes.filter(n => n.kind !== 'malicious' && !n.newcomer)
676
+ const collusionRing = malicious.filter(n => n.attack === 'collusion')
677
+ const verifiableBad = malicious.filter(n => n.attack === 'archive_forger' || n.attack === 'lazy_chunk')
678
+ const socialConfirmable = malicious.filter(n => n.attack === 'spammer' || n.attack === 'social_mob')
679
+
680
+ for (let round = 0; round < rounds; round++) {
681
+ simulationContext.now += 60_000
682
+ simulationContext.round = round
683
+ updateChurnOffline(simulationContext, nodes, rng, scenario)
684
+ const onlineNodes = nodes.filter(n => !simulationContext.offlineSet.has(n.id))
685
+ const onlineFriendlyIds = onlineNodes
686
+ .filter(n => n.kind === 'honest' || n.kind === 'relay' || n.kind === 'lurker')
687
+ .map(n => n.id)
688
+
689
+ for (const obs of observers) {
690
+ /** @type {number | null} */
691
+ let discReachCache = null
692
+
693
+ if (obs.coldStart)
694
+ coldStartDiscoveryJoin(simulationContext.discovery, obs.id, onlineFriendlyIds, rng)
695
+
696
+ for (const peer of obs.trustedPeers.slice(0, 2))
697
+ if (!simulationContext.offlineSet.has(peer))
698
+ bumpReputationOnRelayPure(obs.reputation, peer, `trusted:${round}:${peer}`, simulationContext.now, tunables.reputation)
699
+
700
+ for (const node of friendly.filter(n => n.id !== obs.id && !simulationContext.offlineSet.has(n.id))) {
701
+ if (node.behavior && !behaviorRoll(rng, node.behavior, 'onlineStability')) continue
702
+ const side = node.kind === 'honest' && node.behavior
703
+ ? activeSideFromBehavior(node.behavior, rng)
704
+ : 'chat'
705
+ runFriendlyBehavior(simulationContext, node, obs, side, round, tunables, rng)
706
+ }
707
+
708
+ for (const mal of malicious) {
709
+ if (simulationContext.offlineSet.has(mal.id)) continue
710
+ if (discReachCache == null)
711
+ discReachCache = discoveryReach(
712
+ simulationContext.discovery,
713
+ obs.id,
714
+ onlineFriendlyIds,
715
+ id => obs.reputation.byNodeHash[id]?.score ?? 0,
716
+ tunables.mailbox.maxHop,
717
+ )
718
+
719
+ if (!attackReachesObserver(discReachCache, rng)) continue
720
+ runAttack(simulationContext, mal, obs, rng, round, tunables)
721
+ if (DISCOVERY_MUTATING_ATTACKS.has(mal.attack ?? ''))
722
+ discReachCache = null
723
+ const turnRound = mal.sleeperTurnRound ?? simulationContext.sleeperTurnRound ?? 15
724
+ if ((mal.attack === 'sleeper' || mal.attack === 'key_thief') && round >= turnRound)
725
+ simObservePeerBehavior(obs.reputation, mal.id, 1.25, simulationContext.now, tunables.reputation)
726
+ }
727
+
728
+ if (scenario.keyRecoveryRound != null && round === scenario.keyRecoveryRound) {
729
+ for (const kt of malicious.filter(n => n.attack === 'key_thief'))
730
+ applyReputationResetToScoresPure(obs.reputation, kt.id)
731
+ simulationContext.keyRecoveryApplied = true
732
+ }
733
+
734
+ const propState = simulationContext.propagationByObserver.get(obs.id)
735
+ if (propState)
736
+ tickPropagation(propState, round, (target, sender, claim, verified) => {
737
+ applySubjectiveSlashPure(obs.reputation, target, sender, claim, verified, tunables.reputation)
738
+ }, (senderId) => (obs.reputation.byNodeHash[senderId]?.score ?? 0) / REP_MAX, 0.35, rng)
739
+
740
+
741
+ injectTransientFalsePositive(simulationContext, obs, activeHonest, rng, tunables)
742
+
743
+ // 诚实节点的正常 churn 也会偶发「全未知 want」。wantUnknownThreshold 太低时
744
+ // 这些诚实请求会被误判为 eclipse 而扣分(falsePositive),构成阈值的下行压力。
745
+ if (round % 3 === 0)
746
+ for (const h of activeHonest.filter(n => n.id !== obs.id).slice(0, 2)) {
747
+ const penalized = recordGossipAllUnknownWantPure(obs.reputation, h.id, simulationContext.now, tunables.reputation)
748
+ if (penalized && simulationContext.discovery?.poisonedByAttacker?.has(obs.id))
749
+ recoverDiscoveryFromAnchors(simulationContext.discovery, obs.id)
750
+ }
751
+
752
+ // 可验证审计:对有密码学证据的作恶(伪造归档、惰性分片)发起 verified slash,
753
+ // 让 slashVerifiedMultiplier 真正生效;偶发证据指错诚实节点,形成两面梯度。
754
+ if (round % 4 === 0) {
755
+ for (const bad of verifiableBad) {
756
+ applySubjectiveSlashPure(obs.reputation, bad.id, obs.id, tunables.reputation.slashVerifiedDefaultClaim, true, tunables.reputation)
757
+ let verified = simulationContext.verifiedForgeryByObserver.get(obs.id)
758
+ if (!verified) {
759
+ verified = new Set()
760
+ simulationContext.verifiedForgeryByObserver.set(obs.id, verified)
761
+ }
762
+ verified.add(bad.id)
763
+ }
764
+ if (rng() < 0.05) {
765
+ const victim = pickOne(rng, activeHonest.filter(n => n.id !== obs.id))
766
+ if (victim)
767
+ applySubjectiveSlashPure(obs.reputation, victim.id, obs.id, tunables.reputation.slashVerifiedDefaultClaim, true, tunables.reputation)
768
+ }
769
+ }
770
+
771
+ // 诚实节点对已确认作恶者的正当 social block——这类惩罚本应长期保留,
772
+ // socialBlockDecayFraction 太高会过快赦免它们(malSuppression 下行压力)。
773
+ if (round % 7 === 0 && socialConfirmable.length) {
774
+ const confirmed = pickOne(rng, socialConfirmable)
775
+ applyFollowedBlockSignalPure(
776
+ obs.reputation,
777
+ { followerNodeHash: obs.id, targetNodeHash: confirmed.id, voterKey: `${obs.id}entity`, action: 'block', selfTrust: true },
778
+ simulationContext.now,
779
+ tunables.social,
780
+ )
781
+ }
782
+
783
+ if (round % 5 === 0 && malicious.length) {
784
+ // 优先衰减链尾(最深)共谋成员,使多跳 collusionMaxHop 真正被触达。
785
+ const target = collusionRing.length ? collusionRing[collusionRing.length - 1] : pickOne(rng, malicious)
786
+ applyDecayCollusionAfterSlashPure(obs.reputation, target.id, inviteEdges, tunables.reputation)
787
+ }
788
+
789
+ for (const h of activeHonest.filter(n => n.behavior && activeSideFromBehavior(n.behavior, rng) === 'chat').slice(0, 2))
790
+ if (!simulationContext.offlineSet.has(h.id) && behaviorRoll(rng, h.behavior, 'archiveSubmitRate'))
791
+ bumpChunkStorageReputationPure(obs.reputation, h.id, tunables.reputation)
792
+
793
+ // 累积 churn 下 mailbox 可达率
794
+ /**
795
+ * @param {string} id 64 位十六进制 nodeHash
796
+ * @returns {number} 观察者主观信誉分
797
+ */
798
+ function scoreOfRound(id) {
799
+ return obs.reputation.byNodeHash[id]?.score ?? 0
800
+ }
801
+ const mailRound = simulateMailbox(obs, nodes, tunables, scoreOfRound, simulationContext.offlineSet)
802
+ simulationContext.churnReachAccum += mailRound.reach
803
+ simulationContext.churnMailboxCostAccum += mailRound.cost
804
+ simulationContext.churnReachRounds++
805
+ }
806
+
807
+ for (const obs of observers) {
808
+ const transport = simulationContext.transportByObserver?.get(obs.id)
809
+ if (!transport) continue
810
+ const { throttleOk } = transportMetrics(
811
+ transport,
812
+ obs.id,
813
+ onlineFriendlyIds,
814
+ id => obs.reputation.byNodeHash[id]?.score ?? 0,
815
+ simulationContext.now,
816
+ )
817
+ simulationContext.throttleOkAccum = (simulationContext.throttleOkAccum ?? 0) + throttleOk
818
+ simulationContext.throttleRounds = (simulationContext.throttleRounds ?? 0) + 1
819
+ }
820
+ }
821
+
822
+ return collectSnapshot(observers, nodes, tunables, groupSize, simulationContext)
823
+ }
824
+
825
+ /**
826
+ * @param {SimObserver[]} observers 诚实观察者
827
+ * @param {SimNode[]} nodes 全部节点
828
+ * @param {TunablesBundle} tunables 候选参数
829
+ * @param {number} groupSize 群规模
830
+ * @param {object} [simulationContext] 仿真上下文(churn 累积、等价欺骗记录)
831
+ * @returns {import('./metrics.mjs').SimSnapshot} 指标快照
832
+ */
833
+ function collectSnapshot(observers, nodes, tunables, groupSize, simulationContext = {}) {
834
+ const malicious = nodes.filter(n => n.kind === 'malicious')
835
+ const honest = nodes.filter(n => n.kind === 'honest' && !n.compromised)
836
+ const relays = nodes.filter(n => n.kind === 'relay' || n.kind === 'lurker')
837
+ const quietHonest = honest.filter(n => n.behavior && isQuietHonestBehavior(n.behavior))
838
+ const sybilNodes = malicious.filter(n => n.attack === 'sybil' || n.attack === 'rep_pump')
839
+ const forgers = malicious.filter(n => n.attack === 'archive_forger')
840
+ const keyThieves = malicious.filter(n => n.attack === 'key_thief')
841
+ const sleepers = malicious.filter(n => n.attack === 'sleeper')
842
+ const equivocators = malicious.filter(n => n.attack === 'equivocator')
843
+ const offlineSet = simulationContext.offlineSet ?? new Set()
844
+ const hasEquivocation = equivocators.length || simulationContext.equivocationByObserver?.size
845
+
846
+ let malSuppressed = 0
847
+ let malTotal = 0
848
+ let honestSafe = 0
849
+ let honestTotal = 0
850
+ let falsePositive = 0
851
+ let fanoutReach = 0
852
+ let fanoutCost = 0
853
+ let federationReach = 0
854
+ let malAmplification = 0
855
+ let collusionCollapsed = 0
856
+ let collusionTotal = 0
857
+ let relaySafe = 0
858
+ let relayTotal = 0
859
+ let quietSafe = 0
860
+ let quietTotal = 0
861
+ let sybilContained = 0
862
+ let sybilTotal = 0
863
+ let archiveDefended = 0
864
+ let archiveTotal = 0
865
+ let mailboxReach = 0
866
+ let mailboxCost = 0
867
+ let nodeOverload = 0
868
+ let archiveQuorum = 0
869
+ let compromiseContained = 0
870
+ let compromiseTotal = 0
871
+ let sleeperReacted = 0
872
+ let sleeperTotal = 0
873
+ let equivocationDefended = 0
874
+ let equivocationTotal = 0
875
+ let transportReach = 0
876
+ let signalingDiversity = 0
877
+ let joinThrottle = 0
878
+ /** @type {Set<string>} */
879
+ const collusionClustersSeen = new Set()
880
+ /** @type {Map<string, { defended: number, total: number }>} */
881
+ const attackAccum = new Map()
882
+ /** @type {Map<string, { topKSum: number, reachSum: number, count: number }>} */
883
+ const attackImpact = new Map()
884
+ const nodeKind = new Map(nodes.map(n => [n.id, n]))
885
+ const hideThreshold = tunables.social.socialRepHideThreshold
886
+ const onlineNodes = nodes.filter(n => !offlineSet.has(n.id))
887
+ /** @type {Map<string, SimNode[]>} */
888
+ const collusionByCluster = simulationContext.collusionRingByCluster ?? new Map()
889
+ if (!simulationContext.collusionRingByCluster)
890
+ for (const m of malicious) {
891
+ if (m.attack !== 'collusion') continue
892
+ const key = m.clusterId ?? m.id
893
+ if (!collusionByCluster.has(key)) collusionByCluster.set(key, [])
894
+ collusionByCluster.get(key).push(m)
895
+ }
896
+
897
+
898
+ let idealReach = 0
899
+ const idealTunables = idealReachTunables(tunables)
900
+ const establishedHonest = honest.filter(h => !h.newcomer)
901
+
902
+ for (const obs of observers) {
903
+ /**
904
+ * @param {string} id 64 位十六进制 nodeHash
905
+ * @returns {number} 观察者主观信誉分
906
+ */
907
+ function scoreOf(id) {
908
+ return obs.reputation.byNodeHash[id]?.score ?? 0
909
+ }
910
+
911
+ /**
912
+ * @param {string} id 64 位十六进制 nodeHash
913
+ * @returns {number | undefined} 已打分则返回分数,从未打分(新人)返回 undefined
914
+ */
915
+ function rawScoreOf(id) {
916
+ if (offlineSet.has(id)) return undefined
917
+ const s = obs.reputation.byNodeHash[id]?.score
918
+ return Number.isFinite(s) ? s : undefined
919
+ }
920
+
921
+ const discoveryHints = honest
922
+ .filter(n => !n.newcomer && n.id !== obs.id && !obs.trustedPeers.includes(n.id) && !offlineSet.has(n.id))
923
+ .slice(0, 2)
924
+ .map(n => ({ nodeHash: n.id, source: 'explore' }))
925
+ const hints = [...discoveryHints, ...obs.injectedHints]
926
+
927
+ const top = pickTop({
928
+ trustedPeers: obs.trustedPeers.filter(id => !offlineSet.has(id) && !simIsPeerQuarantined(obs.reputation, id, simulationContext.now ?? Date.now())),
929
+ explorePeers: obs.explorePeers.filter(id => !offlineSet.has(id) && !simIsPeerQuarantined(obs.reputation, id, simulationContext.now ?? Date.now())),
930
+ hints,
931
+ roomRosters: [{
932
+ scopeId: 'sim-group',
933
+ nodeHashes: onlineNodes.map(n => n.id),
934
+ scoreOf: rawScoreOf,
935
+ }],
936
+ scoreOf: rawScoreOf,
937
+ quarantinedNodeHashes: new Set(
938
+ Object.keys(obs.reputation.byNodeHash || {}).filter(id => simIsPeerQuarantined(obs.reputation, id, simulationContext.now ?? Date.now())),
939
+ ),
940
+ }, undefined, tunables.trustGraph)
941
+
942
+ const topSet = new Set(top.map(n => n.nodeHash))
943
+ const topLive = top.filter(n => {
944
+ const kind = nodeKind.get(n.nodeHash)
945
+ return !offlineSet.has(n.nodeHash) && (kind?.kind === 'honest' || kind?.kind === 'relay')
946
+ })
947
+ const topMal = top.filter(n => nodeKind.get(n.nodeHash)?.kind === 'malicious')
948
+ const honestRelayOnline = onlineNodes.filter(n => n.kind === 'honest' || n.kind === 'relay').length
949
+
950
+ const honestScores = establishedHonest.map(h => scoreOf(h.id)).sort((a, b) => a - b)
951
+ const honestMedian = honestScores.length
952
+ ? honestScores[Math.floor(honestScores.length / 2)]
953
+ : 0
954
+
955
+ const churnStress = (simulationContext.scenario?.churnRate ?? 0) + (simulationContext.scenario?.offlineRate ?? 0)
956
+
957
+ const fedReach = federationSaturatingReach(topLive.length, honestRelayOnline, churnStress)
958
+ federationReach += fedReach
959
+ fanoutReach += topLive.length / Math.max(1, honestRelayOnline)
960
+ fanoutCost += top.length / Math.max(1, honest.length)
961
+
962
+ const friendlyIds = onlineNodes.filter(n => n.kind === 'honest' || n.kind === 'relay' || n.kind === 'lurker').map(n => n.id)
963
+ const { maxHop } = tunables.mailbox
964
+ const cleanReach = discoveryReach(simulationContext.discovery, obs.id, friendlyIds, scoreOf, maxHop, true)
965
+ const discReach = discoveryReach(simulationContext.discovery, obs.id, friendlyIds, scoreOf, maxHop, false)
966
+ const observerReachHarm = Math.max(0, cleanReach - discReach)
967
+
968
+ const highRepMalInTop = topMal.filter(n => scoreOf(n.nodeHash) >= honestMedian).length
969
+ if (top.length)
970
+ malAmplification += highRepMalInTop / top.length
971
+
972
+ for (const m of malicious) {
973
+ malTotal++
974
+ const suppressed = isMaliciousSuppressed(m, scoreOf, hideThreshold, honestMedian)
975
+ if (suppressed) malSuppressed++
976
+ const atk = m.attack
977
+ if (atk) {
978
+ let row = attackAccum.get(atk)
979
+ if (!row) {
980
+ row = { defended: 0, total: 0 }
981
+ attackAccum.set(atk, row)
982
+ }
983
+ row.total++
984
+ if (suppressed) row.defended++
985
+
986
+ let impact = attackImpact.get(atk)
987
+ if (!impact) {
988
+ impact = { topKSum: 0, reachSum: 0, count: 0 }
989
+ attackImpact.set(atk, impact)
990
+ }
991
+ impact.count++
992
+ if (topSet.has(m.id) && scoreOf(m.id) >= honestMedian)
993
+ impact.topKSum++
994
+ const attributed = attackReachHarmForNode(simulationContext, obs, m, observerReachHarm)
995
+ impact.reachSum += attributed
996
+ }
997
+ }
998
+
999
+ for (const h of establishedHonest) {
1000
+ honestTotal++
1001
+ if (isNodePreserved(h, scoreOf, hideThreshold))
1002
+ honestSafe++
1003
+ if (scoreOf(h.id) < hideThreshold)
1004
+ falsePositive++
1005
+ }
1006
+
1007
+ for (const r of relays) {
1008
+ relayTotal++
1009
+ if (isNodePreserved(r, scoreOf, hideThreshold))
1010
+ relaySafe++
1011
+ }
1012
+
1013
+ for (const q of quietHonest) {
1014
+ quietTotal++
1015
+ if (isNodePreserved(q, scoreOf, hideThreshold))
1016
+ quietSafe++
1017
+ }
1018
+
1019
+ const sybilClusters = new Map()
1020
+ for (const s of sybilNodes) {
1021
+ const key = s.clusterId ?? s.id
1022
+ if (!sybilClusters.has(key)) sybilClusters.set(key, [])
1023
+ sybilClusters.get(key).push(s)
1024
+ }
1025
+ for (const cluster of sybilClusters.values()) {
1026
+ sybilTotal++
1027
+ const maxScore = Math.max(...cluster.map(n => scoreOf(n.id)))
1028
+ const pumpedInTop = cluster.some(n => topSet.has(n.id) && scoreOf(n.id) >= honestMedian)
1029
+ if (maxScore < honestMedian && !pumpedInTop) sybilContained++
1030
+ }
1031
+
1032
+ for (const f of forgers) {
1033
+ archiveTotal++
1034
+ const cryptoOk = integrityDefendsAgainst(f, obs, simulationContext.scenario ?? {}, simulationContext)
1035
+ if (cryptoOk || isMaliciousSuppressed(f, scoreOf, hideThreshold, honestMedian))
1036
+ archiveDefended++
1037
+ }
1038
+
1039
+ for (const kt of keyThieves) {
1040
+ compromiseTotal++
1041
+ if (isMaliciousSuppressed(kt, scoreOf, hideThreshold, honestMedian))
1042
+ compromiseContained++
1043
+ }
1044
+
1045
+ for (const sl of sleepers) {
1046
+ const turnRound = sl.sleeperTurnRound ?? simulationContext.sleeperTurnRound ?? 15
1047
+ if ((simulationContext.scenario?.rounds ?? 40) > turnRound) {
1048
+ sleeperTotal++
1049
+ if (isMaliciousSuppressed(sl, scoreOf, hideThreshold, honestMedian))
1050
+ sleeperReacted++
1051
+ }
1052
+ }
1053
+
1054
+ for (const eq of equivocators) {
1055
+ equivocationTotal++
1056
+ const cryptoOk = integrityDefendsAgainst(eq, obs, simulationContext.scenario ?? {}, simulationContext)
1057
+ if (cryptoOk || isMaliciousSuppressed(eq, scoreOf, hideThreshold, honestMedian))
1058
+ equivocationDefended++
1059
+ }
1060
+
1061
+ for (const m of malicious.filter(n => n.attack === 'collusion')) {
1062
+ const clusterKey = m.clusterId ?? m.id
1063
+ if (collusionClustersSeen.has(clusterKey)) continue
1064
+ collusionClustersSeen.add(clusterKey)
1065
+ collusionTotal++
1066
+ const ring = collusionByCluster.get(clusterKey) ?? [m]
1067
+ const avg = ring.reduce((s, n) => s + scoreOf(n.id), 0) / Math.max(1, ring.length)
1068
+ if (avg < 0) collusionCollapsed++
1069
+ }
1070
+
1071
+ const mail = simulateMailbox(obs, nodes, tunables, scoreOf, offlineSet)
1072
+ mailboxReach += mail.reach
1073
+ mailboxCost += mail.cost
1074
+ nodeOverload += mail.overload
1075
+ idealReach += simulateMailbox(obs, nodes, idealTunables, scoreOf, new Set()).reach
1076
+ const scenarioMeta = simulationContext.scenario ?? {}
1077
+ let quorumAcc = simulateArchiveQuorum(nodes, tunables, scoreOf, groupSize, hasEquivocation)
1078
+ if (observerHasLocalReplica(obs, scenarioMeta))
1079
+ quorumAcc = blendArchiveQuorumAccuracy(quorumAcc, 1)
1080
+ archiveQuorum += quorumAcc
1081
+
1082
+ const tMetrics = transportMetrics(
1083
+ simulationContext.transportByObserver?.get(obs.id) ?? createTransportState(),
1084
+ obs.id,
1085
+ friendlyIds,
1086
+ scoreOf,
1087
+ simulationContext.now ?? Date.now(),
1088
+ )
1089
+ transportReach += tMetrics.reach
1090
+ signalingDiversity += tMetrics.diversity
1091
+ joinThrottle += tMetrics.throttleOk
1092
+ }
1093
+
1094
+ const nObs = Math.max(1, observers.length)
1095
+ const joinThrottleRate = simulationContext.throttleRounds
1096
+ ? (simulationContext.throttleOkAccum ?? 0) / simulationContext.throttleRounds
1097
+ : joinThrottle / nObs
1098
+
1099
+ // churn 可达率:回合内累积的 mailbox reach 均值;无 churn 时退化为 mailboxReachRate
1100
+ const churnReachRate = simulationContext.churnReachRounds
1101
+ ? simulationContext.churnReachAccum / simulationContext.churnReachRounds
1102
+ : mailboxReach / nObs
1103
+
1104
+ const idealReachRate = idealReach / nObs
1105
+ const normalizedChurnReach = idealReachRate > 0
1106
+ ? Math.min(1, churnReachRate / idealReachRate)
1107
+ : churnReachRate
1108
+
1109
+ const malAmpAvg = malAmplification / nObs
1110
+ const rawMalSuppression = malTotal ? malSuppressed / malTotal : 1
1111
+ const vMult = tunables.reputation.slashVerifiedMultiplier
1112
+ const verifiedSlashScale = 0.35 + 0.65 * Math.min(1, vMult / 0.35)
1113
+ const malSuppressionRate = Math.max(0, rawMalSuppression * (1 - malAmpAvg * 0.85) * verifiedSlashScale)
1114
+ const mailboxCostAvg = simulationContext.churnReachRounds
1115
+ ? simulationContext.churnMailboxCostAccum / simulationContext.churnReachRounds
1116
+ : mailboxCost / nObs
1117
+
1118
+ /** @type {NonNullable<import('./metrics.mjs').SimSnapshot['byAttackDefense']>} */
1119
+ const byAttackDefense = {}
1120
+ for (const [atk, row] of attackAccum)
1121
+ byAttackDefense[atk] = { ...row, rate: row.total ? row.defended / row.total : 1 }
1122
+
1123
+ /** @type {NonNullable<import('./metrics.mjs').SimSnapshot['byAttackImpact']>} */
1124
+ const byAttackImpact = {}
1125
+ for (const [atk, row] of attackImpact) {
1126
+ const n = Math.max(1, row.count)
1127
+ byAttackImpact[atk] = {
1128
+ topKCapture: row.topKSum / n,
1129
+ reachCollapse: REACH_ATTACK_KINDS.has(atk)
1130
+ ? Math.min(1, row.reachSum / n)
1131
+ : 0,
1132
+ }
1133
+ }
1134
+
1135
+ const quietHonestPreservationRate = quietTotal ? quietSafe / quietTotal : 1
1136
+
1137
+ const joinDelay = honestJoinDelayPenalty(
1138
+ resolvePowFloorBits(tunables.admission),
1139
+ simulationContext.scenario?.rounds ?? 40,
1140
+ )
1141
+
1142
+ return {
1143
+ malSuppressionRate,
1144
+ honestPreservationRate: honestTotal ? honestSafe / honestTotal : 1,
1145
+ falsePositiveRate: honestTotal ? falsePositive / honestTotal : 0,
1146
+ fanoutReachRate: fanoutReach / nObs,
1147
+ federationReachRate: federationReach / nObs,
1148
+ fanoutCostRatio: fanoutCost / nObs,
1149
+ collusionCollapseRate: collusionTotal ? collusionCollapsed / collusionTotal : 1,
1150
+ relayPreservationRate: relayTotal ? relaySafe / relayTotal : 1,
1151
+ profilePreservationRate: quietHonestPreservationRate,
1152
+ quietHonestPreservationRate,
1153
+ sybilContainmentRate: sybilTotal ? sybilContained / sybilTotal : 1,
1154
+ archiveDefenseRate: archiveTotal ? archiveDefended / archiveTotal : 1,
1155
+ mailboxReachRate: mailboxReach / nObs,
1156
+ mailboxCostRatio: mailboxCostAvg,
1157
+ nodeOverloadRate: nodeOverload / nObs,
1158
+ archiveQuorumAccuracy: archiveQuorum / nObs,
1159
+ churnReachRate: normalizedChurnReach,
1160
+ compromiseContainmentRate: compromiseTotal ? compromiseContained / compromiseTotal : 1,
1161
+ sleeperReactionRate: sleeperTotal ? sleeperReacted / sleeperTotal : 1,
1162
+ equivocationDefenseRate: equivocationTotal
1163
+ ? equivocationDefended / equivocationTotal
1164
+ : hasEquivocation ? archiveQuorum / nObs : 1,
1165
+ transportReachRate: transportReach / nObs,
1166
+ signalingDiversityRate: signalingDiversity / nObs,
1167
+ joinThrottleEffectiveness: joinThrottleRate,
1168
+ honestJoinDelayPenalty: joinDelay,
1169
+ observerCount: observers.length,
1170
+ maliciousCount: malicious.length,
1171
+ honestCount: honest.length,
1172
+ groupSize,
1173
+ byAttackDefense,
1174
+ byAttackImpact,
1175
+ }
1176
+ }