@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/metrics.mjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 仿真指标与适应度(全部内生,无外生规则惩罚)。
|
|
3
|
+
*/
|
|
4
|
+
import { runSimulation } from './model.mjs'
|
|
5
|
+
import { runSimulationJobs, defaultConcurrency } from './sim_pool.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* malSuppressionRate: number,
|
|
10
|
+
* honestPreservationRate: number,
|
|
11
|
+
* falsePositiveRate: number,
|
|
12
|
+
* fanoutReachRate: number,
|
|
13
|
+
* federationReachRate: number,
|
|
14
|
+
* fanoutCostRatio: number,
|
|
15
|
+
* collusionCollapseRate: number,
|
|
16
|
+
* relayPreservationRate: number,
|
|
17
|
+
* profilePreservationRate: number,
|
|
18
|
+
* sybilContainmentRate: number,
|
|
19
|
+
* archiveDefenseRate: number,
|
|
20
|
+
* mailboxReachRate: number,
|
|
21
|
+
* mailboxCostRatio: number,
|
|
22
|
+
* nodeOverloadRate: number,
|
|
23
|
+
* archiveQuorumAccuracy: number,
|
|
24
|
+
* churnReachRate: number,
|
|
25
|
+
* compromiseContainmentRate: number,
|
|
26
|
+
* sleeperReactionRate: number,
|
|
27
|
+
* equivocationDefenseRate: number,
|
|
28
|
+
* transportReachRate: number,
|
|
29
|
+
* signalingDiversityRate: number,
|
|
30
|
+
* joinThrottleEffectiveness: number,
|
|
31
|
+
* honestJoinDelayPenalty: number,
|
|
32
|
+
* observerCount: number,
|
|
33
|
+
* maliciousCount: number,
|
|
34
|
+
* honestCount: number,
|
|
35
|
+
* groupSize: number,
|
|
36
|
+
* quietHonestPreservationRate: number,
|
|
37
|
+
* byAttackDefense?: Partial<Record<string, { defended: number, total: number, rate: number }>>,
|
|
38
|
+
* byAttackImpact?: Partial<Record<string, { topKCapture: number, reachCollapse: number }>>,
|
|
39
|
+
* }} SimSnapshot
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** @typedef {Partial<Record<keyof SimSnapshot, number>>} MetricWeights */
|
|
43
|
+
|
|
44
|
+
/** @typedef {{ concurrency?: number, serial?: boolean }} EvalOptions */
|
|
45
|
+
|
|
46
|
+
/** 参与适应度加权的速率类指标 */
|
|
47
|
+
export const RATE_METRIC_KEYS = Object.freeze([
|
|
48
|
+
'malSuppressionRate',
|
|
49
|
+
'honestPreservationRate',
|
|
50
|
+
'falsePositiveRate',
|
|
51
|
+
'fanoutReachRate',
|
|
52
|
+
'federationReachRate',
|
|
53
|
+
'fanoutCostRatio',
|
|
54
|
+
'collusionCollapseRate',
|
|
55
|
+
'relayPreservationRate',
|
|
56
|
+
'profilePreservationRate',
|
|
57
|
+
'quietHonestPreservationRate',
|
|
58
|
+
'sybilContainmentRate',
|
|
59
|
+
'archiveDefenseRate',
|
|
60
|
+
'mailboxReachRate',
|
|
61
|
+
'mailboxCostRatio',
|
|
62
|
+
'nodeOverloadRate',
|
|
63
|
+
'archiveQuorumAccuracy',
|
|
64
|
+
'churnReachRate',
|
|
65
|
+
'compromiseContainmentRate',
|
|
66
|
+
'sleeperReactionRate',
|
|
67
|
+
'equivocationDefenseRate',
|
|
68
|
+
'transportReachRate',
|
|
69
|
+
'signalingDiversityRate',
|
|
70
|
+
'joinThrottleEffectiveness',
|
|
71
|
+
'honestJoinDelayPenalty',
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
/** 默认适应度加权系数 */
|
|
75
|
+
export const DEFAULT_WEIGHTS = Object.freeze({
|
|
76
|
+
malSuppressionRate: 0.18,
|
|
77
|
+
honestPreservationRate: 0.15,
|
|
78
|
+
collusionCollapseRate: 0.08,
|
|
79
|
+
sybilContainmentRate: 0.06,
|
|
80
|
+
archiveDefenseRate: 0.07,
|
|
81
|
+
relayPreservationRate: 0.05,
|
|
82
|
+
profilePreservationRate: 0.03,
|
|
83
|
+
quietHonestPreservationRate: 0.05,
|
|
84
|
+
federationReachRate: 0.22,
|
|
85
|
+
fanoutReachRate: 0.02,
|
|
86
|
+
mailboxReachRate: 0.07,
|
|
87
|
+
archiveQuorumAccuracy: 0.06,
|
|
88
|
+
churnReachRate: 0.14,
|
|
89
|
+
transportReachRate: 0.04,
|
|
90
|
+
signalingDiversityRate: 0.02,
|
|
91
|
+
joinThrottleEffectiveness: 0.03,
|
|
92
|
+
compromiseContainmentRate: 0.05,
|
|
93
|
+
sleeperReactionRate: 0.04,
|
|
94
|
+
equivocationDefenseRate: 0.04,
|
|
95
|
+
honestJoinDelayPenalty: -0.03,
|
|
96
|
+
falsePositiveRate: -0.16,
|
|
97
|
+
fanoutCostRatio: -0.04,
|
|
98
|
+
mailboxCostRatio: -0.045,
|
|
99
|
+
nodeOverloadRate: -0.08,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {SimSnapshot} snap 单次快照
|
|
104
|
+
* @param {MetricWeights} [weights] 权重
|
|
105
|
+
* @returns {number} 适应度(越高越好)
|
|
106
|
+
*/
|
|
107
|
+
export function fitnessFromSnapshot(snap, weights = DEFAULT_WEIGHTS) {
|
|
108
|
+
let score = 0
|
|
109
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
110
|
+
const v = snap[/** @type {keyof SimSnapshot} */ key]
|
|
111
|
+
if (typeof v === 'number' && typeof weight === 'number')
|
|
112
|
+
score += v * weight
|
|
113
|
+
}
|
|
114
|
+
return score
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {SimSnapshot[]} snaps 多次快照
|
|
119
|
+
* @param {MetricWeights} [weights] 权重
|
|
120
|
+
* @returns {{ mean: number, min: number, max: number, std: number, fitness: number, snapshots: SimSnapshot[] }} 聚合统计
|
|
121
|
+
*/
|
|
122
|
+
export function aggregateSnapshots(snaps, weights = DEFAULT_WEIGHTS) {
|
|
123
|
+
const fitnesses = snaps.map(s => fitnessFromSnapshot(s, weights))
|
|
124
|
+
const mean = fitnesses.reduce((a, b) => a + b, 0) / Math.max(1, fitnesses.length)
|
|
125
|
+
const min = Math.min(...fitnesses)
|
|
126
|
+
const max = Math.max(...fitnesses)
|
|
127
|
+
const variance = fitnesses.reduce((s, f) => s + (f - mean) ** 2, 0) / Math.max(1, fitnesses.length)
|
|
128
|
+
return {
|
|
129
|
+
mean,
|
|
130
|
+
min,
|
|
131
|
+
max,
|
|
132
|
+
std: Math.sqrt(variance),
|
|
133
|
+
fitness: mean - 0.5 * Math.sqrt(variance),
|
|
134
|
+
snapshots: snaps,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {import('./scenarios.mjs').SimScenario[]} scenarios 场景列表
|
|
140
|
+
* @param {number[]} seeds 种子列表
|
|
141
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
142
|
+
* @param {(scenario: import('./scenarios.mjs').SimScenario, seed: number, tunables: import('./tunables_bundle.mjs').TunablesBundle) => SimSnapshot} runSim 仿真函数
|
|
143
|
+
* @param {MetricWeights} [weights] 权重
|
|
144
|
+
* @param {EvalOptions} [options] 评估选项
|
|
145
|
+
* @returns {Promise<{ fitness: number, mean: number, min: number, max: number, std: number, byScenario: Record<string, ReturnType<typeof aggregateSnapshots>> }>} 跨场景评估结果
|
|
146
|
+
*/
|
|
147
|
+
export async function evaluateTunables(scenarios, seeds, tunables, runSim, weights = DEFAULT_WEIGHTS, options = {}) {
|
|
148
|
+
return evaluateTunablesAgainstAttacks(scenarios, seeds, tunables, [undefined], (scenario, seed, tunablesBundle, attackGenome) =>
|
|
149
|
+
runSim(scenario, seed, tunablesBundle, attackGenome), weights, options)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @typedef {{
|
|
154
|
+
* fitness: number,
|
|
155
|
+
* mean: number,
|
|
156
|
+
* min: number,
|
|
157
|
+
* max: number,
|
|
158
|
+
* std: number,
|
|
159
|
+
* byScenario: Record<string, ReturnType<typeof aggregateSnapshots>>,
|
|
160
|
+
* }} EvalResult
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @typedef {{
|
|
165
|
+
* tunables: import('./tunables_bundle.mjs').TunablesBundle,
|
|
166
|
+
* attackPanel?: Array<import('./attack_space.mjs').AttackGenome | undefined>,
|
|
167
|
+
* }} EvalCandidate
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {import('./scenarios.mjs').SimScenario[]} scenarios 场景
|
|
172
|
+
* @param {number[]} seeds 种子
|
|
173
|
+
* @param {Array<import('./attack_space.mjs').AttackGenome | undefined>} panel 攻击面板
|
|
174
|
+
* @param {Map<string, Map<number, { attackIndex: number, snap: SimSnapshot }[]>>} snapsByScenarioSeed 分组快照
|
|
175
|
+
* @param {MetricWeights} weights 权重
|
|
176
|
+
* @returns {EvalResult} 跨场景面板最差评估
|
|
177
|
+
*/
|
|
178
|
+
function buildEvalFromGroupedSnaps(scenarios, seeds, panel, snapsByScenarioSeed, weights) {
|
|
179
|
+
/** @type {Record<string, ReturnType<typeof aggregateSnapshots>>} */
|
|
180
|
+
const byScenario = {}
|
|
181
|
+
let totalFitness = 0
|
|
182
|
+
let totalMean = 0
|
|
183
|
+
let worstMin = Infinity
|
|
184
|
+
let bestMax = -Infinity
|
|
185
|
+
let totalStd = 0
|
|
186
|
+
|
|
187
|
+
for (const scenario of scenarios) {
|
|
188
|
+
const bySeed = snapsByScenarioSeed.get(scenario.id) ?? new Map()
|
|
189
|
+
/** @type {SimSnapshot[]} */
|
|
190
|
+
const snaps = []
|
|
191
|
+
for (const seed of seeds) {
|
|
192
|
+
const rows = bySeed.get(seed) ?? []
|
|
193
|
+
let worstFit = Infinity
|
|
194
|
+
/** @type {SimSnapshot | null} */
|
|
195
|
+
let worstSnap = null
|
|
196
|
+
for (const row of rows) {
|
|
197
|
+
const f = fitnessFromSnapshot(row.snap, weights)
|
|
198
|
+
if (f < worstFit) {
|
|
199
|
+
worstFit = f
|
|
200
|
+
worstSnap = row.snap
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (worstSnap) snaps.push(worstSnap)
|
|
204
|
+
}
|
|
205
|
+
const agg = aggregateSnapshots(snaps, weights)
|
|
206
|
+
byScenario[scenario.id] = agg
|
|
207
|
+
totalFitness += agg.fitness
|
|
208
|
+
totalMean += agg.mean
|
|
209
|
+
worstMin = Math.min(worstMin, agg.min)
|
|
210
|
+
bestMax = Math.max(bestMax, agg.max)
|
|
211
|
+
totalStd += agg.std
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const n = Math.max(1, scenarios.length)
|
|
215
|
+
return {
|
|
216
|
+
fitness: totalFitness / n,
|
|
217
|
+
mean: totalMean / n,
|
|
218
|
+
min: worstMin,
|
|
219
|
+
max: bestMax,
|
|
220
|
+
std: totalStd / n,
|
|
221
|
+
byScenario,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 多候选批量评估:一次派发全部 job,按候选归并适应度。
|
|
227
|
+
* @param {import('./scenarios.mjs').SimScenario[]} scenarios 场景
|
|
228
|
+
* @param {number[]} seeds 种子
|
|
229
|
+
* @param {EvalCandidate[]} candidates 候选列表(各自 tunables + attackPanel)
|
|
230
|
+
* @param {(scenario: import('./scenarios.mjs').SimScenario, seed: number, tunables: import('./tunables_bundle.mjs').TunablesBundle, attackGenome?: import('./attack_space.mjs').AttackGenome) => SimSnapshot} runSim 仿真
|
|
231
|
+
* @param {MetricWeights} [weights] 权重
|
|
232
|
+
* @param {EvalOptions} [options] 评估选项
|
|
233
|
+
* @returns {Promise<EvalResult[]>} 与 candidates 同序的评估结果
|
|
234
|
+
*/
|
|
235
|
+
export async function evaluateManyAgainstAttacks(scenarios, seeds, candidates, runSim, weights = DEFAULT_WEIGHTS, options = {}) {
|
|
236
|
+
if (!candidates.length) return []
|
|
237
|
+
|
|
238
|
+
const concurrency = options.concurrency ?? defaultConcurrency()
|
|
239
|
+
const usePool = !options.serial && concurrency > 1 && runSim === runSimulation
|
|
240
|
+
|
|
241
|
+
if (!usePool) {
|
|
242
|
+
const out = []
|
|
243
|
+
for (const cand of candidates) {
|
|
244
|
+
const panel = cand.attackPanel?.length ? cand.attackPanel : [undefined]
|
|
245
|
+
out.push(await evaluateTunablesAgainstAttacks(scenarios, seeds, cand.tunables, panel, runSim, weights, options))
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @type {Array<{ candidateIndex: number, scenarioId: string, seed: number }>} */
|
|
251
|
+
const jobMeta = []
|
|
252
|
+
/** @type {import('./sim_pool.mjs').SimJob[]} */
|
|
253
|
+
const jobs = []
|
|
254
|
+
let jobId = 0
|
|
255
|
+
for (let ci = 0; ci < candidates.length; ci++) {
|
|
256
|
+
const cand = candidates[ci]
|
|
257
|
+
const panel = cand.attackPanel?.length ? cand.attackPanel : [undefined]
|
|
258
|
+
for (const scenario of scenarios)
|
|
259
|
+
for (const seed of seeds)
|
|
260
|
+
for (let ai = 0; ai < panel.length; ai++) {
|
|
261
|
+
jobMeta.push({ candidateIndex: ci, scenarioId: scenario.id, seed })
|
|
262
|
+
jobs.push({
|
|
263
|
+
id: jobId++,
|
|
264
|
+
scenarioId: scenario.id,
|
|
265
|
+
seed,
|
|
266
|
+
tunables: cand.tunables,
|
|
267
|
+
attackGenome: panel[ai] ?? undefined,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const poolResults = await runSimulationJobs(jobs, { concurrency })
|
|
273
|
+
for (const poolResult of poolResults)
|
|
274
|
+
if (poolResult.error)
|
|
275
|
+
throw new Error(`sim job ${poolResult.id} failed: ${poolResult.error}`)
|
|
276
|
+
|
|
277
|
+
/** @type {Map<number, Map<string, Map<number, { attackIndex: number, snap: SimSnapshot }[]>>>} */
|
|
278
|
+
const byCandidate = new Map()
|
|
279
|
+
for (let i = 0; i < jobMeta.length; i++) {
|
|
280
|
+
const meta = jobMeta[i]
|
|
281
|
+
const snap = poolResults[i].snapshot
|
|
282
|
+
if (!snap) throw new Error(`sim job ${i} missing snapshot`)
|
|
283
|
+
if (!byCandidate.has(meta.candidateIndex))
|
|
284
|
+
byCandidate.set(meta.candidateIndex, new Map())
|
|
285
|
+
const byScenario = byCandidate.get(meta.candidateIndex)
|
|
286
|
+
if (!byScenario.has(meta.scenarioId))
|
|
287
|
+
byScenario.set(meta.scenarioId, new Map())
|
|
288
|
+
const bySeed = byScenario.get(meta.scenarioId)
|
|
289
|
+
if (!bySeed.has(meta.seed))
|
|
290
|
+
bySeed.set(meta.seed, [])
|
|
291
|
+
bySeed.get(meta.seed).push({ attackIndex: i, snap })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return candidates.map((cand, ci) => {
|
|
295
|
+
const panel = cand.attackPanel?.length ? cand.attackPanel : [undefined]
|
|
296
|
+
return buildEvalFromGroupedSnaps(scenarios, seeds, panel, byCandidate.get(ci) ?? new Map(), weights)
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 蓝队对攻击者面板的评估:每个 (scenario, seed) 取面板内最差适应度。
|
|
302
|
+
* @param {import('./scenarios.mjs').SimScenario[]} scenarios 场景
|
|
303
|
+
* @param {number[]} seeds 种子
|
|
304
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
305
|
+
* @param {Array<import('./attack_space.mjs').AttackGenome | undefined>} attackPanel 攻击者面板
|
|
306
|
+
* @param {(scenario: import('./scenarios.mjs').SimScenario, seed: number, tunables: import('./tunables_bundle.mjs').TunablesBundle, attackGenome?: import('./attack_space.mjs').AttackGenome) => SimSnapshot} runSim 仿真
|
|
307
|
+
* @param {MetricWeights} [weights] 权重
|
|
308
|
+
* @param {EvalOptions} [options] 评估选项(concurrency / serial)
|
|
309
|
+
* @returns {Promise<{ fitness: number, mean: number, min: number, max: number, std: number, byScenario: Record<string, ReturnType<typeof aggregateSnapshots>> }>} 跨场景面板最差评估
|
|
310
|
+
*/
|
|
311
|
+
export async function evaluateTunablesAgainstAttacks(scenarios, seeds, tunables, attackPanel, runSim, weights = DEFAULT_WEIGHTS, options = {}) {
|
|
312
|
+
const panel = attackPanel?.length ? attackPanel : [undefined]
|
|
313
|
+
const concurrency = options.concurrency ?? defaultConcurrency()
|
|
314
|
+
const usePool = !options.serial && concurrency > 1 && runSim === runSimulation
|
|
315
|
+
|
|
316
|
+
/** @type {Record<string, ReturnType<typeof aggregateSnapshots>>} */
|
|
317
|
+
const byScenario = {}
|
|
318
|
+
let totalFitness = 0
|
|
319
|
+
let totalMean = 0
|
|
320
|
+
let worstMin = Infinity
|
|
321
|
+
let bestMax = -Infinity
|
|
322
|
+
let totalStd = 0
|
|
323
|
+
|
|
324
|
+
if (usePool) {
|
|
325
|
+
/** @type {Array<{ scenarioId: string, seed: number, attackIndex: number, attackGenome?: import('./attack_space.mjs').AttackGenome }>} */
|
|
326
|
+
const jobMeta = []
|
|
327
|
+
/** @type {import('./sim_pool.mjs').SimJob[]} */
|
|
328
|
+
const jobs = []
|
|
329
|
+
let jobId = 0
|
|
330
|
+
for (const scenario of scenarios)
|
|
331
|
+
for (const seed of seeds)
|
|
332
|
+
for (let ai = 0; ai < panel.length; ai++) {
|
|
333
|
+
const attackGenome = panel[ai] ?? undefined
|
|
334
|
+
jobMeta.push({ scenarioId: scenario.id, seed, attackIndex: ai, attackGenome })
|
|
335
|
+
jobs.push({
|
|
336
|
+
id: jobId++,
|
|
337
|
+
scenarioId: scenario.id,
|
|
338
|
+
seed,
|
|
339
|
+
tunables,
|
|
340
|
+
attackGenome,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const poolResults = await runSimulationJobs(jobs, { concurrency })
|
|
345
|
+
for (const poolResult of poolResults)
|
|
346
|
+
if (poolResult.error)
|
|
347
|
+
throw new Error(`sim job ${poolResult.id} failed: ${poolResult.error}`)
|
|
348
|
+
|
|
349
|
+
/** @type {Map<string, Map<number, { attackIndex: number, snap: SimSnapshot }[]>>} */
|
|
350
|
+
const snapsByScenarioSeed = new Map()
|
|
351
|
+
for (let i = 0; i < jobMeta.length; i++) {
|
|
352
|
+
const meta = jobMeta[i]
|
|
353
|
+
const snap = poolResults[i].snapshot
|
|
354
|
+
if (!snap) throw new Error(`sim job ${i} missing snapshot`)
|
|
355
|
+
if (!snapsByScenarioSeed.has(meta.scenarioId))
|
|
356
|
+
snapsByScenarioSeed.set(meta.scenarioId, new Map())
|
|
357
|
+
const bySeed = snapsByScenarioSeed.get(meta.scenarioId)
|
|
358
|
+
if (!bySeed.has(meta.seed))
|
|
359
|
+
bySeed.set(meta.seed, [])
|
|
360
|
+
bySeed.get(meta.seed).push({ attackIndex: meta.attackIndex, snap })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return buildEvalFromGroupedSnaps(scenarios, seeds, panel, snapsByScenarioSeed, weights)
|
|
364
|
+
}
|
|
365
|
+
else
|
|
366
|
+
for (const scenario of scenarios) {
|
|
367
|
+
/** @type {SimSnapshot[]} */
|
|
368
|
+
const snaps = []
|
|
369
|
+
for (const seed of seeds) {
|
|
370
|
+
let worstFit = Infinity
|
|
371
|
+
/** @type {SimSnapshot | null} */
|
|
372
|
+
let worstSnap = null
|
|
373
|
+
for (const attackGenome of panel) {
|
|
374
|
+
const snap = runSim(scenario, seed, tunables, attackGenome ?? undefined)
|
|
375
|
+
const f = fitnessFromSnapshot(snap, weights)
|
|
376
|
+
if (f < worstFit) {
|
|
377
|
+
worstFit = f
|
|
378
|
+
worstSnap = snap
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (worstSnap) snaps.push(worstSnap)
|
|
382
|
+
}
|
|
383
|
+
const agg = aggregateSnapshots(snaps, weights)
|
|
384
|
+
byScenario[scenario.id] = agg
|
|
385
|
+
totalFitness += agg.fitness
|
|
386
|
+
totalMean += agg.mean
|
|
387
|
+
worstMin = Math.min(worstMin, agg.min)
|
|
388
|
+
bestMax = Math.max(bestMax, agg.max)
|
|
389
|
+
totalStd += agg.std
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const n = Math.max(1, scenarios.length)
|
|
393
|
+
return {
|
|
394
|
+
fitness: totalFitness / n,
|
|
395
|
+
mean: totalMean / n,
|
|
396
|
+
min: worstMin,
|
|
397
|
+
max: bestMax,
|
|
398
|
+
std: totalStd / n,
|
|
399
|
+
byScenario,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* @param {Record<string, ReturnType<typeof aggregateSnapshots>>} byScenario 分场景结果
|
|
405
|
+
* @returns {number} 面板最差 min 适应度
|
|
406
|
+
*/
|
|
407
|
+
export function minPanelFitness(byScenario) {
|
|
408
|
+
const mins = Object.values(byScenario).map(a => a.min)
|
|
409
|
+
return mins.length ? Math.min(...mins) : 0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @param {SimSnapshot} baseline 基线快照
|
|
414
|
+
* @param {SimSnapshot} best 最优快照
|
|
415
|
+
* @returns {Array<{ key: string, baseline: number, best: number }>} 各维度对比行
|
|
416
|
+
*/
|
|
417
|
+
export function snapshotMetricRows(baseline, best) {
|
|
418
|
+
return RATE_METRIC_KEYS.map(key => ({
|
|
419
|
+
key,
|
|
420
|
+
baseline: baseline[key],
|
|
421
|
+
best: best[key],
|
|
422
|
+
}))
|
|
423
|
+
}
|