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.
Files changed (62) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +30 -21
  3. package/dist/bin/stdio.js +0 -4
  4. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
  5. package/dist/hoi4_agent_tools/core/engine.js +5 -0
  6. package/dist/hoi4_agent_tools/core/index.js +57 -1
  7. package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
  8. package/dist/hoi4_agent_tools/core/source/index.js +1 -0
  9. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  10. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  11. package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
  12. package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
  13. package/dist/hoi4_agent_tools/probability/compare.js +178 -0
  14. package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
  15. package/dist/hoi4_agent_tools/probability/index.js +13 -0
  16. package/dist/hoi4_agent_tools/probability/model.js +5 -0
  17. package/dist/hoi4_agent_tools/probability/rational.js +145 -0
  18. package/dist/hoi4_agent_tools/probability/render.js +307 -0
  19. package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
  20. package/dist/hoi4_agent_tools/probability/service.js +1168 -0
  21. package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
  22. package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
  23. package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
  24. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
  25. package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
  26. package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
  27. package/dist/hoi4_agent_tools/version.js +1 -1
  28. package/docs/README.md +1 -0
  29. package/docs/development.md +2 -0
  30. package/docs/probability.md +80 -0
  31. package/docs/research/probability-adapter-evidence.md +64 -0
  32. package/examples/clients/codex-config.toml +3 -0
  33. package/examples/clients/generic.json +8 -0
  34. package/examples/probability/adaptive-event-pool.json +92 -0
  35. package/examples/probability/adaptive-event-sequence-input.json +57 -0
  36. package/examples/probability/event-mtth-scenarios.json +57 -0
  37. package/examples/probability/event-mtth-simulate-input.json +37 -0
  38. package/examples/probability/focus-route-scenarios.json +61 -0
  39. package/examples/probability/focus-route-sweep-input.json +36 -0
  40. package/package.json +17 -8
  41. package/schemas/configuration.schema.json +747 -0
  42. package/schemas/continuous-focus-palette.schema.json +325 -0
  43. package/schemas/custom-weighted-pool.schema.json +298 -0
  44. package/schemas/event-feature-manifest.schema.json +63 -0
  45. package/schemas/focus-plan.schema.json +927 -0
  46. package/schemas/focus-planning-sidecar.schema.json +407 -0
  47. package/schemas/gui-animation-source.schema.json +176 -0
  48. package/schemas/gui-helper.schema.json +301 -0
  49. package/schemas/gui-scenario.schema.json +298 -0
  50. package/schemas/map-operation.schema.json +2284 -0
  51. package/schemas/operation-result.schema.json +327 -0
  52. package/schemas/probability-analysis-result.schema.json +2211 -0
  53. package/schemas/probability-compare-input.schema.json +664 -0
  54. package/schemas/probability-evaluate-input.schema.json +665 -0
  55. package/schemas/probability-inspect-input.schema.json +97 -0
  56. package/schemas/probability-render-input.schema.json +141 -0
  57. package/schemas/probability-scenario-set.schema.json +430 -0
  58. package/schemas/probability-sequence-input.schema.json +886 -0
  59. package/schemas/probability-simulate-input.schema.json +691 -0
  60. package/schemas/probability-sweep-input.schema.json +702 -0
  61. package/server.json +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -530
@@ -0,0 +1,858 @@
1
+ import { canonicalJson } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ import { DeterministicRandom } from './simulation.js';
4
+ const EXACT_STATE_LIMIT = 5_000;
5
+ const BEAM_STATE_LIMIT = 5_000;
6
+ function recordSequenceIssue(unresolved, issue) {
7
+ if (unresolved.some((existing) => existing.code === issue.code &&
8
+ existing.path === issue.path &&
9
+ existing.candidateId === issue.candidateId &&
10
+ existing.message === issue.message))
11
+ return;
12
+ unresolved.push(issue);
13
+ }
14
+ function pathValue(state, path, selected) {
15
+ if (path === 'true')
16
+ return true;
17
+ if (path === 'false')
18
+ return false;
19
+ if (path === 'selected.id')
20
+ return selected?.id;
21
+ if (path === 'selected.category')
22
+ return selected?.category;
23
+ if (path === 'selected.one_time')
24
+ return selected?.oneTime;
25
+ if (path.startsWith('selected.'))
26
+ return undefined;
27
+ if (path.startsWith('state.'))
28
+ return state.values[path.slice('state.'.length)];
29
+ if (path.startsWith('candidate.')) {
30
+ const parts = path.split('.');
31
+ const candidate = state.candidates.find(({ id }) => id === parts[1]);
32
+ if (candidate === undefined)
33
+ return undefined;
34
+ if (parts[2] === 'weight')
35
+ return candidate.weight;
36
+ if (parts[2] === 'cap')
37
+ return candidate.cap;
38
+ if (parts[2] === 'active')
39
+ return candidate.active;
40
+ }
41
+ if (path === 'selection.timer_min_days')
42
+ return state.timerMinDays;
43
+ if (path === 'selection.timer_max_days')
44
+ return state.timerMaxDays;
45
+ if (Object.hasOwn(state.values, path))
46
+ return state.values[path];
47
+ return undefined;
48
+ }
49
+ function tokenize(expression) {
50
+ const output = [];
51
+ let index = 0;
52
+ while (index < expression.length) {
53
+ if (/\s/u.test(expression[index])) {
54
+ index += 1;
55
+ continue;
56
+ }
57
+ const rest = expression.slice(index);
58
+ const number = /^-?\d+(?:\.\d+)?/u.exec(rest)?.[0];
59
+ if (number !== undefined) {
60
+ output.push({ kind: 'number', text: number });
61
+ index += number.length;
62
+ continue;
63
+ }
64
+ const identifier = /^[A-Za-z_][A-Za-z0-9_.]*/u.exec(rest)?.[0];
65
+ if (identifier !== undefined) {
66
+ output.push({ kind: 'identifier', text: identifier });
67
+ index += identifier.length;
68
+ continue;
69
+ }
70
+ const operator = /^(?:==|!=|<=|>=|[+*/<>-])/u.exec(rest)?.[0];
71
+ if (operator !== undefined) {
72
+ output.push({ kind: 'operator', text: operator });
73
+ index += operator.length;
74
+ continue;
75
+ }
76
+ if (rest.startsWith('(') || rest.startsWith(')')) {
77
+ output.push({
78
+ kind: rest.startsWith('(') ? 'left' : 'right',
79
+ text: rest.startsWith('(') ? '(' : ')',
80
+ });
81
+ index += 1;
82
+ continue;
83
+ }
84
+ return undefined;
85
+ }
86
+ return output;
87
+ }
88
+ class ArithmeticParser {
89
+ tokens;
90
+ state;
91
+ selected;
92
+ index = 0;
93
+ constructor(tokens, state, selected) {
94
+ this.tokens = tokens;
95
+ this.state = state;
96
+ this.selected = selected;
97
+ }
98
+ parse() {
99
+ const result = this.sum();
100
+ return this.index === this.tokens.length ? result : undefined;
101
+ }
102
+ sum() {
103
+ let left = this.product();
104
+ while (left !== undefined && ['+', '-'].includes(this.tokens[this.index]?.text ?? '')) {
105
+ const operation = this.tokens[this.index++].text;
106
+ const right = this.product();
107
+ if (right === undefined)
108
+ return undefined;
109
+ left = operation === '+' ? left + right : left - right;
110
+ }
111
+ return left;
112
+ }
113
+ product() {
114
+ let left = this.primary();
115
+ while (left !== undefined && ['*', '/'].includes(this.tokens[this.index]?.text ?? '')) {
116
+ const operation = this.tokens[this.index++].text;
117
+ const right = this.primary();
118
+ if (right === undefined || (operation === '/' && right === 0))
119
+ return undefined;
120
+ left = operation === '*' ? left * right : left / right;
121
+ }
122
+ return left;
123
+ }
124
+ primary() {
125
+ const token = this.tokens[this.index];
126
+ if (token === undefined)
127
+ return undefined;
128
+ if (token.kind === 'number') {
129
+ this.index += 1;
130
+ return Number(token.text);
131
+ }
132
+ if (token.kind === 'identifier' && ['floor', 'ceil', 'round'].includes(token.text)) {
133
+ this.index += 1;
134
+ if (this.tokens[this.index++]?.kind !== 'left')
135
+ return undefined;
136
+ const value = this.sum();
137
+ if (this.tokens[this.index++]?.kind !== 'right' || value === undefined)
138
+ return undefined;
139
+ return token.text === 'floor'
140
+ ? Math.floor(value)
141
+ : token.text === 'ceil'
142
+ ? Math.ceil(value)
143
+ : Math.round(value);
144
+ }
145
+ if (token.kind === 'identifier') {
146
+ this.index += 1;
147
+ const value = pathValue(this.state, token.text, this.selected);
148
+ return typeof value === 'number' ? value : undefined;
149
+ }
150
+ if (token.kind === 'left') {
151
+ this.index += 1;
152
+ const value = this.sum();
153
+ if (this.tokens[this.index++]?.kind !== 'right')
154
+ return undefined;
155
+ return value;
156
+ }
157
+ return undefined;
158
+ }
159
+ }
160
+ function numberExpression(value, state, selected) {
161
+ if (typeof value === 'number')
162
+ return value;
163
+ if (typeof value !== 'string')
164
+ return undefined;
165
+ const tokens = tokenize(value);
166
+ return tokens === undefined ? undefined : new ArithmeticParser(tokens, state, selected).parse();
167
+ }
168
+ function condition(expression, state, selected) {
169
+ const source = expression.trim();
170
+ if (source === 'true')
171
+ return true;
172
+ if (source === 'false')
173
+ return false;
174
+ if (source.startsWith('not ')) {
175
+ const value = condition(source.slice(4), state, selected);
176
+ return value === undefined ? undefined : !value;
177
+ }
178
+ const booleanParts = /^(.*?)\s+(and|or)\s+(.*)$/u.exec(source);
179
+ if (booleanParts !== null) {
180
+ const left = condition(booleanParts[1], state, selected);
181
+ const right = condition(booleanParts[3], state, selected);
182
+ if (left === undefined || right === undefined)
183
+ return undefined;
184
+ return booleanParts[2] === 'and' ? left && right : left || right;
185
+ }
186
+ if (/^selected\.[A-Za-z0-9_]+$/u.test(source)) {
187
+ const simple = pathValue(state, source, selected);
188
+ if (typeof simple === 'boolean')
189
+ return simple;
190
+ return state.selectedIds.includes(source.slice('selected.'.length));
191
+ }
192
+ const comparison = /^(.*?)\s*(==|!=|<=|>=|<|>)\s*(.*?)$/u.exec(source);
193
+ if (comparison !== null) {
194
+ const leftPath = comparison[1].trim();
195
+ const rightSource = comparison[3].trim();
196
+ const left = pathValue(state, leftPath, selected) ?? numberExpression(leftPath, state, selected);
197
+ const right = pathValue(state, rightSource, selected) ??
198
+ numberExpression(rightSource, state, selected) ??
199
+ rightSource;
200
+ if (left === undefined)
201
+ return undefined;
202
+ if (comparison[2] === '==')
203
+ return left === right;
204
+ if (comparison[2] === '!=')
205
+ return left !== right;
206
+ if (typeof left !== 'number' || typeof right !== 'number')
207
+ return undefined;
208
+ if (comparison[2] === '<')
209
+ return left < right;
210
+ if (comparison[2] === '>')
211
+ return left > right;
212
+ if (comparison[2] === '<=')
213
+ return left <= right;
214
+ return left >= right;
215
+ }
216
+ const value = pathValue(state, source, selected);
217
+ return typeof value === 'boolean' ? value : undefined;
218
+ }
219
+ function cloneState(state) {
220
+ return {
221
+ ...state,
222
+ values: { ...state.values },
223
+ candidates: state.candidates.map((candidate) => ({ ...candidate })),
224
+ selectedIds: [...state.selectedIds],
225
+ selectionDays: Object.fromEntries(Object.entries(state.selectionDays).map(([id, days]) => [id, [...days]])),
226
+ };
227
+ }
228
+ function targetCandidate(state, target, selected) {
229
+ if (target === 'selected.candidate')
230
+ return selected;
231
+ if (!target.startsWith('candidate.'))
232
+ return undefined;
233
+ return state.candidates.find(({ id }) => id === target.split('.')[1]);
234
+ }
235
+ function rounded(value, mode) {
236
+ if (mode === 'floor')
237
+ return Math.floor(value);
238
+ if (mode === 'ceil')
239
+ return Math.ceil(value);
240
+ if (mode === 'nearest')
241
+ return Math.round(value);
242
+ return value;
243
+ }
244
+ function applyAction(state, manifest, action, selected, unresolved) {
245
+ const value = numberExpression(action.value, state, selected);
246
+ if (action.operation === 'terminate') {
247
+ state.terminal = true;
248
+ return;
249
+ }
250
+ if (action.operation === 'remove') {
251
+ const candidate = targetCandidate(state, action.target, selected);
252
+ if (candidate !== undefined)
253
+ candidate.active = false;
254
+ else
255
+ recordSequenceIssue(unresolved, {
256
+ code: 'SEQUENCE_TARGET_UNRESOLVED',
257
+ message: `Cannot remove ${action.target}`,
258
+ path: action.target,
259
+ });
260
+ return;
261
+ }
262
+ if (action.operation === 'reset_category') {
263
+ const category = action.target.replace(/^category\./u, '');
264
+ for (const candidate of state.candidates.filter(({ category: id }) => id === category))
265
+ candidate.weight = rounded(value ?? 0, manifest.selection.rounding);
266
+ return;
267
+ }
268
+ if (action.operation === 'reset_timer') {
269
+ state.timerMinDays = manifest.selection.timerMinDays ?? 0;
270
+ state.timerMaxDays = manifest.selection.timerMaxDays ?? state.timerMinDays;
271
+ return;
272
+ }
273
+ if (action.operation === 'compress_timer') {
274
+ if (value === undefined) {
275
+ recordSequenceIssue(unresolved, {
276
+ code: 'SEQUENCE_VALUE_UNRESOLVED',
277
+ message: `Cannot resolve ${String(action.value)}`,
278
+ path: action.target,
279
+ });
280
+ return;
281
+ }
282
+ state.timerMaxDays = rounded(Math.max(state.timerMinDays, state.timerMaxDays - value), manifest.selection.rounding);
283
+ return;
284
+ }
285
+ const candidate = targetCandidate(state, action.target, selected);
286
+ const field = action.target.split('.').at(-1);
287
+ if (action.operation === 'cooldown') {
288
+ if (candidate !== undefined && value !== undefined)
289
+ candidate.cooldownUntil = state.day + value;
290
+ else
291
+ recordSequenceIssue(unresolved, {
292
+ code: 'SEQUENCE_VALUE_UNRESOLVED',
293
+ message: `Cannot apply cooldown ${action.target}`,
294
+ path: action.target,
295
+ });
296
+ return;
297
+ }
298
+ if (value === undefined) {
299
+ recordSequenceIssue(unresolved, {
300
+ code: 'SEQUENCE_VALUE_UNRESOLVED',
301
+ message: `Cannot resolve ${String(action.value)}`,
302
+ path: action.target,
303
+ });
304
+ return;
305
+ }
306
+ if (action.target.startsWith('state.')) {
307
+ const key = action.target.slice('state.'.length);
308
+ const current = typeof state.values[key] === 'number' ? state.values[key] : 0;
309
+ state.values[key] =
310
+ action.operation === 'set'
311
+ ? value
312
+ : action.operation === 'add'
313
+ ? current + value
314
+ : action.operation === 'multiply'
315
+ ? current * value
316
+ : Math.min(current, value);
317
+ return;
318
+ }
319
+ if (candidate !== undefined && (field === 'weight' || field === 'cap')) {
320
+ const current = field === 'weight' ? candidate.weight : (candidate.cap ?? Infinity);
321
+ const next = action.operation === 'set'
322
+ ? value
323
+ : action.operation === 'add'
324
+ ? current + value
325
+ : action.operation === 'multiply'
326
+ ? current * value
327
+ : Math.min(current, value);
328
+ if (field === 'weight')
329
+ candidate.weight = rounded(next, manifest.selection.rounding);
330
+ else
331
+ candidate.cap = rounded(next, manifest.selection.rounding);
332
+ return;
333
+ }
334
+ recordSequenceIssue(unresolved, {
335
+ code: 'SEQUENCE_TARGET_UNRESOLVED',
336
+ message: `Cannot update ${action.target}`,
337
+ path: action.target,
338
+ });
339
+ }
340
+ function cadenceDays(manifest, state) {
341
+ if (manifest.selection.cadence === 'daily')
342
+ return [1];
343
+ if (manifest.selection.cadence === 'weekly')
344
+ return [7];
345
+ if (manifest.selection.cadence === 'monthly')
346
+ return [30];
347
+ const min = Math.ceil(state.timerMinDays);
348
+ const max = Math.floor(state.timerMaxDays);
349
+ if (max < min)
350
+ return [Math.max(0, state.timerMinDays)];
351
+ return Array.from({ length: max - min + 1 }, (_, index) => min + index);
352
+ }
353
+ function applyRecovery(state, manifest, elapsed, unresolved) {
354
+ for (const recovery of manifest.recovery ?? []) {
355
+ const period = recovery.cadence === 'daily' ? 1 : recovery.cadence === 'weekly' ? 7 : 30;
356
+ const occurrences = Math.floor(elapsed / period);
357
+ if (occurrences <= 0)
358
+ continue;
359
+ const candidate = targetCandidate(state, recovery.target);
360
+ const amount = numberExpression(recovery.amount, state);
361
+ if (candidate === undefined || amount === undefined) {
362
+ recordSequenceIssue(unresolved, {
363
+ code: 'SEQUENCE_RECOVERY_UNRESOLVED',
364
+ message: `Cannot resolve recovery ${recovery.target}`,
365
+ path: recovery.target,
366
+ });
367
+ continue;
368
+ }
369
+ candidate.weight += amount * occurrences;
370
+ const cap = recovery.cap === undefined ? candidate.cap : numberExpression(recovery.cap, state);
371
+ if (cap !== undefined)
372
+ candidate.weight = Math.min(candidate.weight, cap);
373
+ candidate.weight = rounded(candidate.weight, manifest.selection.rounding);
374
+ }
375
+ }
376
+ function eligibleCandidates(state, manifest, unresolved) {
377
+ return state.candidates.filter((candidate) => {
378
+ if (!candidate.active || candidate.cooldownUntil > state.day)
379
+ return false;
380
+ if (candidate.weight <= 0)
381
+ return false;
382
+ const expression = manifest.candidates.find(({ id }) => id === candidate.id)?.eligibleWhen;
383
+ if (expression === undefined)
384
+ return true;
385
+ const result = condition(expression, state, candidate);
386
+ if (result === undefined)
387
+ recordSequenceIssue(unresolved, {
388
+ code: 'SEQUENCE_ELIGIBILITY_UNRESOLVED',
389
+ message: `Cannot resolve eligibility ${expression}`,
390
+ candidateId: candidate.id,
391
+ });
392
+ return result === true;
393
+ });
394
+ }
395
+ function selectionBranches(state, manifest, unresolved) {
396
+ const eligible = eligibleCandidates(state, manifest, unresolved);
397
+ if (manifest.selection.mode === 'categorical_weighted') {
398
+ const total = eligible.reduce((sum, candidate) => sum + candidate.weight, 0);
399
+ return total <= 0
400
+ ? [{ selected: [], probability: 1 }]
401
+ : eligible.map((selected) => ({
402
+ selected: [selected],
403
+ probability: selected.weight / total,
404
+ }));
405
+ }
406
+ if (eligible.length > 20) {
407
+ recordSequenceIssue(unresolved, {
408
+ code: 'SEQUENCE_INDEPENDENT_COMBINATION_LIMIT',
409
+ message: 'Independent custom pools above 20 eligible candidates require Monte Carlo sequence analysis',
410
+ });
411
+ return [{ selected: [], probability: 1 }];
412
+ }
413
+ const branches = [];
414
+ const combinations = 2 ** eligible.length;
415
+ for (let mask = 0; mask < combinations; mask += 1) {
416
+ const selected = [];
417
+ let probability = 1;
418
+ for (const [index, candidate] of eligible.entries()) {
419
+ const chance = Math.max(0, Math.min(1, candidate.weight / 100));
420
+ if ((mask & (2 ** index)) !== 0) {
421
+ selected.push(candidate);
422
+ probability *= chance;
423
+ }
424
+ else
425
+ probability *= 1 - chance;
426
+ }
427
+ if (probability > 0)
428
+ branches.push({ selected, probability });
429
+ }
430
+ return branches;
431
+ }
432
+ function advance(state, manifest, selectedCandidates, unresolved) {
433
+ const next = cloneState(state);
434
+ next.step += 1;
435
+ const actualSelections = selectedCandidates.flatMap((selected) => {
436
+ const candidate = next.candidates.find(({ id }) => id === selected.id);
437
+ return candidate === undefined ? [] : [candidate];
438
+ });
439
+ const transitionSubjects = actualSelections.length === 0 ? [undefined] : actualSelections;
440
+ for (const actualSelected of actualSelections) {
441
+ next.selectedIds.push(actualSelected.id);
442
+ (next.selectionDays[actualSelected.id] ??= []).push(next.day);
443
+ if (actualSelected.cooldownDays > 0)
444
+ actualSelected.cooldownUntil = next.day + actualSelected.cooldownDays;
445
+ }
446
+ for (const actualSelected of transitionSubjects) {
447
+ for (const transition of manifest.transitions) {
448
+ const applies = condition(transition.when, next, actualSelected);
449
+ if (applies === undefined) {
450
+ recordSequenceIssue(unresolved, {
451
+ code: 'SEQUENCE_TRANSITION_UNRESOLVED',
452
+ message: `Cannot resolve transition ${transition.when}`,
453
+ });
454
+ continue;
455
+ }
456
+ if (!applies)
457
+ continue;
458
+ for (const action of transition.actions)
459
+ applyAction(next, manifest, action, actualSelected, unresolved);
460
+ }
461
+ if (actualSelected?.oneTime === true)
462
+ actualSelected.active = false;
463
+ }
464
+ return next;
465
+ }
466
+ function initialState(manifest, scenario) {
467
+ const candidateIds = new Set();
468
+ const candidates = manifest.candidates.map((candidate) => {
469
+ if (candidateIds.has(candidate.id))
470
+ throw new ServiceError('SEQUENCE_CANDIDATE_DUPLICATE', 'Custom pool candidate IDs must be unique', { id: candidate.id });
471
+ candidateIds.add(candidate.id);
472
+ const weight = typeof candidate.weight === 'number' ? candidate.weight : Number(candidate.weight);
473
+ const cap = candidate.cap === undefined
474
+ ? undefined
475
+ : typeof candidate.cap === 'number'
476
+ ? candidate.cap
477
+ : Number(candidate.cap);
478
+ if (!Number.isFinite(weight) || (cap !== undefined && !Number.isFinite(cap)))
479
+ throw new ServiceError('SEQUENCE_INITIAL_VALUE_INVALID', 'Initial custom pool weights and caps must resolve numerically', { id: candidate.id });
480
+ return {
481
+ id: candidate.id,
482
+ ...(candidate.category === undefined ? {} : { category: candidate.category }),
483
+ weight: rounded(weight, manifest.selection.rounding),
484
+ ...(cap === undefined ? {} : { cap: rounded(cap, manifest.selection.rounding) }),
485
+ active: true,
486
+ oneTime: candidate.oneTime ?? false,
487
+ cooldownDays: candidate.cooldownDays ?? 0,
488
+ cooldownUntil: 0,
489
+ };
490
+ });
491
+ return {
492
+ day: 0,
493
+ step: 0,
494
+ values: { ...(manifest.state ?? {}), ...scenario.state },
495
+ candidates,
496
+ timerMinDays: manifest.selection.timerMinDays ?? 0,
497
+ timerMaxDays: manifest.selection.timerMaxDays ?? manifest.selection.timerMinDays ?? 0,
498
+ terminal: false,
499
+ selectedIds: [],
500
+ selectionDays: {},
501
+ };
502
+ }
503
+ function stateKey(state) {
504
+ return canonicalJson(state);
505
+ }
506
+ function addAggregate(aggregate, state, probability, manifest) {
507
+ if (state.terminal)
508
+ aggregate.terminal += probability;
509
+ for (const [index, candidate] of manifest.candidates.entries()) {
510
+ const selections = state.selectedIds.filter((id) => id === candidate.id).length;
511
+ aggregate.selections[index] += selections * probability;
512
+ if (selections > 0)
513
+ aggregate.ever[index] += probability;
514
+ }
515
+ }
516
+ function leadingCandidate(aggregate, manifest) {
517
+ let index = -1;
518
+ let maximum = 0;
519
+ for (const [candidateIndex, selections] of aggregate.selections.entries()) {
520
+ if (selections > maximum) {
521
+ maximum = selections;
522
+ index = candidateIndex;
523
+ }
524
+ }
525
+ return index < 0 ? undefined : manifest.candidates[index]?.id;
526
+ }
527
+ function exactSequence(manifest, scenario, horizonDays, maxSteps, signal, unresolved) {
528
+ let distribution = new Map();
529
+ const start = initialState(manifest, scenario);
530
+ distribution.set(stateKey(start), { state: start, probability: 1 });
531
+ const aggregate = {
532
+ selections: manifest.candidates.map(() => 0),
533
+ ever: manifest.candidates.map(() => 0),
534
+ terminal: 0,
535
+ timeline: [],
536
+ };
537
+ let method = 'exact_state_distribution';
538
+ let omittedProbability = 0;
539
+ let completedSteps = 0;
540
+ for (let step = 0; step < maxSteps; step += 1) {
541
+ signal?.throwIfAborted();
542
+ const next = new Map();
543
+ let active = false;
544
+ for (const { state, probability } of distribution.values()) {
545
+ if (state.terminal || state.day >= horizonDays) {
546
+ const key = stateKey(state);
547
+ const existing = next.get(key);
548
+ next.set(key, { state, probability: probability + (existing?.probability ?? 0) });
549
+ continue;
550
+ }
551
+ active = true;
552
+ const days = cadenceDays(manifest, state);
553
+ for (const elapsed of days) {
554
+ const elapsedProbability = probability / days.length;
555
+ const selectionState = cloneState(state);
556
+ const actualElapsed = Math.min(elapsed, horizonDays - state.day);
557
+ selectionState.day += actualElapsed;
558
+ applyRecovery(selectionState, manifest, actualElapsed, unresolved);
559
+ if (elapsed > horizonDays - state.day) {
560
+ const key = stateKey(selectionState);
561
+ const existing = next.get(key);
562
+ next.set(key, {
563
+ state: selectionState,
564
+ probability: elapsedProbability + (existing?.probability ?? 0),
565
+ });
566
+ continue;
567
+ }
568
+ const selections = selectionBranches(selectionState, manifest, unresolved);
569
+ for (const selection of selections) {
570
+ const advanced = advance(selectionState, manifest, selection.selected, unresolved);
571
+ const branchProbability = elapsedProbability * selection.probability;
572
+ const key = stateKey(advanced);
573
+ const existing = next.get(key);
574
+ next.set(key, {
575
+ state: advanced,
576
+ probability: branchProbability + (existing?.probability ?? 0),
577
+ });
578
+ }
579
+ }
580
+ }
581
+ distribution = next;
582
+ completedSteps = step + 1;
583
+ if (distribution.size > EXACT_STATE_LIMIT) {
584
+ method = 'bounded_beam';
585
+ const ordered = [...distribution.values()].sort((left, right) => right.probability - left.probability);
586
+ const retained = ordered.slice(0, BEAM_STATE_LIMIT);
587
+ omittedProbability += ordered
588
+ .slice(BEAM_STATE_LIMIT)
589
+ .reduce((sum, item) => sum + item.probability, 0);
590
+ distribution = new Map(retained.map((item) => [stateKey(item.state), item]));
591
+ }
592
+ const snapshot = {
593
+ selections: manifest.candidates.map(() => 0),
594
+ ever: manifest.candidates.map(() => 0),
595
+ terminal: 0,
596
+ timeline: [],
597
+ };
598
+ for (const item of distribution.values())
599
+ addAggregate(snapshot, item.state, item.probability, manifest);
600
+ const currentLeader = leadingCandidate(snapshot, manifest);
601
+ aggregate.timeline.push({
602
+ step: step + 1,
603
+ day: Math.max(...[...distribution.values()].map(({ state }) => state.day), 0),
604
+ ...(currentLeader === undefined ? {} : { leadingCandidate: currentLeader }),
605
+ terminalProbability: snapshot.terminal,
606
+ });
607
+ if (!active)
608
+ break;
609
+ }
610
+ for (const item of distribution.values())
611
+ addAggregate(aggregate, item.state, item.probability, manifest);
612
+ const pathProbabilities = new Map();
613
+ const countDistributions = manifest.candidates.map(() => new Map());
614
+ const categoryIds = [
615
+ ...new Set(manifest.candidates.flatMap(({ category }) => (category === undefined ? [] : [category]))),
616
+ ].sort();
617
+ const categoryNext = new Map(categoryIds.map((category) => [category, 0]));
618
+ const categoryEver = new Map(categoryIds.map((category) => [category, 0]));
619
+ const categoryFirstDayTotals = new Map(categoryIds.map((category) => [category, 0]));
620
+ const nextSelectionProbabilities = manifest.candidates.map(() => 0);
621
+ const firstDayTotals = manifest.candidates.map(() => 0);
622
+ for (const item of distribution.values()) {
623
+ const pathKey = canonicalJson(item.state.selectedIds);
624
+ const path = pathProbabilities.get(pathKey);
625
+ if (path === undefined)
626
+ pathProbabilities.set(pathKey, {
627
+ candidateIds: [...item.state.selectedIds],
628
+ probability: item.probability,
629
+ terminal: item.state.terminal,
630
+ endDay: item.state.day,
631
+ });
632
+ else {
633
+ path.probability += item.probability;
634
+ path.terminal = path.terminal || item.state.terminal;
635
+ path.endDay = Math.max(path.endDay, item.state.day);
636
+ }
637
+ for (const [index, candidate] of manifest.candidates.entries()) {
638
+ const count = item.state.selectedIds.filter((id) => id === candidate.id).length;
639
+ countDistributions[index].set(count, (countDistributions[index].get(count) ?? 0) + item.probability);
640
+ if (item.state.selectedIds[0] === candidate.id)
641
+ nextSelectionProbabilities[index] += item.probability;
642
+ const firstDay = item.state.selectionDays[candidate.id]?.[0];
643
+ if (firstDay !== undefined)
644
+ firstDayTotals[index] += firstDay * item.probability;
645
+ }
646
+ const firstSelected = manifest.candidates.find(({ id }) => id === item.state.selectedIds[0]);
647
+ if (firstSelected?.category !== undefined)
648
+ categoryNext.set(firstSelected.category, categoryNext.get(firstSelected.category) + item.probability);
649
+ for (const category of categoryIds) {
650
+ const categoryCandidates = manifest.candidates.filter((candidate) => candidate.category === category);
651
+ const firstDays = categoryCandidates.flatMap(({ id }) => {
652
+ const day = item.state.selectionDays[id]?.[0];
653
+ return day === undefined ? [] : [day];
654
+ });
655
+ if (firstDays.length === 0)
656
+ continue;
657
+ categoryEver.set(category, categoryEver.get(category) + item.probability);
658
+ categoryFirstDayTotals.set(category, categoryFirstDayTotals.get(category) + Math.min(...firstDays) * item.probability);
659
+ }
660
+ }
661
+ return {
662
+ scenarioId: scenario.id,
663
+ method,
664
+ steps: completedSteps,
665
+ terminalProbability: aggregate.terminal,
666
+ stateCount: distribution.size,
667
+ ...(omittedProbability > 0 ? { omittedProbability } : {}),
668
+ candidates: manifest.candidates.map((candidate, index) => ({
669
+ id: candidate.id,
670
+ expectedSelections: aggregate.selections[index],
671
+ everSelectedProbability: aggregate.ever[index],
672
+ nextSelectionProbability: nextSelectionProbabilities[index],
673
+ starvationProbability: Math.max(0, 1 - aggregate.ever[index]),
674
+ ...(aggregate.ever[index] <= 0
675
+ ? {}
676
+ : { expectedFirstSelectionDay: firstDayTotals[index] / aggregate.ever[index] }),
677
+ countDistribution: [...countDistributions[index].entries()]
678
+ .sort(([left], [right]) => left - right)
679
+ .map(([count, probability]) => ({ count, probability })),
680
+ })),
681
+ categories: categoryIds.map((category) => {
682
+ const candidateIndexes = manifest.candidates.flatMap((candidate, index) => candidate.category === category ? [index] : []);
683
+ const everSelectedProbability = categoryEver.get(category);
684
+ return {
685
+ id: category,
686
+ nextSelectionProbability: categoryNext.get(category),
687
+ expectedSelections: candidateIndexes.reduce((sum, index) => sum + aggregate.selections[index], 0),
688
+ everSelectedProbability,
689
+ starvationProbability: Math.max(0, 1 - everSelectedProbability),
690
+ ...(everSelectedProbability <= 0
691
+ ? {}
692
+ : {
693
+ expectedFirstSelectionDay: categoryFirstDayTotals.get(category) / everSelectedProbability,
694
+ }),
695
+ };
696
+ }),
697
+ topPaths: [...pathProbabilities.values()]
698
+ .sort((left, right) => right.probability - left.probability)
699
+ .slice(0, 20),
700
+ timeline: aggregate.timeline,
701
+ };
702
+ }
703
+ function monteCarloSequence(manifest, scenario, horizonDays, maxSteps, samples, seed, confidenceLevel, signal, unresolved) {
704
+ const random = new DeterministicRandom(seed);
705
+ const selections = manifest.candidates.map(() => 0);
706
+ const ever = manifest.candidates.map(() => 0);
707
+ const next = manifest.candidates.map(() => 0);
708
+ const firstDayTotals = manifest.candidates.map(() => 0);
709
+ const countDistributions = manifest.candidates.map(() => new Map());
710
+ const categoryIds = [
711
+ ...new Set(manifest.candidates.flatMap(({ category }) => (category === undefined ? [] : [category]))),
712
+ ].sort();
713
+ const categoryNext = new Map(categoryIds.map((category) => [category, 0]));
714
+ const categoryEver = new Map(categoryIds.map((category) => [category, 0]));
715
+ const categoryFirstDayTotals = new Map(categoryIds.map((category) => [category, 0]));
716
+ const pathCounts = new Map();
717
+ let terminal = 0;
718
+ let maximumSteps = 0;
719
+ for (let sample = 0; sample < samples; sample += 1) {
720
+ if ((sample & 1023) === 0)
721
+ signal?.throwIfAborted();
722
+ let state = initialState(manifest, scenario);
723
+ while (!state.terminal && state.day < horizonDays && state.step < maxSteps) {
724
+ const days = cadenceDays(manifest, state);
725
+ const elapsed = days[Math.min(days.length - 1, Math.floor(random.next() * days.length))];
726
+ const selectionState = cloneState(state);
727
+ const actualElapsed = Math.min(elapsed, horizonDays - state.day);
728
+ selectionState.day += actualElapsed;
729
+ applyRecovery(selectionState, manifest, actualElapsed, unresolved);
730
+ if (elapsed > horizonDays - state.day) {
731
+ state = selectionState;
732
+ break;
733
+ }
734
+ const eligible = eligibleCandidates(selectionState, manifest, unresolved);
735
+ let selected = [];
736
+ if (manifest.selection.mode === 'categorical_weighted') {
737
+ const total = eligible.reduce((sum, candidate) => sum + candidate.weight, 0);
738
+ if (total > 0) {
739
+ let draw = random.next() * total;
740
+ for (const candidate of eligible) {
741
+ draw -= candidate.weight;
742
+ if (draw <= 0) {
743
+ selected = [candidate];
744
+ break;
745
+ }
746
+ }
747
+ }
748
+ }
749
+ else {
750
+ selected = eligible.filter((candidate) => random.next() < Math.max(0, Math.min(1, candidate.weight / 100)));
751
+ }
752
+ state = advance(selectionState, manifest, selected, unresolved);
753
+ }
754
+ maximumSteps = Math.max(maximumSteps, state.step);
755
+ if (state.terminal)
756
+ terminal += 1;
757
+ const pathKey = canonicalJson(state.selectedIds);
758
+ const path = pathCounts.get(pathKey);
759
+ if (path === undefined)
760
+ pathCounts.set(pathKey, {
761
+ candidateIds: [...state.selectedIds],
762
+ count: 1,
763
+ terminal: state.terminal,
764
+ endDay: state.day,
765
+ });
766
+ else {
767
+ path.count += 1;
768
+ path.terminal = path.terminal || state.terminal;
769
+ path.endDay = Math.max(path.endDay, state.day);
770
+ }
771
+ for (const [index, candidate] of manifest.candidates.entries()) {
772
+ const count = state.selectedIds.filter((id) => id === candidate.id).length;
773
+ selections[index] += count;
774
+ if (count > 0)
775
+ ever[index] += 1;
776
+ countDistributions[index].set(count, (countDistributions[index].get(count) ?? 0) + 1);
777
+ if (state.selectedIds[0] === candidate.id)
778
+ next[index] += 1;
779
+ const firstDay = state.selectionDays[candidate.id]?.[0];
780
+ if (firstDay !== undefined)
781
+ firstDayTotals[index] += firstDay;
782
+ }
783
+ const firstSelected = manifest.candidates.find(({ id }) => id === state.selectedIds[0]);
784
+ if (firstSelected?.category !== undefined)
785
+ categoryNext.set(firstSelected.category, categoryNext.get(firstSelected.category) + 1);
786
+ for (const category of categoryIds) {
787
+ const firstDays = manifest.candidates.flatMap((candidate) => {
788
+ if (candidate.category !== category)
789
+ return [];
790
+ const day = state.selectionDays[candidate.id]?.[0];
791
+ return day === undefined ? [] : [day];
792
+ });
793
+ if (firstDays.length === 0)
794
+ continue;
795
+ categoryEver.set(category, categoryEver.get(category) + 1);
796
+ categoryFirstDayTotals.set(category, categoryFirstDayTotals.get(category) + Math.min(...firstDays));
797
+ }
798
+ }
799
+ return {
800
+ scenarioId: scenario.id,
801
+ method: 'seeded_monte_carlo',
802
+ steps: maximumSteps,
803
+ samples,
804
+ seed,
805
+ rng: 'mulberry32',
806
+ stoppingRule: 'fixed_sample_budget',
807
+ confidenceLevel,
808
+ terminalProbability: terminal / samples,
809
+ stateCount: samples,
810
+ candidates: manifest.candidates.map((candidate, index) => ({
811
+ id: candidate.id,
812
+ expectedSelections: selections[index] / samples,
813
+ everSelectedProbability: ever[index] / samples,
814
+ nextSelectionProbability: next[index] / samples,
815
+ starvationProbability: 1 - ever[index] / samples,
816
+ ...((ever[index] ?? 0) === 0
817
+ ? {}
818
+ : { expectedFirstSelectionDay: firstDayTotals[index] / (ever[index] ?? 1) }),
819
+ countDistribution: [...countDistributions[index].entries()]
820
+ .sort(([left], [right]) => left - right)
821
+ .map(([count, observations]) => ({ count, probability: observations / samples })),
822
+ })),
823
+ categories: categoryIds.map((category) => {
824
+ const candidateIndexes = manifest.candidates.flatMap((candidate, index) => candidate.category === category ? [index] : []);
825
+ const everObservations = categoryEver.get(category);
826
+ return {
827
+ id: category,
828
+ nextSelectionProbability: categoryNext.get(category) / samples,
829
+ expectedSelections: candidateIndexes.reduce((sum, index) => sum + selections[index], 0) / samples,
830
+ everSelectedProbability: everObservations / samples,
831
+ starvationProbability: 1 - everObservations / samples,
832
+ ...(everObservations === 0
833
+ ? {}
834
+ : {
835
+ expectedFirstSelectionDay: categoryFirstDayTotals.get(category) / everObservations,
836
+ }),
837
+ };
838
+ }),
839
+ topPaths: [...pathCounts.values()]
840
+ .map(({ count, ...path }) => ({ ...path, probability: count / samples }))
841
+ .sort((left, right) => right.probability - left.probability)
842
+ .slice(0, 20),
843
+ timeline: [],
844
+ };
845
+ }
846
+ export function analyzeSequence(manifest, scenario, horizonDays, maxSteps, samples, seed, confidenceLevel, signal) {
847
+ const unresolved = [];
848
+ const estimatedBranching = manifest.candidates.length *
849
+ Math.max(1, Math.ceil((manifest.selection.timerMaxDays ?? 1) - (manifest.selection.timerMinDays ?? 1) + 1));
850
+ const useMonteCarlo = estimatedBranching > 50 ||
851
+ maxSteps > 250 ||
852
+ manifest.candidates.length > 20 ||
853
+ (manifest.selection.mode === 'independent_chances' && manifest.candidates.length > 10);
854
+ const summary = useMonteCarlo
855
+ ? monteCarloSequence(manifest, scenario, horizonDays, maxSteps, samples, seed, confidenceLevel, signal, unresolved)
856
+ : exactSequence(manifest, scenario, horizonDays, maxSteps, signal, unresolved);
857
+ return { summary, unresolved };
858
+ }