hoi4-agent-tools 2.2.0 → 2.3.0
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/CHANGELOG.md +15 -0
- package/README.md +30 -21
- package/dist/bin/stdio.js +0 -4
- package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
- package/dist/hoi4_agent_tools/core/engine.js +5 -0
- package/dist/hoi4_agent_tools/core/index.js +57 -1
- package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
- package/dist/hoi4_agent_tools/core/source/index.js +1 -0
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
- package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
- package/dist/hoi4_agent_tools/probability/compare.js +178 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
- package/dist/hoi4_agent_tools/probability/index.js +13 -0
- package/dist/hoi4_agent_tools/probability/model.js +5 -0
- package/dist/hoi4_agent_tools/probability/rational.js +145 -0
- package/dist/hoi4_agent_tools/probability/render.js +307 -0
- package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
- package/dist/hoi4_agent_tools/probability/service.js +1168 -0
- package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
- package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
- package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
- package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +2 -0
- package/docs/probability.md +80 -0
- package/docs/research/probability-adapter-evidence.md +64 -0
- package/examples/clients/codex-config.toml +3 -0
- package/examples/clients/generic.json +8 -0
- package/examples/probability/adaptive-event-pool.json +92 -0
- package/examples/probability/adaptive-event-sequence-input.json +57 -0
- package/examples/probability/event-mtth-scenarios.json +57 -0
- package/examples/probability/event-mtth-simulate-input.json +37 -0
- package/examples/probability/focus-route-scenarios.json +61 -0
- package/examples/probability/focus-route-sweep-input.json +36 -0
- package/package.json +17 -8
- package/schemas/configuration.schema.json +747 -0
- package/schemas/continuous-focus-palette.schema.json +325 -0
- package/schemas/custom-weighted-pool.schema.json +298 -0
- package/schemas/event-feature-manifest.schema.json +63 -0
- package/schemas/focus-plan.schema.json +927 -0
- package/schemas/focus-planning-sidecar.schema.json +407 -0
- package/schemas/gui-animation-source.schema.json +176 -0
- package/schemas/gui-helper.schema.json +301 -0
- package/schemas/gui-scenario.schema.json +298 -0
- package/schemas/map-operation.schema.json +2284 -0
- package/schemas/operation-result.schema.json +327 -0
- package/schemas/probability-analysis-result.schema.json +2211 -0
- package/schemas/probability-compare-input.schema.json +664 -0
- package/schemas/probability-evaluate-input.schema.json +665 -0
- package/schemas/probability-inspect-input.schema.json +97 -0
- package/schemas/probability-render-input.schema.json +141 -0
- package/schemas/probability-scenario-set.schema.json +430 -0
- package/schemas/probability-sequence-input.schema.json +886 -0
- package/schemas/probability-simulate-input.schema.json +691 -0
- package/schemas/probability-sweep-input.schema.json +702 -0
- package/server.json +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -530
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { evaluateExactCandidates } from './evaluation.js';
|
|
2
|
+
function recordUnresolved(unresolved, issue) {
|
|
3
|
+
if (unresolved.some((existing) => existing.code === issue.code &&
|
|
4
|
+
existing.path === issue.path &&
|
|
5
|
+
existing.candidateId === issue.candidateId &&
|
|
6
|
+
existing.message === issue.message))
|
|
7
|
+
return;
|
|
8
|
+
unresolved.push(issue);
|
|
9
|
+
}
|
|
10
|
+
export class DeterministicRandom {
|
|
11
|
+
state;
|
|
12
|
+
constructor(seed) {
|
|
13
|
+
this.state = seed >>> 0;
|
|
14
|
+
}
|
|
15
|
+
next() {
|
|
16
|
+
this.state = (this.state + 0x6d2b79f5) >>> 0;
|
|
17
|
+
let value = this.state;
|
|
18
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
19
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
20
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
21
|
+
}
|
|
22
|
+
normal() {
|
|
23
|
+
const left = Math.max(Number.MIN_VALUE, this.next());
|
|
24
|
+
const right = this.next();
|
|
25
|
+
return Math.sqrt(-2 * Math.log(left)) * Math.cos(2 * Math.PI * right);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function normalCdf(value) {
|
|
29
|
+
const sign = value < 0 ? -1 : 1;
|
|
30
|
+
const x = Math.abs(value) / Math.sqrt(2);
|
|
31
|
+
const t = 1 / (1 + 0.3275911 * x);
|
|
32
|
+
const erf = 1 -
|
|
33
|
+
((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
|
|
34
|
+
t *
|
|
35
|
+
Math.exp(-x * x);
|
|
36
|
+
return 0.5 * (1 + sign * erf);
|
|
37
|
+
}
|
|
38
|
+
function inverseNormal(probability) {
|
|
39
|
+
const p = Math.min(1 - 1e-15, Math.max(1e-15, probability));
|
|
40
|
+
const a = [
|
|
41
|
+
-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716,
|
|
42
|
+
2.506628277459239,
|
|
43
|
+
];
|
|
44
|
+
const b = [
|
|
45
|
+
-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972,
|
|
46
|
+
-13.28068155288572,
|
|
47
|
+
];
|
|
48
|
+
const c = [
|
|
49
|
+
-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734,
|
|
50
|
+
4.374664141464968, 2.938163982698783,
|
|
51
|
+
];
|
|
52
|
+
const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
|
|
53
|
+
if (p < 0.02425) {
|
|
54
|
+
const q = Math.sqrt(-2 * Math.log(p));
|
|
55
|
+
return ((((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
56
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1));
|
|
57
|
+
}
|
|
58
|
+
if (p > 1 - 0.02425)
|
|
59
|
+
return -inverseNormal(1 - p);
|
|
60
|
+
const q = p - 0.5;
|
|
61
|
+
const r = q * q;
|
|
62
|
+
return (((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
|
|
63
|
+
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1));
|
|
64
|
+
}
|
|
65
|
+
function sampleDistribution(distribution, uniform, normal) {
|
|
66
|
+
switch (distribution.kind) {
|
|
67
|
+
case 'uniform': {
|
|
68
|
+
if (distribution.min === undefined || distribution.max === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
return distribution.min + uniform * (distribution.max - distribution.min);
|
|
71
|
+
}
|
|
72
|
+
case 'triangular': {
|
|
73
|
+
if (distribution.min === undefined ||
|
|
74
|
+
distribution.max === undefined ||
|
|
75
|
+
distribution.mode === undefined ||
|
|
76
|
+
distribution.max <= distribution.min)
|
|
77
|
+
return undefined;
|
|
78
|
+
const ratio = (distribution.mode - distribution.min) / (distribution.max - distribution.min);
|
|
79
|
+
return uniform < ratio
|
|
80
|
+
? distribution.min +
|
|
81
|
+
Math.sqrt(uniform *
|
|
82
|
+
(distribution.max - distribution.min) *
|
|
83
|
+
(distribution.mode - distribution.min))
|
|
84
|
+
: distribution.max -
|
|
85
|
+
Math.sqrt((1 - uniform) *
|
|
86
|
+
(distribution.max - distribution.min) *
|
|
87
|
+
(distribution.max - distribution.mode));
|
|
88
|
+
}
|
|
89
|
+
case 'normal':
|
|
90
|
+
return distribution.mean === undefined || distribution.stddev === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: distribution.mean + normal * distribution.stddev;
|
|
93
|
+
case 'lognormal':
|
|
94
|
+
return distribution.mean === undefined || distribution.stddev === undefined
|
|
95
|
+
? undefined
|
|
96
|
+
: Math.exp(distribution.mean + normal * distribution.stddev);
|
|
97
|
+
case 'categorical':
|
|
98
|
+
case 'empirical': {
|
|
99
|
+
if (distribution.values === undefined || distribution.values.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
const probabilities = distribution.probabilities;
|
|
102
|
+
if (probabilities === undefined) {
|
|
103
|
+
return distribution.values[Math.min(distribution.values.length - 1, Math.floor(uniform * distribution.values.length))];
|
|
104
|
+
}
|
|
105
|
+
let cumulative = 0;
|
|
106
|
+
for (const [index, probability] of probabilities.entries()) {
|
|
107
|
+
cumulative += probability;
|
|
108
|
+
if (uniform <= cumulative)
|
|
109
|
+
return distribution.values[index];
|
|
110
|
+
}
|
|
111
|
+
return distribution.values.at(-1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function cholesky(matrix) {
|
|
116
|
+
const size = matrix.length;
|
|
117
|
+
const result = Array.from({ length: size }, () => Array(size).fill(0));
|
|
118
|
+
for (let row = 0; row < size; row += 1) {
|
|
119
|
+
for (let column = 0; column <= row; column += 1) {
|
|
120
|
+
let sum = matrix[row][column];
|
|
121
|
+
for (let index = 0; index < column; index += 1)
|
|
122
|
+
sum -= result[row][index] * result[column][index];
|
|
123
|
+
if (row === column) {
|
|
124
|
+
if (sum < -1e-10)
|
|
125
|
+
return undefined;
|
|
126
|
+
result[row][column] = Math.sqrt(Math.max(0, sum));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const divisor = result[column][column];
|
|
130
|
+
if (divisor === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
result[row][column] = sum / divisor;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
function continuousInput(input) {
|
|
139
|
+
return (input.range !== undefined ||
|
|
140
|
+
(input.distribution !== undefined &&
|
|
141
|
+
input.distribution.kind !== 'categorical' &&
|
|
142
|
+
input.distribution.kind !== 'empirical'));
|
|
143
|
+
}
|
|
144
|
+
function greatestCommonDivisor(left, right) {
|
|
145
|
+
let a = Math.abs(Math.trunc(left));
|
|
146
|
+
let b = Math.abs(Math.trunc(right));
|
|
147
|
+
while (b !== 0)
|
|
148
|
+
[a, b] = [b, a % b];
|
|
149
|
+
return a;
|
|
150
|
+
}
|
|
151
|
+
function mixedUnit(seed, sample) {
|
|
152
|
+
let value = (seed + Math.imul(sample + 1, 0x9e3779b1)) >>> 0;
|
|
153
|
+
value ^= value >>> 16;
|
|
154
|
+
value = Math.imul(value, 0x85ebca6b);
|
|
155
|
+
value ^= value >>> 13;
|
|
156
|
+
value = Math.imul(value, 0xc2b2ae35);
|
|
157
|
+
value ^= value >>> 16;
|
|
158
|
+
return (value >>> 0) / 4_294_967_296;
|
|
159
|
+
}
|
|
160
|
+
function continuousSamples(scenario, samples, samplingMethod, random, unresolved) {
|
|
161
|
+
const inputs = (scenario.uncertainInputs ?? []).filter(continuousInput);
|
|
162
|
+
const paths = inputs.map(({ path }) => path);
|
|
163
|
+
const pathSet = new Set(paths);
|
|
164
|
+
const parameters = paths.map(() => {
|
|
165
|
+
let multiplier = Math.max(1, Math.floor(random.next() * samples));
|
|
166
|
+
while (greatestCommonDivisor(multiplier, samples) !== 1)
|
|
167
|
+
multiplier = multiplier === samples ? 1 : multiplier + 1;
|
|
168
|
+
return {
|
|
169
|
+
multiplier,
|
|
170
|
+
offset: Math.floor(random.next() * samples),
|
|
171
|
+
jitterSeed: Math.floor(random.next() * 4_294_967_296) >>> 0,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
const baseFor = (pathIndex, sample) => {
|
|
175
|
+
const parameter = parameters[pathIndex];
|
|
176
|
+
if (samplingMethod === 'pseudo_random')
|
|
177
|
+
return mixedUnit(parameter.jitterSeed, sample);
|
|
178
|
+
const stratum = (parameter.multiplier * sample + parameter.offset) % samples;
|
|
179
|
+
return (stratum + mixedUnit(parameter.jitterSeed, sample)) / samples;
|
|
180
|
+
};
|
|
181
|
+
const unsupportedCorrelations = (scenario.correlations ?? []).filter(({ left, right }) => !pathSet.has(left) || !pathSet.has(right));
|
|
182
|
+
for (const correlation of unsupportedCorrelations) {
|
|
183
|
+
recordUnresolved(unresolved, {
|
|
184
|
+
code: 'CATEGORICAL_CORRELATION_UNSUPPORTED',
|
|
185
|
+
message: `Correlation ${correlation.left} <-> ${correlation.right} includes a discrete or unresolved input and is not modeled`,
|
|
186
|
+
details: correlation,
|
|
187
|
+
});
|
|
188
|
+
recordUnresolved(unresolved, {
|
|
189
|
+
code: 'CORRELATED_INPUTS_SAMPLED_INDEPENDENTLY',
|
|
190
|
+
message: `Correlation ${correlation.left} <-> ${correlation.right} is declared but its unsupported endpoint is sampled independently`,
|
|
191
|
+
details: correlation,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const numericCorrelations = (scenario.correlations ?? []).filter(({ left, right }) => pathSet.has(left) && pathSet.has(right));
|
|
195
|
+
if (numericCorrelations.length === 0)
|
|
196
|
+
return (sample) => new Map(paths.map((path, index) => [path, baseFor(index, sample)]));
|
|
197
|
+
const matrix = paths.map((left, row) => paths.map((right, column) => {
|
|
198
|
+
if (row === column)
|
|
199
|
+
return 1;
|
|
200
|
+
return (numericCorrelations.find((correlation) => (correlation.left === left && correlation.right === right) ||
|
|
201
|
+
(correlation.left === right && correlation.right === left))?.coefficient ?? 0);
|
|
202
|
+
}));
|
|
203
|
+
const decomposition = cholesky(matrix);
|
|
204
|
+
if (decomposition === undefined) {
|
|
205
|
+
recordUnresolved(unresolved, {
|
|
206
|
+
code: 'CORRELATION_MATRIX_INVALID',
|
|
207
|
+
message: `Scenario ${scenario.id} correlation matrix is not positive semidefinite`,
|
|
208
|
+
});
|
|
209
|
+
recordUnresolved(unresolved, {
|
|
210
|
+
code: 'CORRELATED_INPUTS_SAMPLED_INDEPENDENTLY',
|
|
211
|
+
message: `Scenario ${scenario.id} declares an invalid correlation matrix; numeric inputs are sampled independently`,
|
|
212
|
+
});
|
|
213
|
+
return (sample) => new Map(paths.map((path, index) => [path, baseFor(index, sample)]));
|
|
214
|
+
}
|
|
215
|
+
return (sample) => {
|
|
216
|
+
const independent = paths.map((_, index) => inverseNormal(baseFor(index, sample)));
|
|
217
|
+
return new Map(paths.map((path, row) => [
|
|
218
|
+
path,
|
|
219
|
+
normalCdf(decomposition[row].reduce((sum, coefficient, index) => sum + coefficient * independent[index], 0)),
|
|
220
|
+
]));
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function sampledScenario(scenario, continuous, random, unresolved) {
|
|
224
|
+
const inputs = scenario.uncertainInputs ?? [];
|
|
225
|
+
const state = { ...scenario.state };
|
|
226
|
+
for (const input of inputs) {
|
|
227
|
+
let value;
|
|
228
|
+
const plannedUniform = continuous.get(input.path);
|
|
229
|
+
if (input.range !== undefined && plannedUniform !== undefined)
|
|
230
|
+
value = input.range.min + plannedUniform * (input.range.max - input.range.min);
|
|
231
|
+
else if (input.alternatives !== undefined)
|
|
232
|
+
value =
|
|
233
|
+
input.alternatives[Math.min(input.alternatives.length - 1, Math.floor(random.next() * input.alternatives.length))];
|
|
234
|
+
else if (input.distribution !== undefined) {
|
|
235
|
+
const uniform = plannedUniform ?? random.next();
|
|
236
|
+
const normal = inverseNormal(uniform);
|
|
237
|
+
value = sampleDistribution(input.distribution, uniform, normal);
|
|
238
|
+
}
|
|
239
|
+
if (value === undefined) {
|
|
240
|
+
recordUnresolved(unresolved, {
|
|
241
|
+
code: 'DISTRIBUTION_PARAMETERS_INVALID',
|
|
242
|
+
message: `Distribution parameters for ${input.path} are incomplete or invalid`,
|
|
243
|
+
path: input.path,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
else
|
|
247
|
+
state[input.path] = value;
|
|
248
|
+
}
|
|
249
|
+
return { ...scenario, state, uncertainInputs: [] };
|
|
250
|
+
}
|
|
251
|
+
function quantileForConfidence(confidence) {
|
|
252
|
+
return inverseNormal(0.5 + confidence / 2);
|
|
253
|
+
}
|
|
254
|
+
export function wilsonInterval(successes, samples, confidence) {
|
|
255
|
+
if (samples <= 0)
|
|
256
|
+
return { low: 0, high: 1 };
|
|
257
|
+
const z = quantileForConfidence(confidence);
|
|
258
|
+
const probability = successes / samples;
|
|
259
|
+
const denominator = 1 + (z * z) / samples;
|
|
260
|
+
const centre = (probability + (z * z) / (2 * samples)) / denominator;
|
|
261
|
+
const radius = (z / denominator) *
|
|
262
|
+
Math.sqrt((probability * (1 - probability)) / samples + (z * z) / (4 * samples * samples));
|
|
263
|
+
return { low: Math.max(0, centre - radius), high: Math.min(1, centre + radius) };
|
|
264
|
+
}
|
|
265
|
+
function quantile(sorted, probability) {
|
|
266
|
+
if (sorted.length === 0)
|
|
267
|
+
return Number.NaN;
|
|
268
|
+
const position = probability * (sorted.length - 1);
|
|
269
|
+
const lower = Math.floor(position);
|
|
270
|
+
const upper = Math.ceil(position);
|
|
271
|
+
if (lower === upper)
|
|
272
|
+
return sorted[lower];
|
|
273
|
+
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
|
|
274
|
+
}
|
|
275
|
+
function orderStatisticInterval(sorted, probability, confidence) {
|
|
276
|
+
if (sorted.length === 0)
|
|
277
|
+
return { low: Number.NaN, high: Number.NaN };
|
|
278
|
+
const z = quantileForConfidence(confidence);
|
|
279
|
+
const centre = probability * (sorted.length - 1);
|
|
280
|
+
const deviation = z * Math.sqrt(sorted.length * probability * (1 - probability));
|
|
281
|
+
const lowIndex = Math.max(0, Math.floor(centre - deviation));
|
|
282
|
+
const highIndex = Math.min(sorted.length - 1, Math.ceil(centre + deviation));
|
|
283
|
+
return { low: sorted[lowIndex], high: sorted[highIndex] };
|
|
284
|
+
}
|
|
285
|
+
function sampledMtthWaitDays(mtthDays, uniform) {
|
|
286
|
+
if (!Number.isFinite(mtthDays) || mtthDays <= 0)
|
|
287
|
+
return undefined;
|
|
288
|
+
const logDailySurvival = -Math.LN2 / mtthDays;
|
|
289
|
+
return Math.floor(Math.log1p(-Math.min(1 - Number.EPSILON, uniform)) / logDailySurvival) + 1;
|
|
290
|
+
}
|
|
291
|
+
function importanceScore(moment) {
|
|
292
|
+
const numerator = moment.observations * moment.sumXY - moment.sumX * moment.sumY;
|
|
293
|
+
const left = moment.observations * moment.sumXX - moment.sumX * moment.sumX;
|
|
294
|
+
const right = moment.observations * moment.sumYY - moment.sumY * moment.sumY;
|
|
295
|
+
const denominator = Math.sqrt(Math.max(0, left * right));
|
|
296
|
+
if (moment.observations < 2 || denominator === 0)
|
|
297
|
+
return undefined;
|
|
298
|
+
return Math.min(1, Math.abs(numerator / denominator));
|
|
299
|
+
}
|
|
300
|
+
function chooseWinner(surface, candidates, random) {
|
|
301
|
+
if (surface.adapter.selectionRule === 'independent_chance')
|
|
302
|
+
return candidates.flatMap(({ probability }, index) => probability !== undefined && random.next() < probability.toNumber() ? [index] : []);
|
|
303
|
+
if (surface.adapter.selectionRule === 'proportional_categorical') {
|
|
304
|
+
const draw = surface.adapter.id === 'event_option_ai_chance'
|
|
305
|
+
? Math.floor(random.next() * 100) / 100
|
|
306
|
+
: random.next();
|
|
307
|
+
let cumulative = 0;
|
|
308
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
309
|
+
cumulative +=
|
|
310
|
+
(surface.adapter.id === 'random_list'
|
|
311
|
+
? candidate.pathProbability
|
|
312
|
+
: candidate.probability)?.toNumber() ?? 0;
|
|
313
|
+
if (draw < cumulative)
|
|
314
|
+
return [index];
|
|
315
|
+
}
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
if (surface.adapter.selectionRule === 'uniform_score_race') {
|
|
319
|
+
let winner = -1;
|
|
320
|
+
let maximum = -Infinity;
|
|
321
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
322
|
+
const value = candidate.value?.toNumber() ?? 0;
|
|
323
|
+
const draw = random.next() * Math.max(0, value);
|
|
324
|
+
if (value > 0 && draw > maximum) {
|
|
325
|
+
maximum = draw;
|
|
326
|
+
winner = index;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return winner < 0 ? [] : [winner];
|
|
330
|
+
}
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
function candidateSelectionSupported(surface, candidates) {
|
|
334
|
+
if (surface.adapter.selectionRule === 'score_only' ||
|
|
335
|
+
surface.adapter.selectionRule === 'median_daily_hazard')
|
|
336
|
+
return candidates.map(() => false);
|
|
337
|
+
if (surface.adapter.selectionRule === 'independent_chance')
|
|
338
|
+
return candidates.map((candidate) => candidate.eligibility !== 'unresolved' &&
|
|
339
|
+
candidate.probability !== undefined &&
|
|
340
|
+
candidate.unresolved.length === 0);
|
|
341
|
+
const complete = surface.poolComplete &&
|
|
342
|
+
candidates.every((candidate) => candidate.eligibility !== 'unresolved' &&
|
|
343
|
+
candidate.probability !== undefined &&
|
|
344
|
+
(surface.adapter.id !== 'random_list' || candidate.pathProbability !== undefined) &&
|
|
345
|
+
candidate.unresolved.length === 0);
|
|
346
|
+
return candidates.map(() => complete);
|
|
347
|
+
}
|
|
348
|
+
export function simulateSurface(surface, scenarios, definitions, samples, seed, confidenceLevel, samplingMethod, rareOutcomeMinimumObservations, signal) {
|
|
349
|
+
const summaries = [];
|
|
350
|
+
const unresolved = [];
|
|
351
|
+
for (const [scenarioIndex, scenario] of scenarios.entries()) {
|
|
352
|
+
const scenarioSeed = seed + scenarioIndex * 1_000_003;
|
|
353
|
+
const random = new DeterministicRandom(scenarioSeed);
|
|
354
|
+
const timingRandom = new DeterministicRandom((scenarioSeed ^ 0x9e3779b9) >>> 0);
|
|
355
|
+
const reservoirRandom = new DeterministicRandom((scenarioSeed ^ 0x85ebca6b) >>> 0);
|
|
356
|
+
const plannedContinuous = continuousSamples(scenario, samples, samplingMethod, random, unresolved);
|
|
357
|
+
const selections = surface.candidates.map(() => 0);
|
|
358
|
+
const firstHalfSelections = surface.candidates.map(() => 0);
|
|
359
|
+
const eligible = surface.candidates.map(() => 0);
|
|
360
|
+
const rawTotals = surface.candidates.map(() => 0);
|
|
361
|
+
const rawCounts = surface.candidates.map(() => 0);
|
|
362
|
+
const selectionSupported = surface.candidates.map(() => true);
|
|
363
|
+
const timingReservoirCapacity = Math.max(1, Math.floor(100_000 / Math.max(1, surface.candidates.length)));
|
|
364
|
+
const timingReservoirs = surface.candidates.map(() => []);
|
|
365
|
+
const timingEvaluations = surface.candidates.map(() => 0);
|
|
366
|
+
const uncertainPaths = (scenario.uncertainInputs ?? []).map(({ path }) => path).sort();
|
|
367
|
+
const importance = new Map();
|
|
368
|
+
for (let sample = 0; sample < samples; sample += 1) {
|
|
369
|
+
if ((sample & 4095) === 0)
|
|
370
|
+
signal?.throwIfAborted();
|
|
371
|
+
const sampled = sampledScenario(scenario, plannedContinuous(sample), random, unresolved);
|
|
372
|
+
const candidates = evaluateExactCandidates(surface, sampled, definitions);
|
|
373
|
+
for (const candidate of candidates)
|
|
374
|
+
for (const issue of candidate.unresolved)
|
|
375
|
+
recordUnresolved(unresolved, issue);
|
|
376
|
+
const supported = candidateSelectionSupported(surface, candidates);
|
|
377
|
+
for (const [index, value] of supported.entries())
|
|
378
|
+
selectionSupported[index] = selectionSupported[index] && value;
|
|
379
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
380
|
+
if (candidate.eligibility === 'true')
|
|
381
|
+
eligible[index] += 1;
|
|
382
|
+
if (candidate.value !== undefined) {
|
|
383
|
+
rawTotals[index] += candidate.value.toNumber();
|
|
384
|
+
rawCounts[index] += 1;
|
|
385
|
+
}
|
|
386
|
+
if (surface.adapter.selectionRule === 'median_daily_hazard' &&
|
|
387
|
+
candidate.eligibility === 'true' &&
|
|
388
|
+
candidate.value !== undefined &&
|
|
389
|
+
candidate.unresolved.length === 0) {
|
|
390
|
+
const wait = sampledMtthWaitDays(candidate.value.toNumber(), timingRandom.next());
|
|
391
|
+
if (wait !== undefined) {
|
|
392
|
+
timingEvaluations[index] += 1;
|
|
393
|
+
const reservoir = timingReservoirs[index];
|
|
394
|
+
if (reservoir.length < timingReservoirCapacity)
|
|
395
|
+
reservoir.push(wait);
|
|
396
|
+
else {
|
|
397
|
+
const replacement = Math.floor(reservoirRandom.next() * timingEvaluations[index]);
|
|
398
|
+
if (replacement < timingReservoirCapacity)
|
|
399
|
+
reservoir[replacement] = wait;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const inputPath of uncertainPaths) {
|
|
405
|
+
const input = sampled.state[inputPath];
|
|
406
|
+
if (typeof input !== 'number' || !Number.isFinite(input))
|
|
407
|
+
continue;
|
|
408
|
+
for (const candidate of candidates) {
|
|
409
|
+
const output = candidate.value?.toNumber();
|
|
410
|
+
if (output === undefined || !Number.isFinite(output))
|
|
411
|
+
continue;
|
|
412
|
+
const key = `${inputPath}\0${candidate.id}`;
|
|
413
|
+
const moment = importance.get(key) ?? {
|
|
414
|
+
observations: 0,
|
|
415
|
+
sumX: 0,
|
|
416
|
+
sumY: 0,
|
|
417
|
+
sumXX: 0,
|
|
418
|
+
sumYY: 0,
|
|
419
|
+
sumXY: 0,
|
|
420
|
+
};
|
|
421
|
+
moment.observations += 1;
|
|
422
|
+
moment.sumX += input;
|
|
423
|
+
moment.sumY += output;
|
|
424
|
+
moment.sumXX += input * input;
|
|
425
|
+
moment.sumYY += output * output;
|
|
426
|
+
moment.sumXY += input * output;
|
|
427
|
+
importance.set(key, moment);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const selected = [];
|
|
431
|
+
if (supported.every(Boolean))
|
|
432
|
+
selected.push(...chooseWinner(surface, candidates, random));
|
|
433
|
+
else if (surface.adapter.selectionRule === 'independent_chance')
|
|
434
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
435
|
+
if (!supported[index])
|
|
436
|
+
continue;
|
|
437
|
+
const probability = candidate.probability?.toNumber();
|
|
438
|
+
if (probability !== undefined && random.next() < probability)
|
|
439
|
+
selected.push(index);
|
|
440
|
+
}
|
|
441
|
+
for (const winner of selected) {
|
|
442
|
+
selections[winner] += 1;
|
|
443
|
+
if (sample < Math.floor(samples / 2))
|
|
444
|
+
firstHalfSelections[winner] += 1;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const firstHalfSamples = Math.floor(samples / 2);
|
|
448
|
+
const secondHalfSamples = samples - firstHalfSamples;
|
|
449
|
+
const maximumFrequencyDelta = Math.max(...surface.candidates.map((_, index) => {
|
|
450
|
+
if (!selectionSupported[index] || firstHalfSamples === 0 || secondHalfSamples === 0)
|
|
451
|
+
return 0;
|
|
452
|
+
const first = firstHalfSelections[index] / firstHalfSamples;
|
|
453
|
+
const second = (selections[index] - firstHalfSelections[index]) / secondHalfSamples;
|
|
454
|
+
return Math.abs(first - second);
|
|
455
|
+
}), 0);
|
|
456
|
+
for (const [index, count] of selections.entries()) {
|
|
457
|
+
if (selectionSupported[index] && count > 0 && count < rareOutcomeMinimumObservations)
|
|
458
|
+
recordUnresolved(unresolved, {
|
|
459
|
+
code: 'MONTE_CARLO_RARE_OUTCOME_UNRESOLVED',
|
|
460
|
+
message: `${surface.candidates[index].id} was observed only ${count} time(s); the sampled frequency is not stable at the requested precision`,
|
|
461
|
+
candidateId: surface.candidates[index].id,
|
|
462
|
+
details: {
|
|
463
|
+
observedSelections: count,
|
|
464
|
+
samples,
|
|
465
|
+
minimumStableObservations: rareOutcomeMinimumObservations,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
summaries.push({
|
|
470
|
+
scenarioId: scenario.id,
|
|
471
|
+
samples,
|
|
472
|
+
seed: scenarioSeed,
|
|
473
|
+
samplingMethod,
|
|
474
|
+
rng: 'mulberry32',
|
|
475
|
+
stoppingRule: 'fixed_sample_budget',
|
|
476
|
+
confidenceLevel,
|
|
477
|
+
confidenceMethod: 'wilson_score',
|
|
478
|
+
effectiveSampleSize: samples,
|
|
479
|
+
globalImportance: [...importance.entries()]
|
|
480
|
+
.flatMap(([key, moment]) => {
|
|
481
|
+
const score = importanceScore(moment);
|
|
482
|
+
if (score === undefined)
|
|
483
|
+
return [];
|
|
484
|
+
const [path, candidateId] = key.split('\0');
|
|
485
|
+
return [
|
|
486
|
+
{
|
|
487
|
+
path,
|
|
488
|
+
candidateId,
|
|
489
|
+
metric: 'raw_value',
|
|
490
|
+
score,
|
|
491
|
+
method: 'absolute_pearson_correlation',
|
|
492
|
+
observations: moment.observations,
|
|
493
|
+
},
|
|
494
|
+
];
|
|
495
|
+
})
|
|
496
|
+
.sort((left, right) => right.score - left.score ||
|
|
497
|
+
left.path.localeCompare(right.path) ||
|
|
498
|
+
left.candidateId.localeCompare(right.candidateId)),
|
|
499
|
+
convergence: { firstHalfSamples, secondHalfSamples, maximumFrequencyDelta },
|
|
500
|
+
candidates: surface.candidates.map((candidate, index) => {
|
|
501
|
+
const sortedTiming = timingReservoirs[index].slice().sort((left, right) => left - right);
|
|
502
|
+
const timingQuantilesDays = sortedTiming.length === 0
|
|
503
|
+
? undefined
|
|
504
|
+
: {
|
|
505
|
+
p10: quantile(sortedTiming, 0.1),
|
|
506
|
+
p50: quantile(sortedTiming, 0.5),
|
|
507
|
+
p90: quantile(sortedTiming, 0.9),
|
|
508
|
+
p95: quantile(sortedTiming, 0.95),
|
|
509
|
+
};
|
|
510
|
+
const timingQuantileIntervals = sortedTiming.length === 0
|
|
511
|
+
? undefined
|
|
512
|
+
: {
|
|
513
|
+
p10: orderStatisticInterval(sortedTiming, 0.1, confidenceLevel),
|
|
514
|
+
p50: orderStatisticInterval(sortedTiming, 0.5, confidenceLevel),
|
|
515
|
+
p90: orderStatisticInterval(sortedTiming, 0.9, confidenceLevel),
|
|
516
|
+
p95: orderStatisticInterval(sortedTiming, 0.95, confidenceLevel),
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
id: candidate.id,
|
|
520
|
+
...(selectionSupported[index]
|
|
521
|
+
? {
|
|
522
|
+
frequency: selections[index] / samples,
|
|
523
|
+
observedSelections: selections[index],
|
|
524
|
+
confidenceInterval: wilsonInterval(selections[index], samples, confidenceLevel),
|
|
525
|
+
}
|
|
526
|
+
: {}),
|
|
527
|
+
...(rawCounts[index] > 0 ? { rawMean: rawTotals[index] / rawCounts[index] } : {}),
|
|
528
|
+
eligibilityFrequency: eligible[index] / samples,
|
|
529
|
+
...(timingQuantilesDays === undefined ? {} : { timingQuantilesDays }),
|
|
530
|
+
...(timingQuantileIntervals === undefined ? {} : { timingQuantileIntervals }),
|
|
531
|
+
...(sortedTiming.length === 0
|
|
532
|
+
? {}
|
|
533
|
+
: {
|
|
534
|
+
timingSampleCount: sortedTiming.length,
|
|
535
|
+
timingEvaluations: timingEvaluations[index],
|
|
536
|
+
timingReservoirCapacity,
|
|
537
|
+
timingMethod: 'sampled_discrete_daily_hazard',
|
|
538
|
+
timingConfidenceMethod: 'normal_order_statistic',
|
|
539
|
+
}),
|
|
540
|
+
};
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
return { summaries, unresolved };
|
|
545
|
+
}
|