@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.
- package/AGENTS.md +79 -0
- package/docs/evfs.md +32 -0
- package/docs/infra.md +25 -0
- package/docs/mesh.md +67 -0
- package/docs/reputation.md +22 -0
- package/docs/runtime.md +62 -0
- package/docs/signaling.md +23 -0
- package/docs/transports.md +92 -0
- package/docs/wire.md +21 -0
- package/link/providers/webrtc.mjs +5 -2
- package/link/rtc/ice_local_hostname.mjs +15 -47
- package/link/rtc/polyfill.mjs +3 -15
- package/package.json +2 -9
- package/sim/AGENTS.md +29 -0
- package/sim/admission.mjs +56 -0
- package/sim/anomaly.mjs +39 -0
- package/sim/apply.mjs +77 -0
- package/sim/attack_space.mjs +113 -0
- package/sim/attacks.mjs +614 -0
- package/sim/behavior.mjs +78 -0
- package/sim/cli.mjs +282 -0
- package/sim/coevolution.mjs +245 -0
- package/sim/cold_start.md +13 -0
- package/sim/constants.mjs +44 -0
- package/sim/discovery.mjs +259 -0
- package/sim/duration.mjs +64 -0
- package/sim/graph_adj.mjs +28 -0
- package/sim/integrity.mjs +58 -0
- package/sim/metrics.mjs +423 -0
- package/sim/model.mjs +1176 -0
- package/sim/optimizer.mjs +250 -0
- package/sim/propagation.mjs +57 -0
- package/sim/report.mjs +176 -0
- package/sim/reputation_social.tunables.json +6 -0
- package/sim/rng.mjs +62 -0
- package/sim/scenarios.mjs +345 -0
- package/sim/sim_pool.mjs +169 -0
- package/sim/sim_worker.mjs +31 -0
- package/sim/social_reputation.mjs +30 -0
- package/sim/space.mjs +312 -0
- package/sim/test/admission.test.mjs +25 -0
- package/sim/test/attack_space.test.mjs +39 -0
- package/sim/test/behavior_vector.test.mjs +37 -0
- package/sim/test/coevolution.test.mjs +32 -0
- package/sim/test/cold_start.test.mjs +46 -0
- package/sim/test/defense.test.mjs +320 -0
- package/sim/test/discovery.test.mjs +68 -0
- package/sim/test/duration.test.mjs +20 -0
- package/sim/test/fidelity.test.mjs +151 -0
- package/sim/test/integrity.test.mjs +56 -0
- package/sim/test/optimizer.test.mjs +24 -0
- package/sim/test/propagation.test.mjs +29 -0
- package/sim/test/sim_model_regressions.test.mjs +110 -0
- package/sim/test/slow_drip.test.mjs +28 -0
- package/sim/test/smoke.test.mjs +52 -0
- package/sim/test/transport.test.mjs +55 -0
- package/sim/test/tunables_apply.test.mjs +64 -0
- package/sim/test/tunables_param_space.test.mjs +74 -0
- package/sim/test/vulnerability.test.mjs +127 -0
- package/sim/transport.mjs +135 -0
- package/sim/tunables_bundle.mjs +71 -0
- package/sim/vulnerability.mjs +532 -0
- package/deno.json +0 -7
- 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
|
+
}
|