@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/space.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参数搜索空间。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:不再用硬性的 min/max 盒子约束搜索,而是
|
|
5
|
+
* 1. 让每个参数在其**语义域**内自由采样(正数 / (0,1) 比例 / 正整数 / [-1,1] 信誉分),
|
|
6
|
+
* 语义域是结构性的(负惩罚、>1 的比例本就无意义),不是人为调参上下限;
|
|
7
|
+
* 2. 适应度压力**全部来自仿真内生指标**(churn 可达、误伤、quorum 安全等),
|
|
8
|
+
* 不再在此模块追加外生手写常数惩罚。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
resolveArchiveQuorumPeerMin,
|
|
12
|
+
resolveArchiveQuorumPeerStrictMin,
|
|
13
|
+
} from '../trust_graph/resolve.mjs'
|
|
14
|
+
|
|
15
|
+
import { createRng } from './rng.mjs'
|
|
16
|
+
import { loadDefaultTunables } from './tunables_bundle.mjs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {'pos' | 'unit' | 'count' | 'score'} ParamKind
|
|
20
|
+
* - `pos` 正实数(惩罚、加分、权重、衰减率),对数正态采样,恒 > 0
|
|
21
|
+
* - `unit` (0,1) 比例(主张、乘子、分数、默认分),logit 空间采样,恒在 (0,1)
|
|
22
|
+
* - `count` 正整数(跳数、fanout、quorum),对数正态四舍五入,恒 ≥ 1
|
|
23
|
+
* - `score` [-1,1] 信誉分阈值,tanh 空间采样,恒在 (-1,1)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* module: keyof import('./tunables_bundle.mjs').TunablesBundle,
|
|
29
|
+
* key: string,
|
|
30
|
+
* kind: ParamKind,
|
|
31
|
+
* scale?: number,
|
|
32
|
+
* }} ParamSpec
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** 各 kind 的默认探索尺度(采样标准差,作用于各自的变换空间)。 */
|
|
36
|
+
const DEFAULT_SCALE = Object.freeze({ pos: 0.55, unit: 0.7, count: 0.5, score: 0.6 })
|
|
37
|
+
|
|
38
|
+
/** @type {ParamSpec[]} */
|
|
39
|
+
export const PARAM_SPACE = [
|
|
40
|
+
{ module: 'reputation', key: 'penaltyUnknownWant', kind: 'pos' },
|
|
41
|
+
{ module: 'reputation', key: 'penaltyMessageRate', kind: 'pos' },
|
|
42
|
+
{ module: 'reputation', key: 'chunkFetchFailPenalty', kind: 'pos' },
|
|
43
|
+
{ module: 'reputation', key: 'relayRepBump', kind: 'pos' },
|
|
44
|
+
{ module: 'reputation', key: 'wantUnknownThreshold', kind: 'count' },
|
|
45
|
+
{ module: 'reputation', key: 'slashVerifiedMultiplier', kind: 'unit' },
|
|
46
|
+
{ module: 'reputation', key: 'introducerSeedEdge', kind: 'unit' },
|
|
47
|
+
{ module: 'reputation', key: 'recidivismMultiplierStep', kind: 'unit' },
|
|
48
|
+
{ module: 'reputation', key: 'recidivismMax', kind: 'pos' },
|
|
49
|
+
{ module: 'reputation', key: 'redemptionCreditPerStreakLevel', kind: 'pos' },
|
|
50
|
+
{ module: 'reputation', key: 'inviteRedemptionCreditPerBad', kind: 'pos' },
|
|
51
|
+
{ module: 'reputation', key: 'inviteDeltaBoostPerBad', kind: 'unit' },
|
|
52
|
+
{ module: 'reputation', key: 'inviteDeltaBoostMax', kind: 'unit' },
|
|
53
|
+
{ module: 'trustGraph', key: 'federationFanoutTopKRatio', kind: 'unit' },
|
|
54
|
+
{ module: 'trustGraph', key: 'federationFanoutTopKFloor', kind: 'count' },
|
|
55
|
+
{ module: 'trustGraph', key: 'federationFanoutTopKCap', kind: 'count' },
|
|
56
|
+
{ module: 'trustGraph', key: 'hintDefaultWeight', kind: 'unit' },
|
|
57
|
+
{ module: 'trustGraph', key: 'hintMaxBonus', kind: 'unit' },
|
|
58
|
+
{ module: 'trustGraph', key: 'rosterDefaultScore', kind: 'unit' },
|
|
59
|
+
{ module: 'social', key: 'socialRepHideThreshold', kind: 'score' },
|
|
60
|
+
{ module: 'mailbox', key: 'maxHop', kind: 'count' },
|
|
61
|
+
{ module: 'mailbox', key: 'relayFanoutTrustedRatio', kind: 'unit' },
|
|
62
|
+
{ module: 'mailbox', key: 'relayFanoutTrustedFloor', kind: 'count' },
|
|
63
|
+
{ module: 'mailbox', key: 'relayFanoutTrustedCap', kind: 'count' },
|
|
64
|
+
{ module: 'mailbox', key: 'wantFanoutRatio', kind: 'unit' },
|
|
65
|
+
{ module: 'mailbox', key: 'wantFanoutFloor', kind: 'count' },
|
|
66
|
+
{ module: 'mailbox', key: 'wantFanoutCap', kind: 'count' },
|
|
67
|
+
{ module: 'mailbox', key: 'networkBudgetPerRound', kind: 'count' },
|
|
68
|
+
{ module: 'archive', key: 'archiveQuorumPeerMinRatio', kind: 'unit' },
|
|
69
|
+
{ module: 'archive', key: 'archiveQuorumPeerMinFloor', kind: 'count' },
|
|
70
|
+
{ module: 'archive', key: 'archiveQuorumPeerStrictMinRatio', kind: 'unit' },
|
|
71
|
+
{ module: 'archive', key: 'archiveQuorumPeerStrictMinFloor', kind: 'count' },
|
|
72
|
+
{ module: 'admission', key: 'powVoluntaryBonusCap', kind: 'unit' },
|
|
73
|
+
{ module: 'admission', key: 'powVoluntaryBonusScaleBits', kind: 'count' },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const EPS = 1e-9
|
|
77
|
+
|
|
78
|
+
/** 规整输出时的语义域 epsilon:需大于 6 位四舍五入精度,避免正数被舍成 0。 */
|
|
79
|
+
const DOMAIN_EPS = 1e-6
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} x 任意实数
|
|
83
|
+
* @returns {number} softplus,恒 > 0
|
|
84
|
+
*/
|
|
85
|
+
function softplus(x) {
|
|
86
|
+
const z = Number.isFinite(x) ? x : 0
|
|
87
|
+
if (z > 20) return z
|
|
88
|
+
if (z < -20) return Math.exp(z)
|
|
89
|
+
return Math.log1p(Math.exp(z))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {number} x 任意实数
|
|
94
|
+
* @returns {number} sigmoid,落在 (0,1)
|
|
95
|
+
*/
|
|
96
|
+
function sigmoid(x) {
|
|
97
|
+
return 1 / (1 + Math.exp(-x))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {number} p (0,1) 概率
|
|
102
|
+
* @returns {number} logit 值
|
|
103
|
+
*/
|
|
104
|
+
function logit(p) {
|
|
105
|
+
const safe = p > 0 && p < 1 ? p : sigmoid(p)
|
|
106
|
+
return Math.log(safe / (1 - safe))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {number} s (-1,1) 信誉分
|
|
111
|
+
* @returns {number} atanh 值
|
|
112
|
+
*/
|
|
113
|
+
function atanhScore(s) {
|
|
114
|
+
const safe = s > -1 && s < 1 ? s : Math.tanh(s)
|
|
115
|
+
return Math.atanh(safe)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {number} value 原始值
|
|
120
|
+
* @param {number} [decimals=8] 保留小数位
|
|
121
|
+
* @returns {number} 消除浮点尾差的值
|
|
122
|
+
*/
|
|
123
|
+
export function quantize(value, decimals = 8) {
|
|
124
|
+
if (!Number.isFinite(value)) return value
|
|
125
|
+
return Number(value.toFixed(Math.min(12, Math.max(0, decimals))))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 把任意数值规整回参数的语义域(结构性正确性,而非调参上下限)。
|
|
130
|
+
* @param {number} value 原始值
|
|
131
|
+
* @param {ParamSpec} spec 参数规格
|
|
132
|
+
* @returns {number} 规整后的值
|
|
133
|
+
*/
|
|
134
|
+
export function normalizeParam(value, spec) {
|
|
135
|
+
if (!Number.isFinite(value))
|
|
136
|
+
return loadDefaultTunables()[spec.module][spec.key]
|
|
137
|
+
switch (spec.kind) {
|
|
138
|
+
case 'count':
|
|
139
|
+
if (Number.isInteger(value) && value >= 1) return value
|
|
140
|
+
return Math.round(1 + softplus(value - 1))
|
|
141
|
+
case 'pos':
|
|
142
|
+
return quantize(value > 0 ? value : softplus(value) + DOMAIN_EPS, 6)
|
|
143
|
+
case 'unit':
|
|
144
|
+
if (value > 0 && value < 1) return quantize(value, 6)
|
|
145
|
+
{
|
|
146
|
+
const repaired = quantize(sigmoid(value), 6)
|
|
147
|
+
if (repaired >= 1) return 1 - DOMAIN_EPS
|
|
148
|
+
if (repaired <= 0) return DOMAIN_EPS
|
|
149
|
+
return repaired
|
|
150
|
+
}
|
|
151
|
+
case 'score':
|
|
152
|
+
if (value > -1 && value < 1) return quantize(value, 6)
|
|
153
|
+
{
|
|
154
|
+
const repaired = quantize(Math.tanh(value), 6)
|
|
155
|
+
if (repaired >= 1) return 1 - DOMAIN_EPS
|
|
156
|
+
if (repaired <= -1) return -1 + DOMAIN_EPS
|
|
157
|
+
return repaired
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return value
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 将 {@link PARAM_SPACE} 中各 tunables 键规整回语义域。
|
|
166
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} bundle 待规整的参数包
|
|
167
|
+
* @returns {import('./tunables_bundle.mjs').TunablesBundle} 语义域规整后的参数包
|
|
168
|
+
*/
|
|
169
|
+
export function normalizeBundle(bundle) {
|
|
170
|
+
const out = structuredClone(bundle)
|
|
171
|
+
for (const spec of PARAM_SPACE)
|
|
172
|
+
out[spec.module][spec.key] = normalizeParam(out[spec.module][spec.key], spec)
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 关系规则:严格仲裁人数不得低于基础收集仲裁人数(否则语义自相矛盾)。
|
|
178
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} bundle 待修正的参数包(就地修改)
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
export function sanitizeArchiveQuorum(bundle) {
|
|
182
|
+
const refN = 8
|
|
183
|
+
const peerMin = resolveArchiveQuorumPeerMin(refN, bundle.archive)
|
|
184
|
+
const strictMin = resolveArchiveQuorumPeerStrictMin(refN, bundle.archive)
|
|
185
|
+
if (strictMin < peerMin)
|
|
186
|
+
bundle.archive.archiveQuorumPeerStrictMinRatio = Math.max(
|
|
187
|
+
Number(bundle.archive.archiveQuorumPeerMinRatio) || 0.25,
|
|
188
|
+
Number(bundle.archive.archiveQuorumPeerStrictMinRatio) || 0.5,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 对参数包施加结构性关系规则(如 archive quorum 一致性)。
|
|
194
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} bundle 待归一的参数包
|
|
195
|
+
* @returns {import('./tunables_bundle.mjs').TunablesBundle} 规则归一后的参数包
|
|
196
|
+
*/
|
|
197
|
+
export function sanitizeBundle(bundle) {
|
|
198
|
+
sanitizeArchiveQuorum(bundle)
|
|
199
|
+
return bundle
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 把数值映射到 kind 各自的变换空间(采样/漂移度量都在该空间内进行)。
|
|
204
|
+
* @param {number} value 数值
|
|
205
|
+
* @param {ParamKind} kind 参数类型
|
|
206
|
+
* @returns {number} 变换空间坐标
|
|
207
|
+
*/
|
|
208
|
+
function toLatent(value, kind) {
|
|
209
|
+
switch (kind) {
|
|
210
|
+
case 'pos':
|
|
211
|
+
case 'count':
|
|
212
|
+
return Math.log(value > EPS ? value : softplus(value) + EPS)
|
|
213
|
+
case 'unit':
|
|
214
|
+
return logit(value)
|
|
215
|
+
case 'score':
|
|
216
|
+
return atanhScore(value)
|
|
217
|
+
default:
|
|
218
|
+
return value
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* {@link toLatent} 的逆映射。
|
|
224
|
+
* @param {number} latent 变换空间坐标
|
|
225
|
+
* @param {ParamKind} kind 参数类型
|
|
226
|
+
* @returns {number} 语义域数值
|
|
227
|
+
*/
|
|
228
|
+
function fromLatent(latent, kind) {
|
|
229
|
+
switch (kind) {
|
|
230
|
+
case 'pos':
|
|
231
|
+
return Math.exp(latent)
|
|
232
|
+
case 'count':
|
|
233
|
+
return Math.max(1, Math.round(Math.exp(latent)))
|
|
234
|
+
case 'unit':
|
|
235
|
+
return sigmoid(latent)
|
|
236
|
+
case 'score':
|
|
237
|
+
return Math.tanh(latent)
|
|
238
|
+
default:
|
|
239
|
+
return latent
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {() => number} rng 随机源
|
|
245
|
+
* @returns {number} 标准正态样本(Box–Muller)
|
|
246
|
+
*/
|
|
247
|
+
function gaussian(rng) {
|
|
248
|
+
let u = 0
|
|
249
|
+
let v = 0
|
|
250
|
+
while (u <= EPS) u = rng()
|
|
251
|
+
while (v <= EPS) v = rng()
|
|
252
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 围绕 `center` 在语义域内采样一个参数(无硬性盒子)。
|
|
257
|
+
* @param {() => number} rng 随机源
|
|
258
|
+
* @param {ParamSpec} spec 参数规格
|
|
259
|
+
* @param {number} center 采样中心(默认值或父代值)
|
|
260
|
+
* @param {number} [scaleMul=1] 尺度系数(局部变异时取 <1)
|
|
261
|
+
* @returns {number} 采样值
|
|
262
|
+
*/
|
|
263
|
+
export function sampleParam(rng, spec, center, scaleMul = 1) {
|
|
264
|
+
const scale = (spec.scale ?? DEFAULT_SCALE[spec.kind]) * scaleMul
|
|
265
|
+
const latent = toLatent(center, spec.kind) + gaussian(rng) * scale
|
|
266
|
+
return normalizeParam(fromLatent(latent, spec.kind), spec)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 在语义域内随机采样一组 tunables 候选。
|
|
271
|
+
* @param {number} seed 随机种子
|
|
272
|
+
* @returns {import('./tunables_bundle.mjs').TunablesBundle} 随机候选参数包
|
|
273
|
+
*/
|
|
274
|
+
export function randomCandidate(seed) {
|
|
275
|
+
const rng = createRng(seed)
|
|
276
|
+
const base = loadDefaultTunables()
|
|
277
|
+
for (const spec of PARAM_SPACE)
|
|
278
|
+
base[spec.module][spec.key] = sampleParam(rng, spec, base[spec.module][spec.key])
|
|
279
|
+
return sanitizeBundle(base)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} parent 父代
|
|
284
|
+
* @param {number} seed 种子
|
|
285
|
+
* @param {number} [mutationRate=0.3] 变异率
|
|
286
|
+
* @param {number} [scaleMul=0.5] 局部探索尺度
|
|
287
|
+
* @returns {import('./tunables_bundle.mjs').TunablesBundle} 变异子代
|
|
288
|
+
*/
|
|
289
|
+
export function mutateCandidate(parent, seed, mutationRate = 0.3, scaleMul = 0.5) {
|
|
290
|
+
const rng = createRng(seed)
|
|
291
|
+
const child = structuredClone(parent)
|
|
292
|
+
for (const spec of PARAM_SPACE) {
|
|
293
|
+
if (rng() > mutationRate) continue
|
|
294
|
+
child[spec.module][spec.key] = sampleParam(rng, spec, child[spec.module][spec.key], scaleMul)
|
|
295
|
+
}
|
|
296
|
+
return sanitizeBundle(child)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} a 父代 A
|
|
301
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} b 父代 B
|
|
302
|
+
* @param {number} seed 种子
|
|
303
|
+
* @returns {import('./tunables_bundle.mjs').TunablesBundle} 交叉子代
|
|
304
|
+
*/
|
|
305
|
+
export function crossoverCandidates(a, b, seed) {
|
|
306
|
+
const rng = createRng(seed)
|
|
307
|
+
const out = structuredClone(a)
|
|
308
|
+
for (const spec of PARAM_SPACE)
|
|
309
|
+
if (rng() < 0.5)
|
|
310
|
+
out[spec.module][spec.key] = b[spec.module][spec.key]
|
|
311
|
+
return sanitizeBundle(out)
|
|
312
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import {
|
|
5
|
+
capMaliciousByPowBudget,
|
|
6
|
+
honestJoinDelayPenalty,
|
|
7
|
+
roundsPerIdentity,
|
|
8
|
+
} from '../admission.mjs'
|
|
9
|
+
|
|
10
|
+
test('roundsPerIdentity grows with difficulty', () => {
|
|
11
|
+
const low = roundsPerIdentity(16)
|
|
12
|
+
const high = roundsPerIdentity(20)
|
|
13
|
+
assertEquals(high > low, true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('capMaliciousByPowBudget limits sybil count', () => {
|
|
17
|
+
const capped = capMaliciousByPowBudget(100, 18, 40)
|
|
18
|
+
assertEquals(capped < 100, true)
|
|
19
|
+
assertEquals(capped >= 1, true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('honestJoinDelayPenalty is bounded 0..1', () => {
|
|
23
|
+
const p = honestJoinDelayPenalty(18, 40)
|
|
24
|
+
assertEquals(p >= 0 && p <= 1, true)
|
|
25
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import {
|
|
5
|
+
mutateAttackGenome,
|
|
6
|
+
normalizeAttackGenome,
|
|
7
|
+
randomAttackGenome,
|
|
8
|
+
resolveAttackParams,
|
|
9
|
+
updateAttackHallOfFame,
|
|
10
|
+
} from '../attack_space.mjs'
|
|
11
|
+
import { createRng } from '../rng.mjs'
|
|
12
|
+
|
|
13
|
+
test('randomAttackGenome and mutate are deterministic with seed', () => {
|
|
14
|
+
const rng1 = createRng(7)
|
|
15
|
+
const g1 = randomAttackGenome(rng1)
|
|
16
|
+
const rng2 = createRng(7)
|
|
17
|
+
const g2 = randomAttackGenome(rng2)
|
|
18
|
+
assertEquals(g1.global.activationRate, g2.global.activationRate)
|
|
19
|
+
const child = mutateAttackGenome(g1, 99)
|
|
20
|
+
assert(child.global.burstSize >= 1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('resolveAttackParams merges global and per-attack overrides', () => {
|
|
24
|
+
const genome = normalizeAttackGenome({
|
|
25
|
+
global: { activationRate: 0.5, burstSize: 3, targetBiasHighRep: 0.2, eclipseFocus: 0.6 },
|
|
26
|
+
byAttack: { sybil: { activationRate: 0.9 } },
|
|
27
|
+
})
|
|
28
|
+
const p = resolveAttackParams('sybil', genome)
|
|
29
|
+
assertEquals(p.activationRate, 0.9)
|
|
30
|
+
assertEquals(p.burstSize, 3)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('attack hall of fame keeps top fitness', () => {
|
|
34
|
+
const genome = normalizeAttackGenome(undefined)
|
|
35
|
+
let hof = updateAttackHallOfFame([], genome, 0.5)
|
|
36
|
+
hof = updateAttackHallOfFame(hof, { ...genome, global: { ...genome.global, burstSize: 9 } }, 0.9)
|
|
37
|
+
assert(hof.length >= 1)
|
|
38
|
+
assertEquals(hof[0].fitness, 0.9)
|
|
39
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { isQuietHonestBehavior, sampleBehavior, BEHAVIOR_KEYS } from '../behavior.mjs'
|
|
5
|
+
import { runSimulation } from '../model.mjs'
|
|
6
|
+
import { createRng } from '../rng.mjs'
|
|
7
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
8
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
9
|
+
|
|
10
|
+
test('sampleBehavior produces 9-dim vector in [0,1]', () => {
|
|
11
|
+
const rng = createRng(99)
|
|
12
|
+
const b = sampleBehavior(rng)
|
|
13
|
+
for (const key of BEHAVIOR_KEYS)
|
|
14
|
+
assert(b[key] >= 0 && b[key] <= 1, key)
|
|
15
|
+
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('isQuietHonestBehavior detects low-post high-like users', () => {
|
|
19
|
+
assert(isQuietHonestBehavior({
|
|
20
|
+
postRate: 0.05, likeRate: 0.6, replyRate: 0.1,
|
|
21
|
+
relayRate: 0.2, chunkServeRate: 0.1, onlineStability: 0.9,
|
|
22
|
+
blockProneness: 0.05, archiveSubmitRate: 0.05, mentionRate: 0.05,
|
|
23
|
+
}))
|
|
24
|
+
assertEquals(isQuietHonestBehavior({
|
|
25
|
+
postRate: 0.5, likeRate: 0.6, replyRate: 0.1,
|
|
26
|
+
relayRate: 0.2, chunkServeRate: 0.1, onlineStability: 0.9,
|
|
27
|
+
blockProneness: 0.05, archiveSubmitRate: 0.05, mentionRate: 0.05,
|
|
28
|
+
}), false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('quiet_honest scenario preserves silent users', () => {
|
|
32
|
+
const tunables = loadDefaultTunables()
|
|
33
|
+
const scenario = resolveScenarios('quiet_honest')[0]
|
|
34
|
+
const snap = runSimulation(scenario, 11, tunables)
|
|
35
|
+
assertEquals(typeof snap.quietHonestPreservationRate, 'number')
|
|
36
|
+
assert(snap.quietHonestPreservationRate >= 0.5, 'quiet users should mostly survive')
|
|
37
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { runCoevolution } from '../coevolution.mjs'
|
|
5
|
+
import { minPanelFitness } from '../metrics.mjs'
|
|
6
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
7
|
+
import { shutdownSimPool } from '../sim_pool.mjs'
|
|
8
|
+
|
|
9
|
+
test('runCoevolution returns blue and red champions', async () => {
|
|
10
|
+
const scenarios = resolveScenarios('balanced').slice(0, 1)
|
|
11
|
+
const result = await runCoevolution({
|
|
12
|
+
scenarios,
|
|
13
|
+
generations: 2,
|
|
14
|
+
population: 4,
|
|
15
|
+
redPopulation: 3,
|
|
16
|
+
seeds: [1],
|
|
17
|
+
seedBase: 100,
|
|
18
|
+
})
|
|
19
|
+
assert(result.best.result.fitness >= 0)
|
|
20
|
+
assert(result.bestRed.attackGenome?.global?.burstSize >= 1)
|
|
21
|
+
assert(Array.isArray(result.attackHof))
|
|
22
|
+
assert(result.history.length >= 2)
|
|
23
|
+
shutdownSimPool()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('minPanelFitness tracks worst scenario min', () => {
|
|
27
|
+
const byScenario = {
|
|
28
|
+
a: { min: 0.8, fitness: 0.8, mean: 0.8, max: 0.8, std: 0, snapshots: [] },
|
|
29
|
+
b: { min: 0.3, fitness: 0.5, mean: 0.5, max: 0.5, std: 0, snapshots: [] },
|
|
30
|
+
}
|
|
31
|
+
assertEquals(minPanelFitness(byScenario), 0.3)
|
|
32
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
|
|
3
|
+
import { assert, assertEquals } from '../../test/helpers/assert.mjs'
|
|
4
|
+
import { coldStartDiscoveryJoin, discoveryReach } from '../discovery.mjs'
|
|
5
|
+
import { buildWorld, runSimulation } from '../model.mjs'
|
|
6
|
+
import { createRng } from '../rng.mjs'
|
|
7
|
+
import { resolveScenarios } from '../scenarios.mjs'
|
|
8
|
+
import { loadDefaultTunables } from '../tunables_bundle.mjs'
|
|
9
|
+
|
|
10
|
+
test('K=0 cold observer starts isolated before mesh scan', () => {
|
|
11
|
+
const tunables = loadDefaultTunables()
|
|
12
|
+
const scenario = resolveScenarios('cold_start')[0]
|
|
13
|
+
const { observers, simulationContext, nodes } = buildWorld(scenario, 7, tunables)
|
|
14
|
+
const cold = observers.find(obs => obs.coldStart)
|
|
15
|
+
assert(cold)
|
|
16
|
+
const friendlyIds = nodes.filter(n => !n.newcomer && (n.kind === 'honest' || n.kind === 'relay')).map(n => n.id)
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} id 节点 id
|
|
19
|
+
* @returns {number} 主观声誉分
|
|
20
|
+
*/
|
|
21
|
+
const scoreOf = id => cold.reputation.byNodeHash[id]?.score ?? 0
|
|
22
|
+
assertEquals(discoveryReach(simulationContext.discovery, cold.id, friendlyIds, scoreOf, tunables.mailbox.maxHop), 0)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('coldStartDiscoveryJoin raises reach from zero', () => {
|
|
26
|
+
const tunables = loadDefaultTunables()
|
|
27
|
+
const scenario = resolveScenarios('cold_start')[0]
|
|
28
|
+
const { observers, simulationContext, nodes } = buildWorld(scenario, 9, tunables)
|
|
29
|
+
const cold = observers.find(obs => obs.coldStart)
|
|
30
|
+
assert(cold)
|
|
31
|
+
const friendlyIds = nodes.filter(n => !n.newcomer && (n.kind === 'honest' || n.kind === 'relay')).map(n => n.id)
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} id 节点 id
|
|
34
|
+
* @returns {number} 主观声誉分
|
|
35
|
+
*/
|
|
36
|
+
const scoreOf = id => cold.reputation.byNodeHash[id]?.score ?? 0
|
|
37
|
+
const rng = createRng(99)
|
|
38
|
+
for (let round = 0; round < 6; round++)
|
|
39
|
+
coldStartDiscoveryJoin(simulationContext.discovery, cold.id, friendlyIds, rng)
|
|
40
|
+
assert(discoveryReach(simulationContext.discovery, cold.id, friendlyIds, scoreOf, tunables.mailbox.maxHop) > 0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('cold_start scenario finishes with transport reach', () => {
|
|
44
|
+
const snap = runSimulation(resolveScenarios('cold_start')[0], 42, loadDefaultTunables())
|
|
45
|
+
assert(snap.transportReachRate > 0)
|
|
46
|
+
})
|