hoi4-agent-tools 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +30 -21
- package/dist/bin/stdio.js +0 -4
- package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
- package/dist/hoi4_agent_tools/core/engine.js +5 -0
- package/dist/hoi4_agent_tools/core/index.js +57 -1
- package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
- package/dist/hoi4_agent_tools/core/source/index.js +1 -0
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
- package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
- package/dist/hoi4_agent_tools/probability/compare.js +178 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
- package/dist/hoi4_agent_tools/probability/index.js +13 -0
- package/dist/hoi4_agent_tools/probability/model.js +5 -0
- package/dist/hoi4_agent_tools/probability/rational.js +145 -0
- package/dist/hoi4_agent_tools/probability/render.js +307 -0
- package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
- package/dist/hoi4_agent_tools/probability/service.js +1168 -0
- package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
- package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
- package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
- package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +2 -0
- package/docs/probability.md +80 -0
- package/docs/research/probability-adapter-evidence.md +64 -0
- package/examples/clients/codex-config.toml +3 -0
- package/examples/clients/generic.json +8 -0
- package/examples/probability/adaptive-event-pool.json +92 -0
- package/examples/probability/adaptive-event-sequence-input.json +57 -0
- package/examples/probability/event-mtth-scenarios.json +57 -0
- package/examples/probability/event-mtth-simulate-input.json +37 -0
- package/examples/probability/focus-route-scenarios.json +61 -0
- package/examples/probability/focus-route-sweep-input.json +36 -0
- package/package.json +17 -8
- package/schemas/configuration.schema.json +747 -0
- package/schemas/continuous-focus-palette.schema.json +325 -0
- package/schemas/custom-weighted-pool.schema.json +298 -0
- package/schemas/event-feature-manifest.schema.json +63 -0
- package/schemas/focus-plan.schema.json +927 -0
- package/schemas/focus-planning-sidecar.schema.json +407 -0
- package/schemas/gui-animation-source.schema.json +176 -0
- package/schemas/gui-helper.schema.json +301 -0
- package/schemas/gui-scenario.schema.json +298 -0
- package/schemas/map-operation.schema.json +2284 -0
- package/schemas/operation-result.schema.json +327 -0
- package/schemas/probability-analysis-result.schema.json +2211 -0
- package/schemas/probability-compare-input.schema.json +664 -0
- package/schemas/probability-evaluate-input.schema.json +665 -0
- package/schemas/probability-inspect-input.schema.json +97 -0
- package/schemas/probability-render-input.schema.json +141 -0
- package/schemas/probability-scenario-set.schema.json +430 -0
- package/schemas/probability-sequence-input.schema.json +886 -0
- package/schemas/probability-simulate-input.schema.json +691 -0
- package/schemas/probability-sweep-input.schema.json +702 -0
- package/server.json +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -530
|
@@ -0,0 +1,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
|
+
}
|