hoi4-agent-tools 2.2.0 → 2.3.3

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 (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +30 -21
  3. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +5 -0
  5. package/dist/hoi4_agent_tools/core/index.js +57 -1
  6. package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
  7. package/dist/hoi4_agent_tools/core/source/index.js +1 -0
  8. package/dist/hoi4_agent_tools/event/algorithms.js +178 -1
  9. package/dist/hoi4_agent_tools/event/graph.js +24 -9
  10. package/dist/hoi4_agent_tools/event/limits.js +5 -2
  11. package/dist/hoi4_agent_tools/event/service.js +182 -45
  12. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  13. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +13 -63
  14. package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
  15. package/dist/hoi4_agent_tools/mcp/tools/technology.js +4 -1
  16. package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
  17. package/dist/hoi4_agent_tools/probability/compare.js +178 -0
  18. package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
  19. package/dist/hoi4_agent_tools/probability/index.js +13 -0
  20. package/dist/hoi4_agent_tools/probability/model.js +5 -0
  21. package/dist/hoi4_agent_tools/probability/rational.js +145 -0
  22. package/dist/hoi4_agent_tools/probability/render.js +307 -0
  23. package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
  24. package/dist/hoi4_agent_tools/probability/service.js +1168 -0
  25. package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
  26. package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
  27. package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
  28. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
  29. package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
  30. package/dist/hoi4_agent_tools/runtime.js +109 -7
  31. package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
  32. package/dist/hoi4_agent_tools/technology/graph.js +30 -3
  33. package/dist/hoi4_agent_tools/technology/queries.js +70 -3
  34. package/dist/hoi4_agent_tools/technology/service.js +38 -2
  35. package/dist/hoi4_agent_tools/version.js +1 -1
  36. package/docs/README.md +1 -0
  37. package/docs/development.md +2 -0
  38. package/docs/events.md +2 -2
  39. package/docs/probability.md +80 -0
  40. package/docs/research/probability-adapter-evidence.md +64 -0
  41. package/docs/setup.md +2 -0
  42. package/docs/technology.md +4 -2
  43. package/examples/clients/codex-config.toml +3 -0
  44. package/examples/clients/generic.json +8 -0
  45. package/examples/probability/adaptive-event-pool.json +92 -0
  46. package/examples/probability/adaptive-event-sequence-input.json +57 -0
  47. package/examples/probability/event-mtth-scenarios.json +57 -0
  48. package/examples/probability/event-mtth-simulate-input.json +37 -0
  49. package/examples/probability/focus-route-scenarios.json +61 -0
  50. package/examples/probability/focus-route-sweep-input.json +36 -0
  51. package/package.json +19 -8
  52. package/schemas/configuration.schema.json +747 -0
  53. package/schemas/continuous-focus-palette.schema.json +325 -0
  54. package/schemas/custom-weighted-pool.schema.json +298 -0
  55. package/schemas/event-feature-manifest.schema.json +63 -0
  56. package/schemas/focus-plan.schema.json +927 -0
  57. package/schemas/focus-planning-sidecar.schema.json +407 -0
  58. package/schemas/gui-animation-source.schema.json +176 -0
  59. package/schemas/gui-helper.schema.json +301 -0
  60. package/schemas/gui-scenario.schema.json +298 -0
  61. package/schemas/map-operation.schema.json +2284 -0
  62. package/schemas/operation-result.schema.json +327 -0
  63. package/schemas/probability-analysis-result.schema.json +2211 -0
  64. package/schemas/probability-compare-input.schema.json +664 -0
  65. package/schemas/probability-evaluate-input.schema.json +665 -0
  66. package/schemas/probability-inspect-input.schema.json +97 -0
  67. package/schemas/probability-render-input.schema.json +141 -0
  68. package/schemas/probability-scenario-set.schema.json +430 -0
  69. package/schemas/probability-sequence-input.schema.json +886 -0
  70. package/schemas/probability-simulate-input.schema.json +691 -0
  71. package/schemas/probability-sweep-input.schema.json +702 -0
  72. package/server.json +3 -3
@@ -0,0 +1,1168 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { canonicalJson, hashCanonical } from '../core/canonical.js';
4
+ import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
5
+ import { publicArtifactLink } from '../core/artifacts.js';
6
+ import { ServiceError } from '../core/result.js';
7
+ import { PACKAGE_VERSION } from '../version.js';
8
+ import { probabilityAdapter, probabilityAdapters } from './adapters.js';
9
+ import { compareProbabilityResults } from './compare.js';
10
+ import { evaluateExactCandidates, evaluateSurfaceScenarios } from './evaluation.js';
11
+ import { VERIFIED_GAME_CHECKSUM, VERIFIED_GAME_RAW_VERSION, VERIFIED_GAME_VERSION, } from './model.js';
12
+ import { renderProbabilityResult } from './render.js';
13
+ import { analyzeSequence } from './sequence.js';
14
+ import { simulateSurface } from './simulation.js';
15
+ import { discoverWeightedSurface } from './source-analysis.js';
16
+ import { sweepSurface } from './sweep.js';
17
+ const states = new WeakMap();
18
+ function analyzerState(engine) {
19
+ let state = states.get(engine);
20
+ if (state === undefined) {
21
+ state = { byId: new Map(), byCacheKey: new Map() };
22
+ states.set(engine, state);
23
+ }
24
+ return state;
25
+ }
26
+ const DEFAULT_DIAGNOSTIC_THRESHOLDS = {
27
+ dominantProbability: 0.9,
28
+ dominantScenarioPrevalence: 0.8,
29
+ starvedProbability: 0.01,
30
+ negligibleCumulativeChance: 0.01,
31
+ extremeGrowthRatio: 1_000,
32
+ thresholdCliffDelta: 0.5,
33
+ rareOutcomeMinimumObservations: 20,
34
+ };
35
+ function resolvedThresholds(values) {
36
+ return { ...DEFAULT_DIAGNOSTIC_THRESHOLDS, ...values };
37
+ }
38
+ function candidateMetric(candidate, metric) {
39
+ if (candidate === undefined)
40
+ return undefined;
41
+ if (metric === 'conditional_probability')
42
+ return candidate.conditionalProbability ?? undefined;
43
+ if (metric === 'cumulative_chance')
44
+ return candidate.cumulativeChance;
45
+ if (metric === 'effective_mtth_days')
46
+ return candidate.effectiveMtthDays;
47
+ return typeof candidate.rawValue === 'object' && candidate.rawValue !== null
48
+ ? candidate.rawValue.value
49
+ : undefined;
50
+ }
51
+ function insideBand(value, band) {
52
+ return ((band.min === undefined || value >= band.min) && (band.max === undefined || value <= band.max));
53
+ }
54
+ function filterCandidateMetrics(candidate, metrics) {
55
+ if (metrics.size === 0)
56
+ return candidate;
57
+ const { rawValue: _rawValue, rawInterval: _rawInterval, conditionalProbability: _conditionalProbability, exactConditionalProbability: _exactConditionalProbability, pathProbability: _pathProbability, exactPathProbability: _exactPathProbability, pathProbabilityInterval: _pathProbabilityInterval, conditionalProbabilityInterval: _conditionalProbabilityInterval, confidenceInterval: _confidenceInterval, sampledFrequency: _sampledFrequency, effectiveMtthDays: _effectiveMtthDays, timingModel: _timingModel, timingQuantilesDays: _timingQuantilesDays, timingQuantileIntervals: _timingQuantileIntervals, timingSampleCount: _timingSampleCount, timingEvaluations: _timingEvaluations, timingReservoirCapacity: _timingReservoirCapacity, timingMethod: _timingMethod, timingConfidenceMethod: _timingConfidenceMethod, cumulativeChance: _cumulativeChance, cumulativeChanceInterval: _cumulativeChanceInterval, ...base } = candidate;
58
+ return {
59
+ ...base,
60
+ ...(metrics.has('raw_value')
61
+ ? {
62
+ ...(candidate.rawValue === undefined ? {} : { rawValue: candidate.rawValue }),
63
+ ...(candidate.rawInterval === undefined ? {} : { rawInterval: candidate.rawInterval }),
64
+ }
65
+ : {}),
66
+ ...(metrics.has('conditional_probability')
67
+ ? {
68
+ ...(candidate.conditionalProbability === undefined
69
+ ? {}
70
+ : { conditionalProbability: candidate.conditionalProbability }),
71
+ ...(candidate.exactConditionalProbability === undefined
72
+ ? {}
73
+ : { exactConditionalProbability: candidate.exactConditionalProbability }),
74
+ ...(candidate.pathProbability === undefined
75
+ ? {}
76
+ : { pathProbability: candidate.pathProbability }),
77
+ ...(candidate.exactPathProbability === undefined
78
+ ? {}
79
+ : { exactPathProbability: candidate.exactPathProbability }),
80
+ ...(candidate.pathProbabilityInterval === undefined
81
+ ? {}
82
+ : { pathProbabilityInterval: candidate.pathProbabilityInterval }),
83
+ ...(candidate.conditionalProbabilityInterval === undefined
84
+ ? {}
85
+ : { conditionalProbabilityInterval: candidate.conditionalProbabilityInterval }),
86
+ ...(candidate.confidenceInterval === undefined
87
+ ? {}
88
+ : { confidenceInterval: candidate.confidenceInterval }),
89
+ ...(candidate.sampledFrequency === undefined
90
+ ? {}
91
+ : { sampledFrequency: candidate.sampledFrequency }),
92
+ }
93
+ : {}),
94
+ ...(metrics.has('effective_mtth_days')
95
+ ? {
96
+ ...(candidate.effectiveMtthDays === undefined
97
+ ? {}
98
+ : { effectiveMtthDays: candidate.effectiveMtthDays }),
99
+ ...(candidate.timingModel === undefined ? {} : { timingModel: candidate.timingModel }),
100
+ ...(candidate.timingQuantilesDays === undefined
101
+ ? {}
102
+ : { timingQuantilesDays: candidate.timingQuantilesDays }),
103
+ ...(candidate.timingQuantileIntervals === undefined
104
+ ? {}
105
+ : { timingQuantileIntervals: candidate.timingQuantileIntervals }),
106
+ ...(candidate.timingSampleCount === undefined
107
+ ? {}
108
+ : { timingSampleCount: candidate.timingSampleCount }),
109
+ ...(candidate.timingEvaluations === undefined
110
+ ? {}
111
+ : { timingEvaluations: candidate.timingEvaluations }),
112
+ ...(candidate.timingReservoirCapacity === undefined
113
+ ? {}
114
+ : { timingReservoirCapacity: candidate.timingReservoirCapacity }),
115
+ ...(candidate.timingMethod === undefined ? {} : { timingMethod: candidate.timingMethod }),
116
+ ...(candidate.timingConfidenceMethod === undefined
117
+ ? {}
118
+ : { timingConfidenceMethod: candidate.timingConfidenceMethod }),
119
+ }
120
+ : {}),
121
+ ...(metrics.has('cumulative_chance')
122
+ ? {
123
+ ...(candidate.cumulativeChance === undefined
124
+ ? {}
125
+ : { cumulativeChance: candidate.cumulativeChance }),
126
+ ...(candidate.cumulativeChanceInterval === undefined
127
+ ? {}
128
+ : { cumulativeChanceInterval: candidate.cumulativeChanceInterval }),
129
+ }
130
+ : {}),
131
+ };
132
+ }
133
+ function diagnosticsFor(scenarios, surface, extra = {}) {
134
+ const diagnostics = [];
135
+ const thresholds = resolvedThresholds(extra.diagnosticThresholds);
136
+ if (!surface.poolComplete && surface.adapter.completePoolRequired)
137
+ diagnostics.push({
138
+ code: 'PROBABILITY_CANDIDATE_POOL_INCOMPLETE',
139
+ severity: 'warning',
140
+ category: 'validation',
141
+ message: 'The complete candidate pool is not proven, so normalized probabilities are withheld',
142
+ details: { adapterId: surface.adapter.id, candidatesFound: surface.candidates.length },
143
+ });
144
+ if (surface.adapter.id === 'event_option_ai_chance' &&
145
+ surface.candidates.length > 0 &&
146
+ surface.candidates.every(({ eligibilityBlock }) => eligibilityBlock !== undefined))
147
+ diagnostics.push({
148
+ code: 'EVENT_OPTION_FALLBACK_NOT_PROVEN',
149
+ severity: 'warning',
150
+ category: 'validation',
151
+ message: 'Every discovered option is gated; no unconditional AI fallback option is proven',
152
+ });
153
+ for (const scenario of scenarios) {
154
+ const probabilities = scenario.candidates.flatMap(({ conditionalProbability }) => conditionalProbability === undefined || conditionalProbability === null
155
+ ? []
156
+ : [conditionalProbability]);
157
+ if (probabilities.some((probability) => probability >= thresholds.dominantProbability))
158
+ diagnostics.push({
159
+ code: 'PROBABILITY_DOMINANT_OUTCOME',
160
+ severity: 'warning',
161
+ category: 'design',
162
+ message: `Scenario ${scenario.id} has an outcome at or above the configured dominance threshold`,
163
+ details: { scenarioId: scenario.id, maximum: Math.max(...probabilities) },
164
+ });
165
+ for (const candidate of scenario.candidates) {
166
+ if (candidate.eligibility === 'true' &&
167
+ candidate.conditionalProbability !== undefined &&
168
+ candidate.conditionalProbability !== null &&
169
+ candidate.conditionalProbability <= thresholds.starvedProbability)
170
+ diagnostics.push({
171
+ code: 'PROBABILITY_STARVED_OUTCOME',
172
+ severity: 'warning',
173
+ category: 'design',
174
+ message: `${candidate.id} is eligible but at or below the configured starvation threshold in ${scenario.id}`,
175
+ ...(candidate.provenance[0]?.location === undefined
176
+ ? {}
177
+ : { location: candidate.provenance[0].location }),
178
+ details: { scenarioId: scenario.id, candidateId: candidate.id },
179
+ });
180
+ if (surface.adapter.id === 'event_option_ai_chance' &&
181
+ candidate.conditionalProbability !== undefined &&
182
+ candidate.conditionalProbability !== null &&
183
+ candidate.conditionalProbability > 0 &&
184
+ candidate.conditionalProbability < 0.01)
185
+ diagnostics.push({
186
+ code: 'EVENT_OPTION_D100_QUANTIZATION_RISK',
187
+ severity: 'warning',
188
+ category: 'design',
189
+ message: `${candidate.id} is below one percent; verified event-option runtime selection has d100 granularity`,
190
+ ...(candidate.provenance[0]?.location === undefined
191
+ ? {}
192
+ : { location: candidate.provenance[0].location }),
193
+ details: { scenarioId: scenario.id, candidateId: candidate.id },
194
+ });
195
+ const finalTrace = candidate.trace.at(-1);
196
+ if (candidate.unresolved.some(({ code }) => code === 'VALUE_NON_FINITE'))
197
+ diagnostics.push({
198
+ code: 'PROBABILITY_NON_FINITE_VALUE',
199
+ severity: 'error',
200
+ category: 'validation',
201
+ message: `${candidate.id} produces a non-finite value in ${scenario.id}; the result is withheld`,
202
+ ...(candidate.provenance[0]?.location === undefined
203
+ ? {}
204
+ : { location: candidate.provenance[0].location }),
205
+ details: { scenarioId: scenario.id, candidateId: candidate.id },
206
+ });
207
+ if ((finalTrace?.after?.value ?? 0) < 0)
208
+ diagnostics.push({
209
+ code: 'PROBABILITY_NEGATIVE_VALUE_CLAMPED',
210
+ severity: 'warning',
211
+ category: 'validation',
212
+ message: `${candidate.id} evaluates below zero in ${scenario.id} and is clamped before selection`,
213
+ ...(candidate.provenance[0]?.location === undefined
214
+ ? {}
215
+ : { location: candidate.provenance[0].location }),
216
+ details: {
217
+ scenarioId: scenario.id,
218
+ candidateId: candidate.id,
219
+ value: finalTrace.after.value,
220
+ },
221
+ });
222
+ for (const step of candidate.trace) {
223
+ const before = Math.abs(step.before?.value ?? 0);
224
+ const after = Math.abs(step.after?.value ?? 0);
225
+ if (before > 0 && after / before >= thresholds.extremeGrowthRatio)
226
+ diagnostics.push({
227
+ code: 'PROBABILITY_EXTREME_MODIFIER_GROWTH',
228
+ severity: 'warning',
229
+ category: 'design',
230
+ message: `${candidate.id} exceeds the configured modifier-growth ratio in one step`,
231
+ ...(step.provenance?.location === undefined
232
+ ? {}
233
+ : { location: step.provenance.location }),
234
+ details: { scenarioId: scenario.id, candidateId: candidate.id, before, after },
235
+ });
236
+ }
237
+ if (candidate.conditionalProbabilityInterval !== undefined &&
238
+ candidate.conditionalProbabilityInterval.high -
239
+ candidate.conditionalProbabilityInterval.low >=
240
+ 0.5)
241
+ diagnostics.push({
242
+ code: 'PROBABILITY_UNKNOWN_INPUT_DOMINATES_RESULT',
243
+ severity: 'warning',
244
+ category: 'validation',
245
+ message: `Uncertain inputs span at least 50 percentage points for ${candidate.id} in ${scenario.id}`,
246
+ details: {
247
+ scenarioId: scenario.id,
248
+ candidateId: candidate.id,
249
+ interval: candidate.conditionalProbabilityInterval,
250
+ },
251
+ });
252
+ if (candidate.cumulativeChance !== undefined &&
253
+ candidate.cumulativeChance < thresholds.negligibleCumulativeChance &&
254
+ (scenario.horizonDays ?? 0) > 0)
255
+ diagnostics.push({
256
+ code: 'MTTH_HORIZON_CHANCE_NEGLIGIBLE',
257
+ severity: 'info',
258
+ category: 'design',
259
+ message: `${candidate.id} is below the configured cumulative-chance threshold inside the requested horizon`,
260
+ details: {
261
+ scenarioId: scenario.id,
262
+ candidateId: candidate.id,
263
+ horizonDays: scenario.horizonDays,
264
+ cumulativeChance: candidate.cumulativeChance,
265
+ },
266
+ });
267
+ }
268
+ if (surface.adapter.selectionRule === 'proportional_categorical' &&
269
+ scenario.poolComplete &&
270
+ scenario.candidates.some(({ eligibility }) => eligibility === 'true') &&
271
+ scenario.candidates
272
+ .filter(({ eligibility }) => eligibility === 'true')
273
+ .every(({ rawValue }) => typeof rawValue === 'object' && rawValue !== null && rawValue.value === 0))
274
+ diagnostics.push({
275
+ code: 'PROBABILITY_ALL_ELIGIBLE_VALUES_ZERO',
276
+ severity: 'warning',
277
+ category: 'validation',
278
+ message: `All eligible values are zero in ${scenario.id}; adapter fallback behavior controls the result`,
279
+ details: { scenarioId: scenario.id, adapterId: surface.adapter.id },
280
+ });
281
+ }
282
+ for (const scenario of scenarios)
283
+ if (scenario.timingIntervals?.some(({ eligibility }) => eligibility === 'unresolved') === true)
284
+ diagnostics.push({
285
+ code: 'MTTH_SCHEDULE_INTERVAL_UNRESOLVED',
286
+ severity: 'warning',
287
+ category: 'validation',
288
+ message: `Scenario ${scenario.id} has a timing interval whose eligibility cannot be established`,
289
+ details: {
290
+ scenarioId: scenario.id,
291
+ unresolvedIntervals: scenario.timingIntervals.filter(({ eligibility }) => eligibility === 'unresolved'),
292
+ },
293
+ });
294
+ for (const candidate of surface.candidates) {
295
+ const results = scenarios.flatMap((scenario) => {
296
+ const result = scenario.candidates.find(({ id }) => id === candidate.id);
297
+ return result === undefined ? [] : [{ scenario, result }];
298
+ });
299
+ if (results.length > 0 && results.every(({ result }) => result.eligibility === 'false'))
300
+ diagnostics.push({
301
+ code: 'PROBABILITY_OUTCOME_NEVER_ELIGIBLE',
302
+ severity: 'warning',
303
+ category: 'design',
304
+ message: `${candidate.id} is never eligible across the supplied scenarios`,
305
+ ...(candidate.provenance[0]?.location === undefined
306
+ ? {}
307
+ : { location: candidate.provenance[0].location }),
308
+ details: { candidateId: candidate.id, scenarios: results.length },
309
+ });
310
+ const modifierKeys = new Set(results.flatMap(({ result }) => result.trace
311
+ .filter(({ operation }) => operation !== 'eligibility')
312
+ .map(({ operation, expression }) => `${operation}:${expression}`)));
313
+ for (const key of modifierKeys) {
314
+ const applications = results.flatMap(({ result }) => result.trace.filter(({ operation, expression }) => `${operation}:${expression}` === key));
315
+ if (applications.length > 0 && applications.every(({ applied }) => applied === 'false'))
316
+ diagnostics.push({
317
+ code: 'PROBABILITY_MODIFIER_UNSATISFIED_IN_SCENARIOS',
318
+ severity: 'info',
319
+ category: 'design',
320
+ message: `${candidate.id} modifier ${key} is not active in any supplied scenario`,
321
+ ...(applications[0]?.provenance?.location === undefined
322
+ ? {}
323
+ : { location: applications[0].provenance.location }),
324
+ details: { candidateId: candidate.id, modifier: key, scenarios: results.length },
325
+ });
326
+ }
327
+ const sourceTerms = [
328
+ ...new Map(results
329
+ .flatMap(({ result }) => result.trace)
330
+ .filter(({ operation, conditionExpression }) => operation !== 'eligibility' && conditionExpression !== undefined)
331
+ .map((step) => [
332
+ hashCanonical({
333
+ path: step.provenance?.path,
334
+ astPath: step.provenance?.astPath,
335
+ location: step.provenance?.location,
336
+ operation: step.operation,
337
+ expression: step.expression,
338
+ conditionExpression: step.conditionExpression,
339
+ }),
340
+ step,
341
+ ])).values(),
342
+ ];
343
+ const exactModifierGroups = new Map();
344
+ const conditionGroups = new Map();
345
+ for (const term of sourceTerms) {
346
+ const exactKey = `${term.operation}\0${term.expression}\0${term.conditionExpression ?? ''}`;
347
+ exactModifierGroups.set(exactKey, [...(exactModifierGroups.get(exactKey) ?? []), term]);
348
+ const conditionKey = `${term.operation}\0${term.conditionExpression ?? ''}`;
349
+ conditionGroups.set(conditionKey, [...(conditionGroups.get(conditionKey) ?? []), term]);
350
+ }
351
+ for (const [signature, terms] of exactModifierGroups) {
352
+ if (terms.length < 2)
353
+ continue;
354
+ diagnostics.push({
355
+ code: 'PROBABILITY_DUPLICATE_MODIFIER',
356
+ severity: 'warning',
357
+ category: 'design',
358
+ message: `${candidate.id} repeats the same modifier operation and condition`,
359
+ ...(terms[0]?.provenance?.location === undefined
360
+ ? {}
361
+ : { location: terms[0].provenance.location }),
362
+ details: { candidateId: candidate.id, signature, occurrences: terms.length },
363
+ });
364
+ }
365
+ for (const [condition, terms] of conditionGroups) {
366
+ if (new Set(terms.map(({ expression }) => expression)).size < 2)
367
+ continue;
368
+ diagnostics.push({
369
+ code: 'PROBABILITY_CONFLICTING_MODIFIER_CONDITION',
370
+ severity: 'warning',
371
+ category: 'design',
372
+ message: `${candidate.id} applies different modifier values under the same condition`,
373
+ ...(terms[0]?.provenance?.location === undefined
374
+ ? {}
375
+ : { location: terms[0].provenance.location }),
376
+ details: {
377
+ candidateId: candidate.id,
378
+ condition,
379
+ expressions: [...new Set(terms.map(({ expression }) => expression))],
380
+ },
381
+ });
382
+ }
383
+ }
384
+ const dominantCounts = new Map();
385
+ for (const scenario of scenarios) {
386
+ const dominant = scenario.candidates.find(({ conditionalProbability }) => conditionalProbability !== undefined &&
387
+ conditionalProbability !== null &&
388
+ conditionalProbability >= thresholds.dominantProbability);
389
+ if (dominant !== undefined)
390
+ dominantCounts.set(dominant.id, (dominantCounts.get(dominant.id) ?? 0) + 1);
391
+ }
392
+ for (const [candidateId, count] of dominantCounts) {
393
+ if (scenarios.length >= 2 && count / scenarios.length >= thresholds.dominantScenarioPrevalence)
394
+ diagnostics.push({
395
+ code: 'PROBABILITY_OUTCOME_DOMINANT_ACROSS_SCENARIOS',
396
+ severity: 'warning',
397
+ category: 'design',
398
+ message: `${candidateId} exceeds the configured dominance threshold in ${count} of ${scenarios.length} supplied scenarios`,
399
+ details: { candidateId, dominantScenarios: count, suppliedScenarios: scenarios.length },
400
+ });
401
+ }
402
+ const orderedSweep = [...(extra.sweep?.points ?? [])].sort((left, right) => left.scenarioId.localeCompare(right.scenarioId) ||
403
+ left.path.localeCompare(right.path) ||
404
+ left.value - right.value);
405
+ for (let index = 1; index < orderedSweep.length; index += 1) {
406
+ const before = orderedSweep[index - 1];
407
+ const after = orderedSweep[index];
408
+ if (before.scenarioId !== after.scenarioId || before.path !== after.path)
409
+ continue;
410
+ for (const candidate of after.candidates) {
411
+ const previous = before.candidates.find(({ id }) => id === candidate.id);
412
+ const beforeValue = previous?.conditionalProbability ?? previous?.rawValue;
413
+ const afterValue = candidate.conditionalProbability ?? candidate.rawValue;
414
+ if (beforeValue !== undefined &&
415
+ afterValue !== undefined &&
416
+ Math.abs(afterValue - beforeValue) >= thresholds.thresholdCliffDelta)
417
+ diagnostics.push({
418
+ code: 'PROBABILITY_SWEEP_THRESHOLD_CLIFF',
419
+ severity: 'warning',
420
+ category: 'design',
421
+ message: `${candidate.id} exceeds the configured change threshold between adjacent sweep points`,
422
+ details: {
423
+ scenarioId: after.scenarioId,
424
+ candidateId: candidate.id,
425
+ path: after.path,
426
+ between: [before.value, after.value],
427
+ delta: afterValue - beforeValue,
428
+ },
429
+ });
430
+ }
431
+ }
432
+ for (const band of extra.acceptanceBands ?? []) {
433
+ const matchingScenarios = scenarios.filter(({ id }) => band.scenarioId === undefined || band.scenarioId === id);
434
+ if (matchingScenarios.length === 0)
435
+ diagnostics.push({
436
+ code: 'PROBABILITY_ACCEPTANCE_BAND_UNRESOLVED',
437
+ severity: 'warning',
438
+ category: 'validation',
439
+ message: `Acceptance band ${band.id} names no supplied scenario`,
440
+ details: { band },
441
+ });
442
+ for (const scenario of matchingScenarios) {
443
+ const candidate = scenario.candidates.find(({ id }) => id === band.candidateId);
444
+ const value = candidateMetric(candidate, band.metric);
445
+ if (value === undefined) {
446
+ diagnostics.push({
447
+ code: 'PROBABILITY_ACCEPTANCE_BAND_UNRESOLVED',
448
+ severity: 'warning',
449
+ category: 'validation',
450
+ message: `Acceptance band ${band.id} cannot be evaluated for ${scenario.id}`,
451
+ details: { band, scenarioId: scenario.id },
452
+ });
453
+ if (band.min !== undefined && band.min > 0)
454
+ diagnostics.push({
455
+ code: 'PROBABILITY_INTENDED_OUTCOME_UNREACHABLE',
456
+ severity: 'warning',
457
+ category: 'design',
458
+ message: `${band.candidateId} cannot be shown to reach the intended minimum in ${scenario.id}`,
459
+ details: { band, scenarioId: scenario.id },
460
+ });
461
+ continue;
462
+ }
463
+ if (insideBand(value, band))
464
+ continue;
465
+ diagnostics.push({
466
+ code: 'PROBABILITY_ACCEPTANCE_BAND_MISSED',
467
+ severity: 'warning',
468
+ category: 'design',
469
+ message: `${band.candidateId} is outside acceptance band ${band.id} in ${scenario.id}`,
470
+ ...(candidate?.provenance[0]?.location === undefined
471
+ ? {}
472
+ : { location: candidate.provenance[0].location }),
473
+ details: { band, scenarioId: scenario.id, value },
474
+ });
475
+ if (band.max !== undefined &&
476
+ value > band.max &&
477
+ (band.metric === 'conditional_probability' || band.metric === 'cumulative_chance'))
478
+ diagnostics.push({
479
+ code: 'PROBABILITY_RARE_OUTCOME_UNEXPECTEDLY_COMMON',
480
+ severity: 'warning',
481
+ category: 'design',
482
+ message: `${band.candidateId} exceeds its declared maximum in ${scenario.id}`,
483
+ details: { band, scenarioId: scenario.id, value },
484
+ });
485
+ if (band.min !== undefined &&
486
+ value < band.min &&
487
+ (candidate?.eligibility === 'false' || value <= thresholds.starvedProbability))
488
+ diagnostics.push({
489
+ code: 'PROBABILITY_INTENDED_OUTCOME_UNREACHABLE',
490
+ severity: 'warning',
491
+ category: 'design',
492
+ message: `${band.candidateId} is effectively unreachable relative to acceptance band ${band.id}`,
493
+ details: { band, scenarioId: scenario.id, value },
494
+ });
495
+ }
496
+ if (extra.sweep !== undefined &&
497
+ (band.metric === 'raw_value' || band.metric === 'conditional_probability')) {
498
+ const values = extra.sweep.points.flatMap((point) => {
499
+ if (band.scenarioId !== undefined && point.scenarioId !== band.scenarioId)
500
+ return [];
501
+ const candidate = point.candidates.find(({ id }) => id === band.candidateId);
502
+ const value = band.metric === 'raw_value' ? candidate?.rawValue : candidate?.conditionalProbability;
503
+ return value === undefined ? [] : [value];
504
+ });
505
+ if (values.length === 0 || !values.some((value) => insideBand(value, band)))
506
+ diagnostics.push({
507
+ code: 'PROBABILITY_SWEEP_TARGET_BAND_UNREACHED',
508
+ severity: 'warning',
509
+ category: 'design',
510
+ message: `The tested sweep never reaches acceptance band ${band.id}`,
511
+ details: { band, testedValues: values.length },
512
+ });
513
+ }
514
+ }
515
+ for (const issue of extra.unresolved ?? []) {
516
+ if (issue.code !== 'CORRELATED_INPUTS_SAMPLED_INDEPENDENTLY' &&
517
+ issue.code !== 'MONTE_CARLO_RARE_OUTCOME_UNRESOLVED')
518
+ continue;
519
+ diagnostics.push({
520
+ code: issue.code,
521
+ severity: 'warning',
522
+ category: 'validation',
523
+ message: issue.message,
524
+ ...(issue.provenance?.location === undefined ? {} : { location: issue.provenance.location }),
525
+ ...(issue.details === undefined ? {} : { details: issue.details }),
526
+ });
527
+ }
528
+ return diagnostics;
529
+ }
530
+ function deduplicateUnresolved(values) {
531
+ const output = new Map();
532
+ for (const value of values) {
533
+ const key = hashCanonical({
534
+ code: value.code,
535
+ message: value.message,
536
+ path: value.path,
537
+ candidateId: value.candidateId,
538
+ provenance: value.provenance,
539
+ });
540
+ if (!output.has(key))
541
+ output.set(key, value);
542
+ }
543
+ return [...output.values()];
544
+ }
545
+ function resultStatus(unresolved) {
546
+ return unresolved.length === 0 ? 'complete' : 'partial';
547
+ }
548
+ function resultWithoutResources(result) {
549
+ return { ...result, resources: [] };
550
+ }
551
+ function visualOutputs(outputs) {
552
+ return (outputs ?? []).filter((output) => output !== 'json');
553
+ }
554
+ function customSurface(manifest, sourceRevision) {
555
+ const sourceHash = hashCanonical(manifest);
556
+ return {
557
+ id: manifest.id,
558
+ adapter: probabilityAdapter('custom_weighted_pool'),
559
+ candidates: manifest.candidates.map((candidate) => ({
560
+ id: candidate.id,
561
+ adapterId: 'custom_weighted_pool',
562
+ sourceKind: 'custom_pool_candidate',
563
+ defaultValue: String(candidate.weight),
564
+ valueExpression: String(candidate.weight),
565
+ provenance: [
566
+ {
567
+ path: `manifest:${manifest.id}`,
568
+ rootKind: 'fixture',
569
+ loadOrder: 0,
570
+ sourceHash,
571
+ symbol: candidate.id,
572
+ },
573
+ ],
574
+ metadata: {
575
+ ...(candidate.category === undefined ? {} : { category: candidate.category }),
576
+ oneTime: candidate.oneTime ?? false,
577
+ },
578
+ })),
579
+ poolComplete: true,
580
+ sourceRevision,
581
+ sourceHash,
582
+ filesScanned: [],
583
+ unsupported: [],
584
+ };
585
+ }
586
+ export class ProbabilityAnalyzer {
587
+ engine;
588
+ state;
589
+ constructor(engine) {
590
+ this.engine = engine;
591
+ this.state = analyzerState(engine);
592
+ }
593
+ async scan(context) {
594
+ if (context.refresh === true)
595
+ this.engine.invalidate(context.workspaceId);
596
+ return this.engine.scan(context.workspaceId, {}, context.principal, context.signal);
597
+ }
598
+ async verifyGameVersion(context, adapter) {
599
+ const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
600
+ if (adapter === 'custom_weighted_pool' || workspace.gameRoot === undefined)
601
+ return { status: 'adapter_target_only', adapterTarget: VERIFIED_GAME_VERSION };
602
+ const sourcePath = path.join(workspace.gameRoot, 'launcher-settings.json');
603
+ let parsed;
604
+ try {
605
+ parsed = JSON.parse(await readFile(sourcePath, 'utf8'));
606
+ }
607
+ catch (error) {
608
+ throw new ServiceError('PROBABILITY_GAME_VERSION_UNVERIFIED', 'The configured game version could not be verified from launcher-settings.json', { sourcePath, reason: error instanceof Error ? error.message : String(error) });
609
+ }
610
+ if (typeof parsed !== 'object' || parsed === null)
611
+ throw new ServiceError('PROBABILITY_GAME_VERSION_UNVERIFIED', 'The configured launcher-settings.json does not contain a version object', { sourcePath });
612
+ const settings = parsed;
613
+ const observedVersion = typeof settings.version === 'string' ? settings.version : undefined;
614
+ const observedRawVersion = typeof settings.rawVersion === 'string' ? settings.rawVersion : undefined;
615
+ const observedChecksum = observedVersion?.match(/\(([a-z0-9]+)\)\s*$/iu)?.[1];
616
+ if (observedVersion === undefined ||
617
+ observedRawVersion === undefined ||
618
+ observedChecksum === undefined)
619
+ throw new ServiceError('PROBABILITY_GAME_VERSION_UNVERIFIED', 'The configured launcher-settings.json is missing version, rawVersion, or checksum data', { sourcePath, observedVersion, observedRawVersion, observedChecksum });
620
+ if (observedRawVersion !== VERIFIED_GAME_RAW_VERSION ||
621
+ observedChecksum.toLowerCase() !== VERIFIED_GAME_CHECKSUM)
622
+ throw new ServiceError('PROBABILITY_GAME_VERSION_UNSUPPORTED', `Probability adapters are verified for ${VERIFIED_GAME_VERSION}, not the configured game version`, {
623
+ sourcePath,
624
+ observedVersion,
625
+ observedRawVersion,
626
+ observedChecksum,
627
+ adapterTarget: VERIFIED_GAME_VERSION,
628
+ });
629
+ return {
630
+ status: 'workspace_verified',
631
+ adapterTarget: VERIFIED_GAME_VERSION,
632
+ sourcePath,
633
+ observedVersion,
634
+ observedRawVersion,
635
+ observedChecksum: observedChecksum.toLowerCase(),
636
+ };
637
+ }
638
+ metadata(context, snapshot, surface, scenarioSet, candidatePool, gameVersionVerification, extra = {}) {
639
+ const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
640
+ const scenarioHash = hashCanonical(scenarioSet);
641
+ const candidatePoolHash = hashCanonical(candidatePool);
642
+ const cacheKey = hashCanonical({
643
+ workspaceIdentity: workspace.workspaceIdentity,
644
+ sourceRevision: snapshot.revision,
645
+ sourceHash: surface.sourceHash,
646
+ scenarioHash,
647
+ candidatePoolHash,
648
+ adapter: surface.adapter,
649
+ gameVersionVerification,
650
+ extra,
651
+ });
652
+ return {
653
+ workspaceId: context.workspaceId,
654
+ workspaceIdentity: workspace.workspaceIdentity,
655
+ sourceRevision: snapshot.revision,
656
+ sourceHash: surface.sourceHash,
657
+ scenarioHash,
658
+ candidatePoolHash,
659
+ gameVersion: surface.adapter.gameVersion,
660
+ gameVersionVerification,
661
+ adapterId: surface.adapter.id,
662
+ adapterVersion: surface.adapter.version,
663
+ ...(Array.isArray(extra.requestedMetrics)
664
+ ? { requestedMetrics: extra.requestedMetrics }
665
+ : {}),
666
+ ...(typeof extra.seed === 'number' ? { seed: extra.seed } : {}),
667
+ ...(typeof extra.samples === 'number' ? { samples: extra.samples } : {}),
668
+ numericalPrecision: 'exact bigint rationals for finite source arithmetic; IEEE-754 for hazards, distributions, and sampled summaries',
669
+ cacheKey,
670
+ };
671
+ }
672
+ buildResult(operation, metadata, surface, scenarios, extra = {}) {
673
+ const unresolved = deduplicateUnresolved([
674
+ ...surface.unsupported,
675
+ ...scenarios.flatMap(({ unresolved: items }) => items),
676
+ ...(extra.unresolved ?? []),
677
+ ]);
678
+ const analysisId = `probability-${metadata.cacheKey.slice(0, 24)}`;
679
+ const prevalenceRows = scenarios.filter((scenario) => scenario.prevalence !== undefined);
680
+ const prevalenceTotal = prevalenceRows.reduce((sum, scenario) => sum + scenario.prevalence, 0);
681
+ const aggregateCandidateIds = [
682
+ ...new Set(prevalenceRows.flatMap(({ candidates }) => candidates.map(({ id }) => id))),
683
+ ].sort();
684
+ const prevalenceAggregate = prevalenceTotal <= 0
685
+ ? undefined
686
+ : {
687
+ prevalenceTotal,
688
+ candidates: aggregateCandidateIds.map((id) => {
689
+ const rows = prevalenceRows.flatMap((scenario) => {
690
+ const candidate = scenario.candidates.find((item) => item.id === id);
691
+ return candidate === undefined ? [] : [{ scenario, candidate }];
692
+ });
693
+ const probabilityRows = rows.filter(({ candidate }) => candidate.conditionalProbability !== undefined &&
694
+ candidate.conditionalProbability !== null);
695
+ return {
696
+ id,
697
+ eligibilityPrevalence: rows.reduce((sum, { scenario, candidate }) => sum + (candidate.eligibility === 'true' ? scenario.prevalence : 0), 0) / prevalenceTotal,
698
+ ...(probabilityRows.length === rows.length
699
+ ? {
700
+ weightedConditionalProbability: probabilityRows.reduce((sum, { scenario, candidate }) => sum + scenario.prevalence * candidate.conditionalProbability, 0) / prevalenceTotal,
701
+ }
702
+ : {}),
703
+ };
704
+ }),
705
+ };
706
+ return {
707
+ schemaVersion: '1.0',
708
+ status: resultStatus(unresolved),
709
+ operation,
710
+ analysisId,
711
+ metadata,
712
+ adapter: surface.adapter,
713
+ scenarios,
714
+ ...(prevalenceAggregate === undefined ? {} : { prevalenceAggregate }),
715
+ diagnostics: diagnosticsFor(scenarios, surface, {
716
+ ...(extra.sweep === undefined ? {} : { sweep: extra.sweep }),
717
+ ...(extra.simulation === undefined ? {} : { simulation: extra.simulation }),
718
+ ...(extra.acceptanceBands === undefined ? {} : { acceptanceBands: extra.acceptanceBands }),
719
+ ...(extra.diagnosticThresholds === undefined
720
+ ? {}
721
+ : { diagnosticThresholds: extra.diagnosticThresholds }),
722
+ ...(extra.unresolved === undefined ? {} : { unresolved: extra.unresolved }),
723
+ }),
724
+ unresolved,
725
+ ...(extra.sweep === undefined ? {} : { sweep: extra.sweep }),
726
+ ...(extra.simulation === undefined ? {} : { simulation: extra.simulation }),
727
+ ...(extra.sequence === undefined ? {} : { sequence: extra.sequence }),
728
+ ...(extra.comparison === undefined ? {} : { comparison: extra.comparison }),
729
+ resources: [],
730
+ };
731
+ }
732
+ async store(result, outputs, includeHtml = false, signal, principal) {
733
+ const cached = this.state.byCacheKey.get(result.metadata.cacheKey);
734
+ if (cached?.operation === result.operation &&
735
+ cached.status === result.status &&
736
+ visualOutputs(outputs).length === 0)
737
+ return cached;
738
+ const workspace = this.engine.resolver.get(result.metadata.workspaceId, principal);
739
+ const provenance = {
740
+ kind: 'probability-analysis',
741
+ toolVersion: PACKAGE_VERSION,
742
+ schemaVersion: 'probability-analysis.v1',
743
+ sourceHashes: { aggregate: result.metadata.sourceHash },
744
+ metadata: {
745
+ analysisId: result.analysisId,
746
+ operation: result.operation,
747
+ adapterId: result.adapter.id,
748
+ adapterVersion: result.adapter.version,
749
+ scenarioHash: result.metadata.scenarioHash,
750
+ candidatePoolHash: result.metadata.candidatePoolHash,
751
+ },
752
+ };
753
+ const visuals = visualOutputs(outputs);
754
+ const rendered = await renderProbabilityResult(result, visuals, includeHtml, provenance, signal);
755
+ const writes = [
756
+ {
757
+ name: `${result.analysisId}.json`,
758
+ mimeType: 'application/json',
759
+ content: `${canonicalJson(resultWithoutResources(result))}\n`,
760
+ provenance,
761
+ description: 'Authoritative AI and MTTH scenario analysis JSON',
762
+ },
763
+ ...rendered.writes,
764
+ ];
765
+ const stored = await this.engine.artifacts.withAtomicChunkedWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), signal);
766
+ const complete = { ...result, resources: stored.map(publicArtifactLink) };
767
+ if (complete.operation !== 'render') {
768
+ this.state.byId.set(complete.analysisId, complete);
769
+ this.state.byCacheKey.set(complete.metadata.cacheKey, complete);
770
+ }
771
+ return complete;
772
+ }
773
+ async inspect(context, adapter, source, candidatePool = []) {
774
+ if (adapter === undefined || source === undefined)
775
+ return { adapters: probabilityAdapters(), artifacts: [], filesScanned: [] };
776
+ const gameVersionVerification = await this.verifyGameVersion(context, adapter);
777
+ const snapshot = await this.scan(context);
778
+ const surface = discoverWeightedSurface(snapshot, adapter, source, candidatePool);
779
+ const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
780
+ const inspectedCandidates = evaluateExactCandidates(surface, { id: 'inspection', state: {} }, definitions);
781
+ const requiredInputs = [
782
+ ...new Set(inspectedCandidates.flatMap(({ unresolved }) => unresolved.flatMap(({ path }) => (path === undefined ? [] : [path])))),
783
+ ].sort();
784
+ const report = {
785
+ schemaVersion: 'probability-inspection.v1',
786
+ workspaceId: context.workspaceId,
787
+ surface: {
788
+ id: surface.id,
789
+ adapter: surface.adapter,
790
+ poolComplete: surface.poolComplete,
791
+ sourceRevision: surface.sourceRevision,
792
+ sourceHash: surface.sourceHash,
793
+ gameVersionVerification,
794
+ candidateCount: surface.candidates.length,
795
+ candidates: surface.candidates.map((candidate, index) => {
796
+ const evaluated = inspectedCandidates[index];
797
+ const candidateRequiredInputs = [
798
+ ...new Set(evaluated.unresolved.flatMap(({ path }) => (path === undefined ? [] : [path]))),
799
+ ].sort();
800
+ return {
801
+ id: candidate.id,
802
+ sourceKind: candidate.sourceKind,
803
+ provenance: candidate.provenance,
804
+ hasEligibility: candidate.eligibilityBlock !== undefined,
805
+ hasWeightBlock: candidate.weightBlock !== undefined || candidate.valueExpression !== undefined,
806
+ requiredInputs: candidateRequiredInputs,
807
+ referencedProvenance: evaluated.provenance.filter(({ path, symbol }) => !candidate.provenance.some((source) => source.path === path && source.symbol === symbol)),
808
+ analysisSupport: candidateRequiredInputs.length > 0
809
+ ? 'requires_scenario'
810
+ : surface.adapter.selectionRule === 'score_only'
811
+ ? 'score_only'
812
+ : surface.adapter.completePoolRequired && !surface.poolComplete
813
+ ? 'requires_complete_pool'
814
+ : 'exact',
815
+ };
816
+ }),
817
+ requiredInputs,
818
+ unsupported: surface.unsupported,
819
+ },
820
+ };
821
+ const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
822
+ const artifact = await this.engine.artifacts.putChunked(workspace, `probability-inspect-${surface.sourceHash.slice(0, 12)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
823
+ kind: 'probability-inspection',
824
+ toolVersion: PACKAGE_VERSION,
825
+ schemaVersion: 'probability-inspection.v1',
826
+ sourceHashes: { aggregate: surface.sourceHash },
827
+ }, 'Discovered weighted surfaces and adapter capabilities', context.signal);
828
+ return {
829
+ adapters: probabilityAdapters(),
830
+ surface: report.surface,
831
+ artifacts: [publicArtifactLink(artifact)],
832
+ filesScanned: surface.filesScanned,
833
+ };
834
+ }
835
+ async analyzeUnstored(operation, request, extra = {}) {
836
+ request.signal?.throwIfAborted();
837
+ if (request.scenarioSet.workspaceId !== undefined &&
838
+ request.scenarioSet.workspaceId !== request.workspaceId)
839
+ throw new ServiceError('PROBABILITY_SCENARIO_WORKSPACE_MISMATCH', 'Scenario set belongs to a different workspace');
840
+ const gameVersionVerification = await this.verifyGameVersion(request, request.adapter);
841
+ const snapshot = await this.scan(request);
842
+ const surface = discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
843
+ const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
844
+ const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.candidatePool ?? [], gameVersionVerification, {
845
+ operation,
846
+ horizonDays: request.horizonDays,
847
+ requestedMetrics: request.metrics,
848
+ acceptanceBands: request.acceptanceBands,
849
+ diagnosticThresholds: request.diagnosticThresholds,
850
+ ...extra,
851
+ });
852
+ const cached = this.state.byCacheKey.get(metadata.cacheKey);
853
+ if (cached !== undefined)
854
+ return { result: cached, surface, definitions, snapshot };
855
+ const scenarios = evaluateSurfaceScenarios(surface, request.scenarioSet.scenarios, definitions, request.horizonDays);
856
+ return {
857
+ result: this.buildResult(operation, metadata, surface, scenarios, {
858
+ ...(request.acceptanceBands === undefined
859
+ ? {}
860
+ : { acceptanceBands: request.acceptanceBands }),
861
+ ...(request.diagnosticThresholds === undefined
862
+ ? {}
863
+ : { diagnosticThresholds: request.diagnosticThresholds }),
864
+ }),
865
+ surface,
866
+ definitions,
867
+ snapshot,
868
+ };
869
+ }
870
+ async evaluate(request) {
871
+ const analyzed = await this.analyzeUnstored('evaluate', request);
872
+ return this.store(analyzed.result, request.outputs ?? ['json'], false, request.signal, request.principal);
873
+ }
874
+ async sweep(request) {
875
+ const analyzed = await this.analyzeUnstored('sweep', request, { sweep: request.sweep });
876
+ if (analyzed.result.operation === 'sweep' && analyzed.result.sweep !== undefined)
877
+ return this.store(analyzed.result, request.outputs ?? ['json', 'sensitivity'], false, request.signal, request.principal);
878
+ const sweep = sweepSurface(analyzed.surface, request.scenarioSet.scenarios, analyzed.definitions, request.sweep, request.signal);
879
+ const result = this.buildResult('sweep', analyzed.result.metadata, analyzed.surface, analyzed.result.scenarios, {
880
+ sweep,
881
+ ...(request.acceptanceBands === undefined
882
+ ? {}
883
+ : { acceptanceBands: request.acceptanceBands }),
884
+ ...(request.diagnosticThresholds === undefined
885
+ ? {}
886
+ : { diagnosticThresholds: request.diagnosticThresholds }),
887
+ });
888
+ return this.store(result, request.outputs ?? ['json', 'sensitivity'], false, request.signal, request.principal);
889
+ }
890
+ async simulate(request) {
891
+ const analyzed = await this.analyzeUnstored('simulate', request, {
892
+ samples: request.samples,
893
+ seed: request.seed,
894
+ confidenceLevel: request.confidenceLevel,
895
+ samplingMethod: request.samplingMethod ?? 'latin_hypercube',
896
+ });
897
+ if (analyzed.result.operation === 'simulate' && analyzed.result.simulation !== undefined)
898
+ return this.store(analyzed.result, request.outputs ?? ['json', 'matrix'], false, request.signal, request.principal);
899
+ const simulation = simulateSurface(analyzed.surface, request.scenarioSet.scenarios, analyzed.definitions, request.samples, request.seed, request.confidenceLevel, request.samplingMethod ?? 'latin_hypercube', resolvedThresholds(request.diagnosticThresholds).rareOutcomeMinimumObservations, request.signal);
900
+ const scenarios = analyzed.result.scenarios.map((scenario, scenarioIndex) => {
901
+ const summary = simulation.summaries.find(({ scenarioId }) => scenarioId === scenario.id);
902
+ const sampledPaths = new Set(request.scenarioSet.scenarios[scenarioIndex]?.uncertainInputs?.map(({ path }) => path) ??
903
+ []);
904
+ const unresolvedAfterSampling = (issue) => issue.path === undefined || !sampledPaths.has(issue.path);
905
+ const candidates = scenario.candidates.map((candidate) => {
906
+ const sampled = summary?.candidates.find(({ id }) => id === candidate.id);
907
+ const remainingUnresolved = candidate.unresolved.filter(unresolvedAfterSampling);
908
+ return sampled === undefined
909
+ ? { ...candidate, unresolved: remainingUnresolved }
910
+ : {
911
+ ...candidate,
912
+ supportLevel: remainingUnresolved.length === 0 &&
913
+ (sampled.frequency !== undefined || sampled.timingSampleCount !== undefined)
914
+ ? 'sampled'
915
+ : candidate.supportLevel,
916
+ unresolved: remainingUnresolved,
917
+ ...(sampled.frequency === undefined ? {} : { sampledFrequency: sampled.frequency }),
918
+ ...(sampled.confidenceInterval === undefined
919
+ ? {}
920
+ : { confidenceInterval: sampled.confidenceInterval }),
921
+ ...(sampled.timingQuantilesDays === undefined
922
+ ? {}
923
+ : { timingQuantilesDays: sampled.timingQuantilesDays }),
924
+ ...(sampled.timingQuantileIntervals === undefined
925
+ ? {}
926
+ : { timingQuantileIntervals: sampled.timingQuantileIntervals }),
927
+ ...(sampled.timingSampleCount === undefined
928
+ ? {}
929
+ : { timingSampleCount: sampled.timingSampleCount }),
930
+ ...(sampled.timingEvaluations === undefined
931
+ ? {}
932
+ : { timingEvaluations: sampled.timingEvaluations }),
933
+ ...(sampled.timingReservoirCapacity === undefined
934
+ ? {}
935
+ : { timingReservoirCapacity: sampled.timingReservoirCapacity }),
936
+ ...(sampled.timingMethod === undefined ? {} : { timingMethod: sampled.timingMethod }),
937
+ ...(sampled.timingConfidenceMethod === undefined
938
+ ? {}
939
+ : { timingConfidenceMethod: sampled.timingConfidenceMethod }),
940
+ };
941
+ });
942
+ const supportLevel = candidates.some(({ supportLevel }) => supportLevel === 'unsupported')
943
+ ? 'unsupported'
944
+ : candidates.some(({ supportLevel }) => supportLevel === 'external')
945
+ ? 'external'
946
+ : candidates.some(({ supportLevel }) => supportLevel === 'bounded')
947
+ ? 'bounded'
948
+ : candidates.some(({ supportLevel }) => supportLevel === 'sampled')
949
+ ? 'sampled'
950
+ : scenario.supportLevel;
951
+ return {
952
+ ...scenario,
953
+ supportLevel,
954
+ candidates,
955
+ unresolved: scenario.unresolved.filter(unresolvedAfterSampling),
956
+ };
957
+ });
958
+ const result = this.buildResult('simulate', analyzed.result.metadata, analyzed.surface, scenarios, {
959
+ simulation: simulation.summaries,
960
+ unresolved: simulation.unresolved,
961
+ ...(request.acceptanceBands === undefined
962
+ ? {}
963
+ : { acceptanceBands: request.acceptanceBands }),
964
+ ...(request.diagnosticThresholds === undefined
965
+ ? {}
966
+ : { diagnosticThresholds: request.diagnosticThresholds }),
967
+ });
968
+ return this.store(result, request.outputs ?? ['json', 'matrix'], false, request.signal, request.principal);
969
+ }
970
+ async sequence(request) {
971
+ request.signal?.throwIfAborted();
972
+ const snapshot = await this.scan(request);
973
+ const surface = customSurface(request.customPoolManifest, snapshot.revision);
974
+ const gameVersionVerification = await this.verifyGameVersion(request, 'custom_weighted_pool');
975
+ const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.customPoolManifest.candidates.map(({ id }) => id), gameVersionVerification, {
976
+ operation: 'sequence',
977
+ manifest: request.customPoolManifest,
978
+ horizonDays: request.horizonDays,
979
+ maxSteps: request.maxSteps,
980
+ samples: request.samples,
981
+ seed: request.seed,
982
+ confidenceLevel: request.confidenceLevel,
983
+ acceptanceBands: request.acceptanceBands,
984
+ diagnosticThresholds: request.diagnosticThresholds,
985
+ });
986
+ const cached = this.state.byCacheKey.get(metadata.cacheKey);
987
+ if (cached !== undefined)
988
+ return cached;
989
+ const [scenario] = request.scenarioSet.scenarios;
990
+ if (scenario === undefined)
991
+ throw new ServiceError('PROBABILITY_SCENARIO_REQUIRED', 'Sequence analysis requires one scenario');
992
+ const sequence = analyzeSequence(request.customPoolManifest, scenario, request.horizonDays, request.maxSteps, request.samples, request.seed, request.confidenceLevel, request.signal);
993
+ const scenarios = [
994
+ {
995
+ id: scenario.id,
996
+ ...(scenario.label === undefined ? {} : { label: scenario.label }),
997
+ ...(scenario.prevalence === undefined ? {} : { prevalence: scenario.prevalence }),
998
+ poolComplete: true,
999
+ supportLevel: sequence.summary.method === 'seeded_monte_carlo'
1000
+ ? 'sampled'
1001
+ : sequence.summary.method === 'bounded_beam'
1002
+ ? 'bounded'
1003
+ : 'exact',
1004
+ candidates: request.customPoolManifest.candidates.map((candidate) => ({
1005
+ id: candidate.id,
1006
+ eligibility: 'true',
1007
+ supportLevel: sequence.summary.method === 'seeded_monte_carlo'
1008
+ ? 'sampled'
1009
+ : sequence.summary.method === 'bounded_beam'
1010
+ ? 'bounded'
1011
+ : 'exact',
1012
+ rawValue: typeof candidate.weight === 'number'
1013
+ ? {
1014
+ numerator: String(candidate.weight),
1015
+ denominator: '1',
1016
+ decimal: String(candidate.weight),
1017
+ value: candidate.weight,
1018
+ }
1019
+ : null,
1020
+ rank: null,
1021
+ trace: [],
1022
+ provenance: surface.candidates.find(({ id }) => id === candidate.id).provenance,
1023
+ unresolved: [],
1024
+ })),
1025
+ horizonDays: request.horizonDays,
1026
+ summary: {
1027
+ topOutcomes: sequence.summary.candidates
1028
+ .slice()
1029
+ .sort((left, right) => right.nextSelectionProbability - left.nextSelectionProbability ||
1030
+ left.id.localeCompare(right.id))
1031
+ .slice(0, 3)
1032
+ .map(({ id }) => id),
1033
+ bottomEligibleOutcomes: sequence.summary.candidates
1034
+ .slice()
1035
+ .sort((left, right) => left.nextSelectionProbability - right.nextSelectionProbability ||
1036
+ left.id.localeCompare(right.id))
1037
+ .slice(0, 3)
1038
+ .map(({ id }) => id),
1039
+ impossibleOutcomes: sequence.summary.candidates
1040
+ .filter(({ everSelectedProbability }) => everSelectedProbability === 0)
1041
+ .map(({ id }) => id),
1042
+ unresolvedOutcomes: [],
1043
+ dominantFactors: [],
1044
+ },
1045
+ unresolved: sequence.unresolved,
1046
+ },
1047
+ ];
1048
+ const result = this.buildResult('sequence', metadata, surface, scenarios, {
1049
+ sequence: sequence.summary,
1050
+ unresolved: sequence.unresolved,
1051
+ ...(request.acceptanceBands === undefined
1052
+ ? {}
1053
+ : { acceptanceBands: request.acceptanceBands }),
1054
+ ...(request.diagnosticThresholds === undefined
1055
+ ? {}
1056
+ : { diagnosticThresholds: request.diagnosticThresholds }),
1057
+ });
1058
+ return this.store(result, request.outputs ?? ['json', 'sequence'], false, request.signal, request.principal);
1059
+ }
1060
+ async compare(request) {
1061
+ const before = await this.analyzeUnstored('evaluate', { ...request, source: request.before });
1062
+ const after = await this.analyzeUnstored('evaluate', {
1063
+ ...request,
1064
+ source: request.after,
1065
+ refresh: false,
1066
+ });
1067
+ const metadata = {
1068
+ ...after.result.metadata,
1069
+ cacheKey: hashCanonical({
1070
+ operation: 'compare',
1071
+ before: before.result.metadata.cacheKey,
1072
+ after: after.result.metadata.cacheKey,
1073
+ }),
1074
+ };
1075
+ const cached = this.state.byCacheKey.get(metadata.cacheKey);
1076
+ if (cached !== undefined)
1077
+ return cached;
1078
+ const comparison = compareProbabilityResults(before.result, after.result, request.acceptanceBands);
1079
+ const result = this.buildResult('compare', metadata, after.surface, after.result.scenarios, {
1080
+ comparison,
1081
+ unresolved: [...before.result.unresolved, ...after.result.unresolved],
1082
+ ...(request.acceptanceBands === undefined
1083
+ ? {}
1084
+ : { acceptanceBands: request.acceptanceBands }),
1085
+ ...(request.diagnosticThresholds === undefined
1086
+ ? {}
1087
+ : { diagnosticThresholds: request.diagnosticThresholds }),
1088
+ });
1089
+ return this.store(result, request.outputs ?? ['json', 'comparison'], false, request.signal, request.principal);
1090
+ }
1091
+ async render(request) {
1092
+ const result = this.state.byId.get(request.analysisId);
1093
+ if (result === undefined)
1094
+ throw new ServiceError('PROBABILITY_ANALYSIS_NOT_CACHED', 'Render requires an analysis ID produced by this server process', { analysisId: request.analysisId });
1095
+ if (result.metadata.workspaceId !== request.workspaceId)
1096
+ throw new ServiceError('PROBABILITY_ANALYSIS_WORKSPACE_MISMATCH', 'Analysis belongs to a different workspace');
1097
+ if (request.expectedScenarioHash !== undefined &&
1098
+ request.expectedScenarioHash !== result.metadata.scenarioHash) {
1099
+ const stale = {
1100
+ ...result,
1101
+ status: 'stale',
1102
+ operation: 'render',
1103
+ diagnostics: [
1104
+ ...result.diagnostics,
1105
+ {
1106
+ code: 'PROBABILITY_SCENARIO_STALE',
1107
+ severity: 'warning',
1108
+ category: 'validation',
1109
+ message: 'The expected scenario hash does not match this cached analysis; run analysis for the current scenario set before rendering',
1110
+ details: {
1111
+ expectedScenarioHash: request.expectedScenarioHash,
1112
+ analysisScenarioHash: result.metadata.scenarioHash,
1113
+ },
1114
+ },
1115
+ ],
1116
+ };
1117
+ return this.store(stale, ['json'], false, request.signal, request.principal);
1118
+ }
1119
+ await this.verifyGameVersion(request, result.adapter.id);
1120
+ const snapshot = await this.scan(request);
1121
+ if (snapshot.revision !== result.metadata.sourceRevision) {
1122
+ const stale = {
1123
+ ...result,
1124
+ status: 'stale',
1125
+ operation: 'render',
1126
+ diagnostics: [
1127
+ ...result.diagnostics,
1128
+ {
1129
+ code: 'PROBABILITY_ANALYSIS_STALE',
1130
+ severity: 'warning',
1131
+ category: 'validation',
1132
+ message: 'Workspace sources changed after this analysis; run the analysis again before rendering',
1133
+ details: {
1134
+ analysisRevision: result.metadata.sourceRevision,
1135
+ currentRevision: snapshot.revision,
1136
+ },
1137
+ },
1138
+ ],
1139
+ };
1140
+ return this.store(stale, ['json'], false, request.signal, request.principal);
1141
+ }
1142
+ const scenarioIds = new Set(request.filter?.scenarioIds ?? []);
1143
+ const candidateIds = new Set(request.filter?.candidateIds ?? []);
1144
+ const sourcePaths = new Set((request.filter?.sourcePaths ?? []).map((value) => value.replaceAll('\\', '/').toLowerCase()));
1145
+ const metrics = new Set(request.filter?.metrics ?? []);
1146
+ const offset = request.filter?.offset ?? 0;
1147
+ const limit = request.filter?.limit ?? 10_000;
1148
+ const filteredScenarios = result.scenarios
1149
+ .filter(({ id }) => scenarioIds.size === 0 || scenarioIds.has(id))
1150
+ .map((scenario) => ({
1151
+ ...scenario,
1152
+ candidates: scenario.candidates
1153
+ .filter((candidate) => (candidateIds.size === 0 || candidateIds.has(candidate.id)) &&
1154
+ (sourcePaths.size === 0 ||
1155
+ candidate.provenance.some(({ path }) => sourcePaths.has(path.replaceAll('\\', '/').toLowerCase()))))
1156
+ .slice(offset, offset + limit)
1157
+ .map((candidate) => filterCandidateMetrics(candidate, metrics)),
1158
+ }));
1159
+ const severities = new Set(request.filter?.diagnosticSeverities ?? []);
1160
+ const filtered = {
1161
+ ...result,
1162
+ operation: 'render',
1163
+ scenarios: filteredScenarios,
1164
+ diagnostics: result.diagnostics.filter(({ severity }) => severities.size === 0 || severities.has(severity)),
1165
+ };
1166
+ return this.store(filtered, ['json', ...request.outputs], request.includeHtml, request.signal, request.principal);
1167
+ }
1168
+ }