@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import {
|
|
5
|
+
createDiscoveryState,
|
|
6
|
+
eclipseFillExplore,
|
|
7
|
+
initObserverDiscovery,
|
|
8
|
+
takeRoomSlot,
|
|
9
|
+
} from '../discovery.mjs'
|
|
10
|
+
import { integrityDefendsAgainst, observerHasLocalReplica } from '../integrity.mjs'
|
|
11
|
+
import { runSimulation } from '../model.mjs'
|
|
12
|
+
import { createRng } from '../rng.mjs'
|
|
13
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
14
|
+
import { createTransportState, takeTransportJoinSlot } from '../transport.mjs'
|
|
15
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
16
|
+
|
|
17
|
+
test('reachCollapse does not shrink with observer count', () => {
|
|
18
|
+
const tunables = loadDefaultTunables()
|
|
19
|
+
const base = resolveScenarios('spam_eclipse')[0]
|
|
20
|
+
const small = { ...base, honestCount: 6, attacks: { eclipse: 6 }, rounds: 30 }
|
|
21
|
+
const large = { ...base, honestCount: 24, attacks: { eclipse: 6 }, rounds: 30 }
|
|
22
|
+
const snapSmall = runSimulation(small, 5, tunables)
|
|
23
|
+
const snapLarge = runSimulation(large, 5, tunables)
|
|
24
|
+
const rSmall = snapSmall.byAttackImpact?.eclipse?.reachCollapse ?? 0
|
|
25
|
+
const rLarge = snapLarge.byAttackImpact?.eclipse?.reachCollapse ?? 0
|
|
26
|
+
if (rSmall > 0.02)
|
|
27
|
+
assert(
|
|
28
|
+
rLarge >= rSmall * 0.35,
|
|
29
|
+
`reachCollapse should not scale down with more observers: small=${rSmall} large=${rLarge}`,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('newcomers excluded from falsePositiveRate', () => {
|
|
34
|
+
const tunables = loadDefaultTunables()
|
|
35
|
+
tunables.social.socialRepHideThreshold = 0.01
|
|
36
|
+
const withNewcomers = {
|
|
37
|
+
...resolveScenarios('balanced')[0],
|
|
38
|
+
honestCount: 8,
|
|
39
|
+
newcomerCount: 6,
|
|
40
|
+
attacks: {},
|
|
41
|
+
rounds: 10,
|
|
42
|
+
}
|
|
43
|
+
const withoutNewcomers = { ...withNewcomers, newcomerCount: 0 }
|
|
44
|
+
const snapWith = runSimulation(withNewcomers, 11, tunables)
|
|
45
|
+
const snapWithout = runSimulation(withoutNewcomers, 11, tunables)
|
|
46
|
+
assert(
|
|
47
|
+
snapWith.falsePositiveRate <= snapWithout.falsePositiveRate,
|
|
48
|
+
`newcomers inflated falsePositive: with=${snapWith.falsePositiveRate} without=${snapWithout.falsePositiveRate}`,
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('verifiedForgery is per-observer not global', () => {
|
|
53
|
+
const attacker = { id: 'm1', attack: 'archive_forger' }
|
|
54
|
+
const scenario = { honestCount: 10, groupSize: 12, behaviorDist: { archiveSubmitRate: { mean: 0.2 } } }
|
|
55
|
+
const observerA = { id: 'o1', trustedPeers: ['t1', 't2'] }
|
|
56
|
+
const observerB = { id: 'o2', trustedPeers: ['t1', 't2'] }
|
|
57
|
+
const simulationContext = { verifiedForgeryByObserver: new Map([['o1', new Set(['m1'])]]) }
|
|
58
|
+
assertEquals(integrityDefendsAgainst(attacker, observerA, scenario, simulationContext), true)
|
|
59
|
+
assertEquals(integrityDefendsAgainst(attacker, observerB, scenario, simulationContext), false)
|
|
60
|
+
assertEquals(observerHasLocalReplica(observerB, scenario), true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('takeRoomSlot enforces maxNonTrusted cap', () => {
|
|
64
|
+
const state = createDiscoveryState()
|
|
65
|
+
state.rtcMaxActive = 10
|
|
66
|
+
const trusted = ['t1', 't2', 't3']
|
|
67
|
+
initObserverDiscovery(state, 'obs', trusted, ['obs', 't1', 't2', 't3', ...Array.from({ length: 12 }, (_, i) => `p${i}`)], createRng(1), 4)
|
|
68
|
+
const bucket = state.roomSlotsByObserver.get('obs')
|
|
69
|
+
assert(bucket)
|
|
70
|
+
const maxNonTrusted = Math.max(0, state.rtcMaxActive - bucket.trustedReserved.size)
|
|
71
|
+
let nonTrustedAdded = 0
|
|
72
|
+
for (let i = 0; i < 20; i++) {
|
|
73
|
+
if (!takeRoomSlot(state, 'obs', `extra${i}`, 'spam', trusted)) continue
|
|
74
|
+
if (!bucket.trustedReserved.has(`extra${i}`)) nonTrustedAdded++
|
|
75
|
+
}
|
|
76
|
+
assert(nonTrustedAdded <= maxNonTrusted, `non-trusted slots ${nonTrustedAdded} > cap ${maxNonTrusted}`)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('eclipse cluster mates fill explore together', () => {
|
|
80
|
+
const state = createDiscoveryState()
|
|
81
|
+
const rng = createRng(3)
|
|
82
|
+
const roster = ['obs', 'h1', 'mal1', 'mal2', 'mal3']
|
|
83
|
+
initObserverDiscovery(state, 'obs', ['h1'], roster, rng, 4)
|
|
84
|
+
eclipseFillExplore(state, 'obs', 'mal1', ['mal2', 'mal3'], 0.9)
|
|
85
|
+
const explore = state.exploreByObserver.get('obs')
|
|
86
|
+
assert(explore?.has('mal2'))
|
|
87
|
+
assert(explore?.has('mal3'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('transport trusted peers reserve slots per observer', () => {
|
|
91
|
+
const state = createTransportState()
|
|
92
|
+
state.rtcMaxActive = 8
|
|
93
|
+
state.maxJoinsPerMin = 200
|
|
94
|
+
for (const id of ['anchor1', 'anchor2', 'anchor3'])
|
|
95
|
+
state.trustedPeers.add(id)
|
|
96
|
+
for (const id of ['anchor1', 'anchor2', 'anchor3'])
|
|
97
|
+
assertEquals(takeTransportJoinSlot(state, id, 'trusted', 1000), true)
|
|
98
|
+
const trustedReserved = Math.max(3, Math.floor(state.rtcMaxActive * 0.25))
|
|
99
|
+
const maxNonTrusted = Math.max(1, state.rtcMaxActive - trustedReserved)
|
|
100
|
+
let nonTrusted = 0
|
|
101
|
+
for (let i = 0; i < 20; i++)
|
|
102
|
+
if (takeTransportJoinSlot(state, `flood${i}`, 'flood-src', 2000 + i)) nonTrusted++
|
|
103
|
+
assert(nonTrusted <= maxNonTrusted + 1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('buildWorld wires per-observer transport with trusted anchors', () => {
|
|
107
|
+
const snap = runSimulation(resolveScenarios('transport_siege')[0], 2, loadDefaultTunables())
|
|
108
|
+
assertEquals(typeof snap.joinThrottleEffectiveness, 'number')
|
|
109
|
+
assert(snap.joinThrottleEffectiveness >= 0 && snap.joinThrottleEffectiveness <= 1)
|
|
110
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { runSimulation } from '../model.mjs'
|
|
5
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
6
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
7
|
+
|
|
8
|
+
test('slow_drip_spam is deterministic across runs', () => {
|
|
9
|
+
const scenario = resolveScenarios('slow_drip_spam')[0]
|
|
10
|
+
const tunables = loadDefaultTunables()
|
|
11
|
+
const a = runSimulation(scenario, 42, tunables)
|
|
12
|
+
const b = runSimulation(scenario, 42, tunables)
|
|
13
|
+
assertEquals(JSON.stringify(a), JSON.stringify(b))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('slow_drip_spam defends better than disabled penalties', () => {
|
|
17
|
+
const scenario = resolveScenarios('slow_drip_spam')[0]
|
|
18
|
+
const defaults = loadDefaultTunables()
|
|
19
|
+
const disabled = loadDefaultTunables()
|
|
20
|
+
disabled.reputation.penaltyMessageRate = 0
|
|
21
|
+
let defaultDefense = 0
|
|
22
|
+
let disabledDefense = 0
|
|
23
|
+
for (const seed of [1, 2, 3]) {
|
|
24
|
+
defaultDefense += runSimulation(scenario, seed, defaults).byAttackDefense?.slow_drip_spammer?.rate ?? 0
|
|
25
|
+
disabledDefense += runSimulation(scenario, seed, disabled).byAttackDefense?.slow_drip_spammer?.rate ?? 0
|
|
26
|
+
}
|
|
27
|
+
assertEquals(defaultDefense / 3 >= disabledDefense / 3, true)
|
|
28
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { fitnessFromSnapshot, RATE_METRIC_KEYS } from '../metrics.mjs'
|
|
5
|
+
import { runSimulation } from '../model.mjs'
|
|
6
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
7
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
8
|
+
|
|
9
|
+
test('p2p sim smoke', () => {
|
|
10
|
+
const tunables = loadDefaultTunables()
|
|
11
|
+
const scenario = resolveScenarios('balanced')[0]
|
|
12
|
+
const snap = runSimulation(scenario, 1, tunables)
|
|
13
|
+
|
|
14
|
+
for (const key of RATE_METRIC_KEYS) {
|
|
15
|
+
assertEquals(typeof snap[key], 'number', key)
|
|
16
|
+
if (key === 'fanoutCostRatio' || key === 'mailboxCostRatio')
|
|
17
|
+
assertEquals(snap[key] >= 0, true, key)
|
|
18
|
+
else
|
|
19
|
+
assertEquals(snap[key] >= 0 && snap[key] <= 1, true, key)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
assertEquals(typeof snap.federationReachRate, 'number')
|
|
23
|
+
assertEquals(snap.federationReachRate >= 0 && snap.federationReachRate <= 1, true)
|
|
24
|
+
|
|
25
|
+
assertEquals(typeof snap.malSuppressionRate, 'number')
|
|
26
|
+
assertEquals(typeof snap.honestPreservationRate, 'number')
|
|
27
|
+
assertEquals(snap.malSuppressionRate >= 0 && snap.malSuppressionRate <= 1, true)
|
|
28
|
+
assertEquals(snap.honestPreservationRate >= 0 && snap.honestPreservationRate <= 1, true)
|
|
29
|
+
|
|
30
|
+
const fitness = fitnessFromSnapshot(snap)
|
|
31
|
+
assertEquals(Number.isFinite(fitness), true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('usage_mix scenario profiles', () => {
|
|
35
|
+
const tunables = loadDefaultTunables()
|
|
36
|
+
const scenario = resolveScenarios('usage_mix')[0]
|
|
37
|
+
const snap = runSimulation(scenario, 7, tunables)
|
|
38
|
+
assertEquals(snap.profilePreservationRate > 0, true)
|
|
39
|
+
assertEquals(snap.relayPreservationRate > 0, true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('new endogenous scenarios produce metrics', () => {
|
|
43
|
+
const tunables = loadDefaultTunables()
|
|
44
|
+
for (const id of ['churn_storm', 'key_compromise', 'sleeper_turn', 'digest_equivocation', 'eclipse_targeted']) {
|
|
45
|
+
const scenario = resolveScenarios(id)[0]
|
|
46
|
+
const snap = runSimulation(scenario, 3, tunables)
|
|
47
|
+
assertEquals(typeof snap.churnReachRate, 'number', id)
|
|
48
|
+
assertEquals(typeof snap.compromiseContainmentRate, 'number', id)
|
|
49
|
+
assertEquals(typeof snap.sleeperReactionRate, 'number', id)
|
|
50
|
+
assertEquals(typeof snap.equivocationDefenseRate, 'number', id)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { runSimulation } from '../model.mjs'
|
|
5
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
6
|
+
import {
|
|
7
|
+
createTransportState,
|
|
8
|
+
takeTransportJoinSlot,
|
|
9
|
+
transportHintWeight,
|
|
10
|
+
transportMetrics,
|
|
11
|
+
} from '../transport.mjs'
|
|
12
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
13
|
+
|
|
14
|
+
test('single source can still connect at least one peer', () => {
|
|
15
|
+
const state = createTransportState()
|
|
16
|
+
state.rtcMaxActive = 8
|
|
17
|
+
state.maxJoinsPerMin = 120
|
|
18
|
+
assertEquals(takeTransportJoinSlot(state, 'peer0', 'solo-source', 1000), true)
|
|
19
|
+
const sourceCap = Math.max(1, Math.floor(state.rtcMaxActive * 0.25))
|
|
20
|
+
for (let i = 1; i < sourceCap; i++)
|
|
21
|
+
assertEquals(takeTransportJoinSlot(state, `peer${i}`, 'solo-source', 1000 + i), true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('transport_siege scenario exposes transport metrics', () => {
|
|
25
|
+
const snap = runSimulation(resolveScenarios('transport_siege')[0], 3, loadDefaultTunables())
|
|
26
|
+
assertEquals(typeof snap.transportReachRate, 'number')
|
|
27
|
+
assertEquals(typeof snap.joinThrottleEffectiveness, 'number')
|
|
28
|
+
assertEquals(snap.transportReachRate >= 0 && snap.transportReachRate <= 1, true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('transportMetrics tracks signaling diversity', () => {
|
|
32
|
+
const state = createTransportState()
|
|
33
|
+
transportHintWeight(state, 'obs', 'p1', 'tracker')
|
|
34
|
+
transportHintWeight(state, 'obs', 'p2', 'nostr')
|
|
35
|
+
const m = transportMetrics(state, 'obs', ['obs', 'p1', 'p2'], () => 0.5)
|
|
36
|
+
assertEquals(m.reach >= 0 && m.reach <= 1, true)
|
|
37
|
+
assertEquals(m.diversity > 0, true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('transportMetrics throttle uses simulation clock not wall clock', () => {
|
|
41
|
+
const state = createTransportState()
|
|
42
|
+
const simStart = Date.now()
|
|
43
|
+
// 40 回合后 simulationContext.now 比墙钟超前约 2.4M ms,overloadUntil 落在虚拟时间轴上
|
|
44
|
+
state.overloadUntil = simStart + 2_400_000
|
|
45
|
+
const atWall = transportMetrics(state, 'obs', ['obs'], () => 0.5)
|
|
46
|
+
assertEquals(atWall.throttleOk, 0)
|
|
47
|
+
const atSim = transportMetrics(state, 'obs', ['obs'], () => 0.5, simStart + 2_500_000)
|
|
48
|
+
assertEquals(atSim.throttleOk, 1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('transport_siege join throttle effective after sim decay', () => {
|
|
52
|
+
const snap = runSimulation(resolveScenarios('transport_siege')[0], 3, loadDefaultTunables())
|
|
53
|
+
assertEquals(typeof snap.joinThrottleEffectiveness, 'number')
|
|
54
|
+
assertEquals(snap.joinThrottleEffectiveness >= 0 && snap.joinThrottleEffectiveness <= 1, true)
|
|
55
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { resolveArchiveQuorumPeerMin, resolveArchiveQuorumPeerStrictMin } from '../../trust_graph/resolve.mjs'
|
|
5
|
+
import { applyTunablesBundle, prepareBundleForApply } from '../apply.mjs'
|
|
6
|
+
import { normalizeBundle, PARAM_SPACE, randomCandidate, sanitizeArchiveQuorum } from '../space.mjs'
|
|
7
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
8
|
+
|
|
9
|
+
test('normalizeBundle keeps every PARAM_SPACE value in its semantic domain', () => {
|
|
10
|
+
for (let seed = 1; seed <= 40; seed++) {
|
|
11
|
+
const bundle = normalizeBundle(randomCandidate(seed))
|
|
12
|
+
for (const spec of PARAM_SPACE) {
|
|
13
|
+
const v = bundle[spec.module][spec.key]
|
|
14
|
+
const label = `${spec.module}.${spec.key} seed ${seed}`
|
|
15
|
+
assertEquals(Number.isFinite(v), true, label)
|
|
16
|
+
if (spec.kind === 'count') assertEquals(Number.isInteger(v) && v >= 1, true, label)
|
|
17
|
+
if (spec.kind === 'pos') assertEquals(v > 0, true, label)
|
|
18
|
+
if (spec.kind === 'unit') assertEquals(v > 0 && v < 1, true, label)
|
|
19
|
+
if (spec.kind === 'score') assertEquals(v > -1 && v < 1, true, label)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('prepareBundleForApply preserves fractional tunables', () => {
|
|
25
|
+
const bundle = randomCandidate(7)
|
|
26
|
+
bundle.reputation.penaltyUnknownWant = 0.22
|
|
27
|
+
bundle.reputation.slashDefaultClaim = 0.2
|
|
28
|
+
bundle.social.socialRepHideThreshold = -0.4
|
|
29
|
+
bundle.reputation.collusionDelta = 0.62
|
|
30
|
+
|
|
31
|
+
const ready = prepareBundleForApply(bundle)
|
|
32
|
+
assertEquals(ready.reputation.penaltyUnknownWant, 0.22)
|
|
33
|
+
assertEquals(ready.reputation.slashDefaultClaim, 0.2)
|
|
34
|
+
assertEquals(ready.social.socialRepHideThreshold, -0.4)
|
|
35
|
+
assertEquals(ready.reputation.collusionDelta, 0.62)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('prepareBundleForApply does not zero non-space reputation keys', () => {
|
|
39
|
+
const bundle = randomCandidate(11)
|
|
40
|
+
const ready = prepareBundleForApply(bundle)
|
|
41
|
+
assertEquals(ready.reputation.slashDefaultClaim > 0, true)
|
|
42
|
+
assertEquals(ready.reputation.slashUnverifiedDefaultClaim > 0, true)
|
|
43
|
+
assertEquals(ready.reputation.chunkStoreRepBump > 0, true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('applyTunablesBundle requires socialTunablesPath', async () => {
|
|
47
|
+
await assert.rejects(
|
|
48
|
+
() => applyTunablesBundle(loadDefaultTunables(), {}),
|
|
49
|
+
/socialTunablesPath required/u,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('prepareBundleForApply enforces strict>=base quorum rule', () => {
|
|
54
|
+
const bundle = randomCandidate(3)
|
|
55
|
+
bundle.archive.archiveQuorumPeerMinRatio = 0.8
|
|
56
|
+
bundle.archive.archiveQuorumPeerStrictMinRatio = 0.1
|
|
57
|
+
sanitizeArchiveQuorum(bundle)
|
|
58
|
+
const refN = 8
|
|
59
|
+
assertEquals(
|
|
60
|
+
resolveArchiveQuorumPeerStrictMin(refN, bundle.archive)
|
|
61
|
+
>= resolveArchiveQuorumPeerMin(refN, bundle.archive),
|
|
62
|
+
true,
|
|
63
|
+
)
|
|
64
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { resolveArchiveQuorumPeerMin, resolveArchiveQuorumPeerStrictMin } from '../../trust_graph/resolve.mjs'
|
|
5
|
+
import { createRng } from '../rng.mjs'
|
|
6
|
+
import {
|
|
7
|
+
normalizeParam,
|
|
8
|
+
PARAM_SPACE,
|
|
9
|
+
quantize,
|
|
10
|
+
randomCandidate,
|
|
11
|
+
sampleParam,
|
|
12
|
+
sanitizeArchiveQuorum,
|
|
13
|
+
} from '../space.mjs'
|
|
14
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
15
|
+
|
|
16
|
+
test('quantize removes float tails', () => {
|
|
17
|
+
assertEquals(quantize(0.060000000000000005, 6), 0.06)
|
|
18
|
+
assertEquals(quantize(0.8400000000000001, 6), 0.84)
|
|
19
|
+
assertEquals(String(quantize(0.034999999999999996, 6)).includes('999'), false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('quantize default preserves fractional values', () => {
|
|
23
|
+
assertEquals(quantize(0.22), 0.22)
|
|
24
|
+
assertEquals(quantize(-0.4), -0.4)
|
|
25
|
+
assertEquals(quantize(0.62), 0.62)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('sampleParam stays inside each semantic domain', () => {
|
|
29
|
+
const rng = createRng(99)
|
|
30
|
+
const base = loadDefaultTunables()
|
|
31
|
+
for (const spec of PARAM_SPACE)
|
|
32
|
+
for (let i = 0; i < 50; i++) {
|
|
33
|
+
const v = sampleParam(rng, spec, base[spec.module][spec.key])
|
|
34
|
+
const label = `${spec.module}.${spec.key}`
|
|
35
|
+
assertEquals(Number.isFinite(v), true, label)
|
|
36
|
+
switch (spec.kind) {
|
|
37
|
+
case 'count':
|
|
38
|
+
assertEquals(Number.isInteger(v) && v >= 1, true, label)
|
|
39
|
+
break
|
|
40
|
+
case 'pos':
|
|
41
|
+
assertEquals(v > 0, true, label)
|
|
42
|
+
break
|
|
43
|
+
case 'unit':
|
|
44
|
+
assertEquals(v > 0 && v < 1, true, label)
|
|
45
|
+
break
|
|
46
|
+
case 'score':
|
|
47
|
+
assertEquals(v > -1 && v < 1, true, label)
|
|
48
|
+
break
|
|
49
|
+
default:
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('normalizeParam repairs out-of-domain values without a tuning box', () => {
|
|
56
|
+
assertEquals(normalizeParam(-5, { module: 'reputation', key: 'penaltyUnknownWant', kind: 'pos' }) > 0, true)
|
|
57
|
+
assertEquals(normalizeParam(3.4, { module: 'reputation', key: 'slashVerifiedMultiplier', kind: 'unit' }) < 1, true)
|
|
58
|
+
assertEquals(normalizeParam(2.7, { module: 'mailbox', key: 'maxHop', kind: 'count' }), 3)
|
|
59
|
+
assertEquals(normalizeParam(9, { module: 'social', key: 'socialRepHideThreshold', kind: 'score' }) < 1, true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('randomCandidate archive quorum ordering', () => {
|
|
63
|
+
for (let seed = 1; seed <= 30; seed++) {
|
|
64
|
+
const bundle = randomCandidate(seed)
|
|
65
|
+
sanitizeArchiveQuorum(bundle)
|
|
66
|
+
const refN = 8
|
|
67
|
+
assertEquals(
|
|
68
|
+
resolveArchiveQuorumPeerStrictMin(refN, bundle.archive)
|
|
69
|
+
>= resolveArchiveQuorumPeerMin(refN, bundle.archive),
|
|
70
|
+
true,
|
|
71
|
+
`seed ${seed}`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { evaluateTunables } from '../metrics.mjs'
|
|
5
|
+
import { runSimulation } from '../model.mjs'
|
|
6
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
7
|
+
import { shutdownSimPool } from '../sim_pool.mjs'
|
|
8
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
9
|
+
import {
|
|
10
|
+
analyzeVulnerabilities,
|
|
11
|
+
ATTACK_PROFILES,
|
|
12
|
+
classifyDefenseSeverity,
|
|
13
|
+
formatVulnerabilityConsole,
|
|
14
|
+
formatVulnerabilityMarkdown,
|
|
15
|
+
} from '../vulnerability.mjs'
|
|
16
|
+
|
|
17
|
+
test('classifyDefenseSeverity bands', () => {
|
|
18
|
+
assertEquals(classifyDefenseSeverity(0.1).severity, '群级瘫痪')
|
|
19
|
+
assertEquals(classifyDefenseSeverity(0.35).severity, '群级接管')
|
|
20
|
+
assertEquals(classifyDefenseSeverity(0.5).severity, '明显薄弱')
|
|
21
|
+
assertEquals(classifyDefenseSeverity(0.7).severity, '尚可')
|
|
22
|
+
assertEquals(classifyDefenseSeverity(0.9).severity, '较稳')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('snapshot includes byAttackDefense and byAttackImpact', () => {
|
|
26
|
+
const tunables = loadDefaultTunables()
|
|
27
|
+
const scenario = resolveScenarios('balanced')[0]
|
|
28
|
+
const snap = runSimulation(scenario, 1, tunables)
|
|
29
|
+
assert.ok(snap.byAttackDefense)
|
|
30
|
+
assert.ok(snap.byAttackImpact)
|
|
31
|
+
assertEquals(typeof snap.byAttackDefense.sybil?.rate, 'number')
|
|
32
|
+
assertEquals(snap.byAttackDefense.sybil.rate >= 0 && snap.byAttackDefense.sybil.rate <= 1, true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('gain-type with low topK harm avoids 群级瘫痪 despite zero suppression', () => {
|
|
36
|
+
/**
|
|
37
|
+
* 构造测试快照。
|
|
38
|
+
* @type {import('../metrics.mjs').SimSnapshot}
|
|
39
|
+
*/
|
|
40
|
+
const snap = {
|
|
41
|
+
malSuppressionRate: 0,
|
|
42
|
+
honestPreservationRate: 1,
|
|
43
|
+
falsePositiveRate: 0,
|
|
44
|
+
fanoutReachRate: 1,
|
|
45
|
+
federationReachRate: 1,
|
|
46
|
+
fanoutCostRatio: 0,
|
|
47
|
+
collusionCollapseRate: 1,
|
|
48
|
+
relayPreservationRate: 1,
|
|
49
|
+
profilePreservationRate: 1,
|
|
50
|
+
quietHonestPreservationRate: 1,
|
|
51
|
+
sybilContainmentRate: 1,
|
|
52
|
+
archiveDefenseRate: 1,
|
|
53
|
+
mailboxReachRate: 1,
|
|
54
|
+
mailboxCostRatio: 0,
|
|
55
|
+
archiveQuorumAccuracy: 1,
|
|
56
|
+
churnReachRate: 1,
|
|
57
|
+
compromiseContainmentRate: 1,
|
|
58
|
+
sleeperReactionRate: 1,
|
|
59
|
+
equivocationDefenseRate: 1,
|
|
60
|
+
observerCount: 1,
|
|
61
|
+
maliciousCount: 1,
|
|
62
|
+
honestCount: 1,
|
|
63
|
+
groupSize: 8,
|
|
64
|
+
byAttackDefense: { relay_farmer: { rate: 0, defended: 0, total: 3 } },
|
|
65
|
+
byAttackImpact: { relay_farmer: { topKCapture: 0.08, reachCollapse: 0.05 } },
|
|
66
|
+
}
|
|
67
|
+
const defense = ATTACK_PROFILES.relay_farmer.defense(snap, 'relay_farmer')
|
|
68
|
+
assertEquals(classifyDefenseSeverity(defense).severity !== '群级瘫痪', true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('collusion defense counts per-node suppression when ring avg stays positive', () => {
|
|
72
|
+
/**
|
|
73
|
+
* 构造测试快照。
|
|
74
|
+
* @type {import('../metrics.mjs').SimSnapshot}
|
|
75
|
+
*/
|
|
76
|
+
const snap = {
|
|
77
|
+
malSuppressionRate: 1,
|
|
78
|
+
honestPreservationRate: 1,
|
|
79
|
+
falsePositiveRate: 0,
|
|
80
|
+
fanoutReachRate: 1,
|
|
81
|
+
federationReachRate: 1,
|
|
82
|
+
fanoutCostRatio: 0,
|
|
83
|
+
collusionCollapseRate: 0,
|
|
84
|
+
relayPreservationRate: 1,
|
|
85
|
+
profilePreservationRate: 1,
|
|
86
|
+
quietHonestPreservationRate: 1,
|
|
87
|
+
sybilContainmentRate: 1,
|
|
88
|
+
archiveDefenseRate: 1,
|
|
89
|
+
mailboxReachRate: 1,
|
|
90
|
+
mailboxCostRatio: 0,
|
|
91
|
+
archiveQuorumAccuracy: 1,
|
|
92
|
+
churnReachRate: 1,
|
|
93
|
+
compromiseContainmentRate: 1,
|
|
94
|
+
sleeperReactionRate: 1,
|
|
95
|
+
equivocationDefenseRate: 1,
|
|
96
|
+
observerCount: 1,
|
|
97
|
+
maliciousCount: 4,
|
|
98
|
+
honestCount: 10,
|
|
99
|
+
groupSize: 8,
|
|
100
|
+
byAttackDefense: { collusion: { rate: 1, defended: 4, total: 4 } },
|
|
101
|
+
byAttackImpact: { collusion: { topKCapture: 0, reachCollapse: 0 } },
|
|
102
|
+
}
|
|
103
|
+
const defense = ATTACK_PROFILES.collusion.defense(snap, 'collusion')
|
|
104
|
+
assertEquals(defense, 1)
|
|
105
|
+
assertEquals(classifyDefenseSeverity(defense).severity, '较稳')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('analyzeVulnerabilities ranks attacks', async () => {
|
|
109
|
+
const tunables = loadDefaultTunables()
|
|
110
|
+
const scenarios = resolveScenarios('all')
|
|
111
|
+
const evalResult = await evaluateTunables(scenarios, [1, 2], tunables, runSimulation)
|
|
112
|
+
const analysis = analyzeVulnerabilities(scenarios, evalResult)
|
|
113
|
+
|
|
114
|
+
assert.ok(analysis.weakest)
|
|
115
|
+
assertEquals(analysis.ranked.length >= 1, true)
|
|
116
|
+
assertEquals(analysis.ranked[0].attack, analysis.weakest.attack)
|
|
117
|
+
assertEquals(typeof analysis.weakest.severity, 'string')
|
|
118
|
+
assertEquals(typeof analysis.weakest.impact, 'string')
|
|
119
|
+
|
|
120
|
+
const md = formatVulnerabilityMarkdown(analysis).join('\n')
|
|
121
|
+
assertEquals(md.includes('防御薄弱点'), true)
|
|
122
|
+
assertEquals(md.includes(analysis.weakest.label), true)
|
|
123
|
+
|
|
124
|
+
const consoleLines = formatVulnerabilityConsole(analysis)
|
|
125
|
+
assertEquals(consoleLines.some(l => l.includes('最弱:')), true)
|
|
126
|
+
shutdownSimPool()
|
|
127
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 传输/信令层子模型:RTC 连接预算 + 信令源多样性(软加成,不强制多来源)。
|
|
3
|
+
* 预算默认值直接派生自真实 rtc_connection_budget.mjs,避免与生产代码人肉脱钩。
|
|
4
|
+
*/
|
|
5
|
+
import { MAX_SOURCE_SLOT_FRACTION, resolveRtcBudgetLimits } from '../transport/rtc_connection_budget.mjs'
|
|
6
|
+
|
|
7
|
+
import { buildRankedNeighborAdj } from './graph_adj.mjs'
|
|
8
|
+
|
|
9
|
+
/** 默认注册的信令/发现源(对齐真实 link_registry:lan + nostr + bt) */
|
|
10
|
+
export const DEFAULT_SIGNALING_SOURCES = Object.freeze(['lan', 'nostr', 'bt'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @returns {object} 传输状态
|
|
14
|
+
*/
|
|
15
|
+
export function createTransportState() {
|
|
16
|
+
const limits = resolveRtcBudgetLimits()
|
|
17
|
+
return {
|
|
18
|
+
rtcMaxActive: limits.maxActive,
|
|
19
|
+
maxJoinsPerMin: limits.maxJoinsPerMin,
|
|
20
|
+
overloadCooldownMs: limits.overloadCooldownMs,
|
|
21
|
+
trustedReserveFraction: limits.trustedReserveFraction,
|
|
22
|
+
minTrustedReserved: limits.minTrustedReserved,
|
|
23
|
+
sourceSlotFraction: MAX_SOURCE_SLOT_FRACTION,
|
|
24
|
+
overloadUntil: 0,
|
|
25
|
+
joinTimestamps: [],
|
|
26
|
+
active: new Set(),
|
|
27
|
+
sourceByPeer: new Map(),
|
|
28
|
+
trustedPeers: new Set(),
|
|
29
|
+
hintsByObserver: new Map(),
|
|
30
|
+
signalingSources: [...DEFAULT_SIGNALING_SOURCES],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {ReturnType<typeof createTransportState>} state 传输状态
|
|
36
|
+
* @param {string} observerId 观察者
|
|
37
|
+
* @param {string} peerId 对端
|
|
38
|
+
* @param {string} sourceId 信令/发现来源
|
|
39
|
+
* @param {number} [weight=0.1] 基础权重
|
|
40
|
+
* @returns {number} 有效权重(多来源软加成,单来源仍可连接)
|
|
41
|
+
*/
|
|
42
|
+
export function transportHintWeight(state, observerId, peerId, sourceId, weight = 0.1) {
|
|
43
|
+
let bucket = state.hintsByObserver.get(observerId)
|
|
44
|
+
if (!bucket) {
|
|
45
|
+
bucket = new Map()
|
|
46
|
+
state.hintsByObserver.set(observerId, bucket)
|
|
47
|
+
}
|
|
48
|
+
let row = bucket.get(peerId)
|
|
49
|
+
if (!row) {
|
|
50
|
+
row = { sources: new Set(), weight: 0, trusted: state.trustedPeers.has(peerId) }
|
|
51
|
+
bucket.set(peerId, row)
|
|
52
|
+
}
|
|
53
|
+
row.sources.add(String(sourceId || 'peer'))
|
|
54
|
+
const multiBoost = row.sources.size >= 2 ? 1.2 : 1
|
|
55
|
+
row.weight = Math.max(row.weight, weight * multiBoost)
|
|
56
|
+
return row.weight
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {ReturnType<typeof createTransportState>} state 传输状态
|
|
61
|
+
* @param {string} peerId 对端
|
|
62
|
+
* @param {string} sourceId 来源
|
|
63
|
+
* @param {number} now 当前时间
|
|
64
|
+
* @returns {boolean} 是否占槽成功
|
|
65
|
+
*/
|
|
66
|
+
export function takeTransportJoinSlot(state, peerId, sourceId, now = Date.now()) {
|
|
67
|
+
const cooldown = state.overloadCooldownMs ?? 15_000
|
|
68
|
+
if (now < state.overloadUntil) return false
|
|
69
|
+
state.joinTimestamps = state.joinTimestamps.filter(t => now - t < 60_000)
|
|
70
|
+
if (state.joinTimestamps.length >= state.maxJoinsPerMin) {
|
|
71
|
+
state.overloadUntil = now + cooldown
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
if (peerId && state.active.has(peerId)) return true
|
|
75
|
+
const isTrusted = peerId && state.trustedPeers.has(peerId)
|
|
76
|
+
const trustedReserved = Math.max(state.minTrustedReserved ?? 3, Math.floor(state.rtcMaxActive * (state.trustedReserveFraction ?? 0.25)))
|
|
77
|
+
const maxNonTrusted = Math.max(1, state.rtcMaxActive - trustedReserved)
|
|
78
|
+
const nonTrusted = [...state.active].filter(id => !state.trustedPeers.has(id)).length
|
|
79
|
+
if (!isTrusted) {
|
|
80
|
+
const sameSource = [...state.sourceByPeer.values()].filter(s => s === sourceId).length
|
|
81
|
+
const sourceCap = Math.max(1, Math.floor(state.rtcMaxActive * (state.sourceSlotFraction ?? 0.25)))
|
|
82
|
+
if (sameSource >= sourceCap) return false
|
|
83
|
+
if (nonTrusted >= maxNonTrusted && state.active.size >= state.rtcMaxActive) {
|
|
84
|
+
state.overloadUntil = now + cooldown
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (state.active.size >= state.rtcMaxActive && !isTrusted) {
|
|
89
|
+
state.overloadUntil = now + cooldown
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
state.joinTimestamps.push(now)
|
|
93
|
+
if (peerId) {
|
|
94
|
+
state.active.add(peerId)
|
|
95
|
+
state.sourceByPeer.set(peerId, String(sourceId || 'peer'))
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {ReturnType<typeof createTransportState>} state 传输状态
|
|
102
|
+
* @param {string} observerId 观察者
|
|
103
|
+
* @param {string[]} friendlyIds 友善节点
|
|
104
|
+
* @param {(id: string) => number} scoreOf 信誉分
|
|
105
|
+
* @param {number} [now=Date.now()] 当前时间(仿真须传 simulationContext.now,与 takeTransportJoinSlot 同一时钟)
|
|
106
|
+
* @returns {{ reach: number, diversity: number, throttleOk: number }} 传输指标
|
|
107
|
+
*/
|
|
108
|
+
export function transportMetrics(state, observerId, friendlyIds, scoreOf, now = Date.now()) {
|
|
109
|
+
const bucket = state.hintsByObserver.get(observerId) ?? new Map()
|
|
110
|
+
const adj = buildRankedNeighborAdj(friendlyIds, scoreOf, 6)
|
|
111
|
+
const visited = new Set([observerId])
|
|
112
|
+
let frontier = [observerId]
|
|
113
|
+
for (let hop = 0; hop < 4 && frontier.length; hop++) {
|
|
114
|
+
const next = []
|
|
115
|
+
for (const id of frontier)
|
|
116
|
+
for (const peer of adj.get(id) ?? []) {
|
|
117
|
+
if (visited.has(peer)) continue
|
|
118
|
+
visited.add(peer)
|
|
119
|
+
next.push(peer)
|
|
120
|
+
}
|
|
121
|
+
frontier = next
|
|
122
|
+
}
|
|
123
|
+
const target = friendlyIds.filter(id => id !== observerId)
|
|
124
|
+
const reach = target.length ? [...visited].filter(id => target.includes(id)).length / target.length : 0
|
|
125
|
+
let diversitySum = 0
|
|
126
|
+
let diversityN = 0
|
|
127
|
+
for (const [peerId, row] of bucket) {
|
|
128
|
+
if (!target.includes(peerId)) continue
|
|
129
|
+
diversitySum += Math.min(1, row.sources.size / Math.max(1, state.signalingSources.length))
|
|
130
|
+
diversityN++
|
|
131
|
+
}
|
|
132
|
+
const diversity = diversityN ? diversitySum / diversityN : 1
|
|
133
|
+
const throttleOk = state.overloadUntil > now ? 0 : 1
|
|
134
|
+
return { reach, diversity, throttleOk }
|
|
135
|
+
}
|