hoi4-agent-tools 2.7.0 → 2.8.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 +7 -0
- package/README.md +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -0
- package/dist/hoi4_agent_tools/probability/adapters.js +5 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
- package/dist/hoi4_agent_tools/probability/scenario-state.js +165 -0
- package/dist/hoi4_agent_tools/probability/scope-pools.js +199 -0
- package/dist/hoi4_agent_tools/probability/simulation.js +4 -3
- package/dist/hoi4_agent_tools/probability/sweep.js +6 -2
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +35 -28
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +4 -0
- package/dist/hoi4_agent_tools/schemas/probability.js +90 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/probability.md +46 -0
- package/docs/research/probability-adapter-evidence.md +2 -1
- package/examples/probability/scoped-dynamic-pools-evaluate-input.json +50 -0
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +175 -66
- package/schemas/probability-compare-input.schema.json +251 -66
- package/schemas/probability-evaluate-input.schema.json +252 -67
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +231 -46
- package/schemas/probability-sequence-input.schema.json +273 -88
- package/schemas/probability-simulate-input.schema.json +252 -67
- package/schemas/probability-sweep-input.schema.json +254 -69
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 2.8.0 - 2026-08-26
|
|
8
|
+
|
|
9
|
+
- Added structured `ROOT`, `THIS`, `PREV`, `FROM` chain, saved-scope, and event-target bindings to probability scenarios.
|
|
10
|
+
- Added deterministic enumeration and exact complete-pool shares for declared dynamic destination, opposition, donor, and other runtime scope pools.
|
|
11
|
+
- Extended exact branching, sweeps, and simulation to update scoped and pool-candidate state without flattening it into the root scenario.
|
|
12
|
+
- Added MCP resource counts, a published callable example, 2,048-candidate coverage, and source-linked trigger traces for dynamic scope pools.
|
|
13
|
+
|
|
7
14
|
## 2.7.0 - 2026-08-24
|
|
8
15
|
|
|
9
16
|
- Made scripted-GUI layout follow Clausewitz orientation, element-origin, center-position, relative-dimension, local-scale, container-clipping, and fixed text-bound behavior.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
|
|
|
9
9
|
- Maps: navigate the complete rendered map by ID or localised name; inspect provinces, states, regions, adjacency, supply, railways, and positions; create states and provinces; change IDs; and repair connected map data.
|
|
10
10
|
- Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
|
|
11
11
|
- Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
|
|
12
|
-
- AI and MTTH: evaluate weighted choices and timing across explicit scenarios, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
|
|
12
|
+
- AI and MTTH: evaluate weighted choices and timing across explicit scenarios, bind special scope chains, enumerate dynamic target pools, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
|
|
13
13
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
@@ -120,6 +120,8 @@ const analysisDataSchema = z
|
|
|
120
120
|
cacheKey: sha256Schema,
|
|
121
121
|
scenarios: nonNegativeIntegerSchema,
|
|
122
122
|
candidates: nonNegativeIntegerSchema,
|
|
123
|
+
scopePools: nonNegativeIntegerSchema.optional(),
|
|
124
|
+
scopePoolCandidates: nonNegativeIntegerSchema.optional(),
|
|
123
125
|
unresolved: nonNegativeIntegerSchema,
|
|
124
126
|
diagnostics: nonNegativeIntegerSchema,
|
|
125
127
|
sweepPoints: nonNegativeIntegerSchema.optional(),
|
|
@@ -194,6 +196,13 @@ function analysisData(result) {
|
|
|
194
196
|
cacheKey: result.metadata.cacheKey,
|
|
195
197
|
scenarios: result.scenarios.length,
|
|
196
198
|
candidates: result.scenarios.reduce((sum, scenario) => sum + scenario.candidates.length, 0),
|
|
199
|
+
...(result.scenarios.some(({ scopePools }) => (scopePools?.length ?? 0) > 0)
|
|
200
|
+
? {
|
|
201
|
+
scopePools: result.scenarios.reduce((sum, scenario) => sum + (scenario.scopePools?.length ?? 0), 0),
|
|
202
|
+
scopePoolCandidates: result.scenarios.reduce((sum, scenario) => sum +
|
|
203
|
+
(scenario.scopePools?.reduce((poolSum, pool) => poolSum + pool.candidates.length, 0) ?? 0), 0),
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
197
206
|
unresolved: result.unresolved.length,
|
|
198
207
|
diagnostics: result.diagnostics.length,
|
|
199
208
|
...(result.sweep === undefined ? {} : { sweepPoints: result.sweep.points.length }),
|
|
@@ -326,6 +326,11 @@ const descriptors = Object.fromEntries(Object.entries(sources).map(([id, source]
|
|
|
326
326
|
'Apply declared external factors only after source-local operations.',
|
|
327
327
|
],
|
|
328
328
|
poolNormalizationRules: normalizationRule(source.selectionRule),
|
|
329
|
+
supportedExpressions: [
|
|
330
|
+
...source.supportedExpressions,
|
|
331
|
+
'scenario-bound ROOT/THIS/PREV/FROM scope chains, saved scopes, and event targets',
|
|
332
|
+
'declared dynamic scope-pool filters with uniform or proportional selection',
|
|
333
|
+
],
|
|
329
334
|
timingConversion: source.selectionRule === 'median_daily_hazard'
|
|
330
335
|
? [
|
|
331
336
|
'Treat the effective duration as the median of verified daily checks while active.',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
2
|
import { assignments } from '../core/source/index.js';
|
|
3
3
|
import { Rational, sumRationals, uniformRaceProbabilities } from './rational.js';
|
|
4
|
+
import { withScenarioPathValue } from './scenario-state.js';
|
|
5
|
+
import { evaluateScopePools } from './scope-pools.js';
|
|
4
6
|
import { evaluateTriggerBlock } from './trigger-evaluator.js';
|
|
5
7
|
import { evaluateCandidateValue } from './value-evaluator.js';
|
|
6
8
|
const BRANCH_LIMIT = 4_096;
|
|
@@ -305,7 +307,7 @@ function scenarioBranches(surface, scenario) {
|
|
|
305
307
|
});
|
|
306
308
|
break;
|
|
307
309
|
}
|
|
308
|
-
branches = branches.flatMap((branch) => values.map((value) => (
|
|
310
|
+
branches = branches.flatMap((branch) => values.map((value) => withScenarioPathValue(branch, input.path, value)));
|
|
309
311
|
if (values.length > 1)
|
|
310
312
|
bounded = true;
|
|
311
313
|
}
|
|
@@ -599,10 +601,12 @@ export function evaluateSurfaceScenarios(surface, scenarios, definitions, horizo
|
|
|
599
601
|
return result;
|
|
600
602
|
});
|
|
601
603
|
rankCandidates(candidates);
|
|
604
|
+
const scopePools = evaluateScopePools(scenario, definitions);
|
|
602
605
|
const allUnresolved = [
|
|
603
606
|
...surface.unsupported,
|
|
604
607
|
...expanded.unresolved,
|
|
605
608
|
...candidates.flatMap(({ unresolved }) => unresolved),
|
|
609
|
+
...scopePools.flatMap(({ unresolved }) => unresolved),
|
|
606
610
|
];
|
|
607
611
|
const timing = horizonDays === undefined
|
|
608
612
|
? { unresolved: [] }
|
|
@@ -632,6 +636,7 @@ export function evaluateSurfaceScenarios(surface, scenarios, definitions, horizo
|
|
|
632
636
|
? 'score_only'
|
|
633
637
|
: 'exact',
|
|
634
638
|
candidates,
|
|
639
|
+
...(scopePools.length === 0 ? {} : { scopePools }),
|
|
635
640
|
...(exactValues.length === candidates.length &&
|
|
636
641
|
surface.adapter.selectionRule === 'proportional_categorical'
|
|
637
642
|
? { poolTotal: sumRationals(exactValues).toJSON() }
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
function caseInsensitiveEntry(record, key) {
|
|
2
|
+
if (record === undefined)
|
|
3
|
+
return undefined;
|
|
4
|
+
if (Object.hasOwn(record, key))
|
|
5
|
+
return record[key];
|
|
6
|
+
const normalized = key.toLowerCase();
|
|
7
|
+
const found = Object.entries(record).find(([candidate]) => candidate.toLowerCase() === normalized);
|
|
8
|
+
return found?.[1];
|
|
9
|
+
}
|
|
10
|
+
export function rootScopeContext(scenario) {
|
|
11
|
+
return {
|
|
12
|
+
expression: 'ROOT',
|
|
13
|
+
binding: {
|
|
14
|
+
id: scenario.actor ?? 'ROOT',
|
|
15
|
+
...(scenario.actor === undefined ? {} : { actor: scenario.actor }),
|
|
16
|
+
state: scenario.state,
|
|
17
|
+
...(scenario.flags === undefined ? {} : { flags: scenario.flags }),
|
|
18
|
+
...(scenario.eventTargets === undefined ? {} : { eventTargets: scenario.eventTargets }),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function explicitScope(scenario, expression, parent) {
|
|
23
|
+
const binding = caseInsensitiveEntry(scenario.scopes, expression);
|
|
24
|
+
return binding === undefined ? undefined : { expression, binding, parent };
|
|
25
|
+
}
|
|
26
|
+
export function resolveScopeContext(scenario, expression, current) {
|
|
27
|
+
const upper = expression.toUpperCase();
|
|
28
|
+
if (upper === 'ROOT')
|
|
29
|
+
return rootScopeContext(scenario);
|
|
30
|
+
if (upper === 'THIS')
|
|
31
|
+
return current;
|
|
32
|
+
if (upper === 'PREV')
|
|
33
|
+
return current.parent ?? rootScopeContext(scenario);
|
|
34
|
+
const chained = current.expression === 'ROOT' || current.expression === 'THIS'
|
|
35
|
+
? expression
|
|
36
|
+
: `${current.expression}.${expression}`;
|
|
37
|
+
const chainedBinding = explicitScope(scenario, chained, current);
|
|
38
|
+
if (chainedBinding !== undefined)
|
|
39
|
+
return chainedBinding;
|
|
40
|
+
const direct = explicitScope(scenario, expression, current);
|
|
41
|
+
if (direct !== undefined)
|
|
42
|
+
return direct;
|
|
43
|
+
const targetMatch = /^(?:event_target|scope):(.+)$/iu.exec(expression);
|
|
44
|
+
const targetId = targetMatch === null
|
|
45
|
+
? undefined
|
|
46
|
+
: (caseInsensitiveEntry(current.binding.eventTargets, targetMatch[1]) ??
|
|
47
|
+
caseInsensitiveEntry(scenario.eventTargets, targetMatch[1]));
|
|
48
|
+
if (targetId !== undefined) {
|
|
49
|
+
const declaredBinding = Object.values(scenario.scopes ?? {}).find((binding) => binding.id === targetId || binding.actor === targetId);
|
|
50
|
+
return {
|
|
51
|
+
expression,
|
|
52
|
+
binding: declaredBinding ?? { id: targetId, actor: targetId, state: {} },
|
|
53
|
+
parent: current,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
export function scopeStateValue(scenario, key, current) {
|
|
59
|
+
if (Object.hasOwn(current.binding.state, key))
|
|
60
|
+
return current.binding.state[key];
|
|
61
|
+
if (Object.hasOwn(current.binding.state, `trigger.${key}`))
|
|
62
|
+
return current.binding.state[`trigger.${key}`];
|
|
63
|
+
if (current.expression !== 'ROOT')
|
|
64
|
+
return undefined;
|
|
65
|
+
if (Object.hasOwn(scenario.state, key))
|
|
66
|
+
return scenario.state[key];
|
|
67
|
+
if (Object.hasOwn(scenario.state, `trigger.${key}`))
|
|
68
|
+
return scenario.state[`trigger.${key}`];
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
export function rootStateValue(scenario, key) {
|
|
72
|
+
if (Object.hasOwn(scenario.state, key))
|
|
73
|
+
return scenario.state[key];
|
|
74
|
+
if (Object.hasOwn(scenario.state, `trigger.${key}`))
|
|
75
|
+
return scenario.state[`trigger.${key}`];
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
export function scopeIdentity(current) {
|
|
79
|
+
return current.binding.actor ?? current.binding.id;
|
|
80
|
+
}
|
|
81
|
+
export function scopedExpressionValue(scenario, expression) {
|
|
82
|
+
const scopes = Object.entries(scenario.scopes ?? {}).sort(([left], [right]) => right.length - left.length);
|
|
83
|
+
for (const [scope, binding] of scopes) {
|
|
84
|
+
for (const prefix of [`${scope}.`, `scope.${scope}.`, `scopes.${scope}.`]) {
|
|
85
|
+
if (!expression.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
86
|
+
continue;
|
|
87
|
+
const key = expression.slice(prefix.length).replace(/^state\./u, '');
|
|
88
|
+
if (Object.hasOwn(binding.state, key))
|
|
89
|
+
return binding.state[key];
|
|
90
|
+
if (Object.hasOwn(binding.state, `variable.${key}`))
|
|
91
|
+
return binding.state[`variable.${key}`];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function updateScopeState(scenario, path, value) {
|
|
97
|
+
const scopes = Object.entries(scenario.scopes ?? {}).sort(([left], [right]) => right.length - left.length);
|
|
98
|
+
for (const [scope, binding] of scopes) {
|
|
99
|
+
for (const prefix of [`scope.${scope}.`, `scopes.${scope}.`]) {
|
|
100
|
+
if (!path.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
101
|
+
continue;
|
|
102
|
+
const key = path.slice(prefix.length).replace(/^state\./u, '');
|
|
103
|
+
return {
|
|
104
|
+
...scenario,
|
|
105
|
+
scopes: {
|
|
106
|
+
...scenario.scopes,
|
|
107
|
+
[scope]: { ...binding, state: { ...binding.state, [key]: value } },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
function updatePoolCandidateState(scenario, path, value) {
|
|
115
|
+
if (!path.startsWith('scopePools.'))
|
|
116
|
+
return undefined;
|
|
117
|
+
for (const [poolIndex, pool] of (scenario.scopePools ?? []).entries()) {
|
|
118
|
+
const poolPrefix = `scopePools.${pool.id}.`;
|
|
119
|
+
if (!path.startsWith(poolPrefix))
|
|
120
|
+
continue;
|
|
121
|
+
for (const [candidateIndex, candidate] of pool.candidates.entries()) {
|
|
122
|
+
const candidatePrefix = `${poolPrefix}${candidate.id}.`;
|
|
123
|
+
if (!path.startsWith(candidatePrefix))
|
|
124
|
+
continue;
|
|
125
|
+
const key = path.slice(candidatePrefix.length).replace(/^state\./u, '');
|
|
126
|
+
const candidates = [...pool.candidates];
|
|
127
|
+
candidates[candidateIndex] = {
|
|
128
|
+
...candidate,
|
|
129
|
+
state: { ...candidate.state, [key]: value },
|
|
130
|
+
};
|
|
131
|
+
const pools = [...(scenario.scopePools ?? [])];
|
|
132
|
+
pools[poolIndex] = { ...pool, candidates };
|
|
133
|
+
return { ...scenario, scopePools: pools };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
export function scenarioPathValue(scenario, path) {
|
|
139
|
+
const scoped = scopedExpressionValue(scenario, path);
|
|
140
|
+
if (scoped !== undefined)
|
|
141
|
+
return scoped;
|
|
142
|
+
if (path.startsWith('scopePools.')) {
|
|
143
|
+
for (const pool of scenario.scopePools ?? []) {
|
|
144
|
+
const poolPrefix = `scopePools.${pool.id}.`;
|
|
145
|
+
if (!path.startsWith(poolPrefix))
|
|
146
|
+
continue;
|
|
147
|
+
for (const candidate of pool.candidates) {
|
|
148
|
+
const candidatePrefix = `${poolPrefix}${candidate.id}.`;
|
|
149
|
+
if (!path.startsWith(candidatePrefix))
|
|
150
|
+
continue;
|
|
151
|
+
const key = path.slice(candidatePrefix.length).replace(/^state\./u, '');
|
|
152
|
+
if (Object.hasOwn(candidate.state, key))
|
|
153
|
+
return candidate.state[key];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return rootStateValue(scenario, path);
|
|
158
|
+
}
|
|
159
|
+
export function withScenarioPathValue(scenario, path, value) {
|
|
160
|
+
return (updateScopeState(scenario, path, value) ??
|
|
161
|
+
updatePoolCandidateState(scenario, path, value) ?? {
|
|
162
|
+
...scenario,
|
|
163
|
+
state: { ...scenario.state, [path]: value },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
2
|
+
import { childBlocks, parseClausewitz, } from '../core/source/index.js';
|
|
3
|
+
import { Rational, sumRationals } from './rational.js';
|
|
4
|
+
import { rootScopeContext, scopedExpressionValue, } from './scenario-state.js';
|
|
5
|
+
import { evaluateTriggerBlock } from './trigger-evaluator.js';
|
|
6
|
+
function filterSource(pool) {
|
|
7
|
+
if (pool.filter?.scriptedTrigger !== undefined)
|
|
8
|
+
return `filter = { ${pool.filter.scriptedTrigger} = yes }`;
|
|
9
|
+
const inline = pool.filter?.inlineClausewitz?.trim();
|
|
10
|
+
if (inline === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (/^filter\s*=/iu.test(inline))
|
|
13
|
+
return inline;
|
|
14
|
+
return inline.startsWith('{') ? `filter = ${inline}` : `filter = { ${inline} }`;
|
|
15
|
+
}
|
|
16
|
+
function parseFilter(scenario, pool) {
|
|
17
|
+
const source = filterSource(pool);
|
|
18
|
+
const sourceHash = hashCanonical({ scenarioId: scenario.id, poolId: pool.id, source });
|
|
19
|
+
if (source === undefined)
|
|
20
|
+
return { sourceHash, unresolved: [] };
|
|
21
|
+
const path = `scenario:${scenario.id}/scope-pool:${pool.id}`;
|
|
22
|
+
try {
|
|
23
|
+
const document = parseClausewitz(Buffer.from(source, 'utf8'), path);
|
|
24
|
+
const block = childBlocks(document.root, 'filter')[0];
|
|
25
|
+
if (block !== undefined)
|
|
26
|
+
return { document, block, sourceHash, unresolved: [] };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
sourceHash,
|
|
31
|
+
unresolved: [
|
|
32
|
+
{
|
|
33
|
+
code: 'SCOPE_POOL_FILTER_INVALID',
|
|
34
|
+
message: `Scope pool ${pool.id} filter could not be parsed`,
|
|
35
|
+
path: pool.id,
|
|
36
|
+
details: { reason: error instanceof Error ? error.message : String(error) },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
sourceHash,
|
|
43
|
+
unresolved: [
|
|
44
|
+
{
|
|
45
|
+
code: 'SCOPE_POOL_FILTER_INVALID',
|
|
46
|
+
message: `Scope pool ${pool.id} filter does not contain a trigger block`,
|
|
47
|
+
path: pool.id,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function scopedScenario(scenario, pool, binding) {
|
|
53
|
+
const bindAs = pool.bindAs ?? 'THIS';
|
|
54
|
+
if (bindAs === 'ROOT') {
|
|
55
|
+
const root = {
|
|
56
|
+
...scenario,
|
|
57
|
+
actor: binding.actor ?? binding.id,
|
|
58
|
+
state: binding.state,
|
|
59
|
+
...(binding.flags === undefined ? {} : { flags: binding.flags }),
|
|
60
|
+
...(binding.eventTargets === undefined ? {} : { eventTargets: binding.eventTargets }),
|
|
61
|
+
scopes: { ...scenario.scopes, ROOT: binding, THIS: binding },
|
|
62
|
+
};
|
|
63
|
+
return { scenario: root, context: rootScopeContext(root) };
|
|
64
|
+
}
|
|
65
|
+
const selected = {
|
|
66
|
+
...scenario,
|
|
67
|
+
scopes: {
|
|
68
|
+
...scenario.scopes,
|
|
69
|
+
THIS: binding,
|
|
70
|
+
[bindAs]: binding,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
scenario: selected,
|
|
75
|
+
context: {
|
|
76
|
+
expression: 'THIS',
|
|
77
|
+
binding,
|
|
78
|
+
parent: rootScopeContext(scenario),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function candidateFor(scenario, pool, binding, filter) {
|
|
83
|
+
return {
|
|
84
|
+
id: binding.id,
|
|
85
|
+
adapterId: 'custom_weighted_pool',
|
|
86
|
+
sourceKind: 'dynamic_scope_pool_candidate',
|
|
87
|
+
defaultValue: '1',
|
|
88
|
+
...(filter.document === undefined ? {} : { document: filter.document }),
|
|
89
|
+
provenance: [
|
|
90
|
+
{
|
|
91
|
+
path: `scenario:${scenario.id}/scope-pool:${pool.id}`,
|
|
92
|
+
rootKind: 'scenario',
|
|
93
|
+
loadOrder: 0,
|
|
94
|
+
sourceHash: filter.sourceHash,
|
|
95
|
+
symbol: binding.id,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
metadata: { poolId: pool.id, bindAs: pool.bindAs ?? 'THIS' },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function poolWeight(pool, binding, scenario) {
|
|
102
|
+
if (pool.selection === 'uniform')
|
|
103
|
+
return Rational.one;
|
|
104
|
+
const expression = binding.weight ?? 1;
|
|
105
|
+
const direct = Rational.parse(expression);
|
|
106
|
+
if (direct !== undefined)
|
|
107
|
+
return direct.compare(Rational.zero) < 0 ? undefined : direct;
|
|
108
|
+
if (typeof expression !== 'string')
|
|
109
|
+
return undefined;
|
|
110
|
+
const value = binding.state[expression] ??
|
|
111
|
+
binding.state[`variable.${expression}`] ??
|
|
112
|
+
scopedExpressionValue(scenario, expression) ??
|
|
113
|
+
scenario.state[expression] ??
|
|
114
|
+
scenario.state[`variable.${expression}`];
|
|
115
|
+
const parsed = typeof value === 'number' || typeof value === 'string' ? Rational.parse(value) : undefined;
|
|
116
|
+
return parsed !== undefined && parsed.compare(Rational.zero) >= 0 ? parsed : undefined;
|
|
117
|
+
}
|
|
118
|
+
function evaluatePool(scenario, pool, definitions) {
|
|
119
|
+
const filter = parseFilter(scenario, pool);
|
|
120
|
+
const candidates = [...pool.candidates]
|
|
121
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id))
|
|
122
|
+
.map((binding) => {
|
|
123
|
+
const selected = scopedScenario(scenario, pool, binding);
|
|
124
|
+
const candidate = candidateFor(scenario, pool, binding, filter);
|
|
125
|
+
const eligibility = filter.unresolved.length > 0
|
|
126
|
+
? { state: 'unresolved', unresolved: filter.unresolved }
|
|
127
|
+
: evaluateTriggerBlock(filter.block, selected.scenario, candidate, definitions, [], selected.context);
|
|
128
|
+
const unresolved = [...eligibility.unresolved];
|
|
129
|
+
const weight = pool.selection === 'enumeration' ? undefined : poolWeight(pool, binding, selected.scenario);
|
|
130
|
+
if (pool.selection !== 'enumeration' && weight === undefined)
|
|
131
|
+
unresolved.push({
|
|
132
|
+
code: 'SCOPE_POOL_WEIGHT_UNRESOLVED',
|
|
133
|
+
message: `Scope pool ${pool.id} candidate ${binding.id} has an unresolved weight`,
|
|
134
|
+
path: pool.id,
|
|
135
|
+
candidateId: binding.id,
|
|
136
|
+
details: { weight: binding.weight ?? 1 },
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
id: binding.id,
|
|
140
|
+
eligibility: eligibility.state,
|
|
141
|
+
...(weight === undefined ? {} : { weight: weight.toJSON() }),
|
|
142
|
+
trace: eligibility.trace ?? [],
|
|
143
|
+
unresolved,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const unresolved = [
|
|
147
|
+
...filter.unresolved,
|
|
148
|
+
...candidates.flatMap(({ unresolved: items }) => items),
|
|
149
|
+
];
|
|
150
|
+
if (pool.selection !== 'enumeration' && !pool.complete)
|
|
151
|
+
unresolved.push({
|
|
152
|
+
code: 'SCOPE_POOL_INCOMPLETE',
|
|
153
|
+
message: `Scope pool ${pool.id} can be enumerated, but normalized probabilities require a complete candidate catalog`,
|
|
154
|
+
path: pool.id,
|
|
155
|
+
});
|
|
156
|
+
const resolved = candidates.every(({ eligibility, weight }) => eligibility !== 'unresolved' && (pool.selection === 'enumeration' || weight !== undefined));
|
|
157
|
+
const eligibleWeights = candidates.flatMap((candidate) => candidate.eligibility === 'true' && candidate.weight !== undefined
|
|
158
|
+
? [Rational.parse(candidate.weight.decimal)]
|
|
159
|
+
: []);
|
|
160
|
+
const total = sumRationals(eligibleWeights);
|
|
161
|
+
if (pool.selection !== 'enumeration' &&
|
|
162
|
+
pool.complete &&
|
|
163
|
+
resolved &&
|
|
164
|
+
total.compare(Rational.zero) > 0) {
|
|
165
|
+
for (const candidate of candidates) {
|
|
166
|
+
const probability = candidate.eligibility === 'true' && candidate.weight !== undefined
|
|
167
|
+
? Rational.parse(candidate.weight.decimal).divide(total)
|
|
168
|
+
: Rational.zero;
|
|
169
|
+
candidate.conditionalProbability = probability.toNumber();
|
|
170
|
+
candidate.exactConditionalProbability = probability.toJSON();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (pool.selection !== 'enumeration' && pool.complete && resolved && total.isZero())
|
|
174
|
+
unresolved.push({
|
|
175
|
+
code: 'SCOPE_POOL_ZERO_TOTAL',
|
|
176
|
+
message: `Scope pool ${pool.id} has no positive eligible weight`,
|
|
177
|
+
path: pool.id,
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
id: pool.id,
|
|
181
|
+
bindAs: pool.bindAs ?? 'THIS',
|
|
182
|
+
selection: pool.selection,
|
|
183
|
+
poolComplete: pool.complete,
|
|
184
|
+
candidates,
|
|
185
|
+
eligibleCandidateIds: candidates
|
|
186
|
+
.filter(({ eligibility }) => eligibility === 'true')
|
|
187
|
+
.map(({ id }) => id),
|
|
188
|
+
unresolvedCandidateIds: candidates
|
|
189
|
+
.filter(({ eligibility, unresolved: items }) => eligibility === 'unresolved' || items.length > 0)
|
|
190
|
+
.map(({ id }) => id),
|
|
191
|
+
...(pool.selection !== 'enumeration' && resolved ? { poolTotal: total.toJSON() } : {}),
|
|
192
|
+
unresolved,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function evaluateScopePools(scenario, definitions) {
|
|
196
|
+
return [...(scenario.scopePools ?? [])]
|
|
197
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id))
|
|
198
|
+
.map((pool) => evaluatePool(scenario, pool, definitions));
|
|
199
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withScenarioPathValue } from './scenario-state.js';
|
|
1
2
|
import { evaluateExactCandidates } from './evaluation.js';
|
|
2
3
|
function recordUnresolved(unresolved, issue) {
|
|
3
4
|
if (unresolved.some((existing) => existing.code === issue.code &&
|
|
@@ -222,7 +223,7 @@ function continuousSamples(scenario, samples, samplingMethod, random, unresolved
|
|
|
222
223
|
}
|
|
223
224
|
function sampledScenario(scenario, continuous, random, unresolved) {
|
|
224
225
|
const inputs = scenario.uncertainInputs ?? [];
|
|
225
|
-
|
|
226
|
+
let sampled = { ...scenario, state: { ...scenario.state } };
|
|
226
227
|
for (const input of inputs) {
|
|
227
228
|
let value;
|
|
228
229
|
const plannedUniform = continuous.get(input.path);
|
|
@@ -244,9 +245,9 @@ function sampledScenario(scenario, continuous, random, unresolved) {
|
|
|
244
245
|
});
|
|
245
246
|
}
|
|
246
247
|
else
|
|
247
|
-
|
|
248
|
+
sampled = withScenarioPathValue(sampled, input.path, value);
|
|
248
249
|
}
|
|
249
|
-
return { ...
|
|
250
|
+
return { ...sampled, uncertainInputs: [] };
|
|
250
251
|
}
|
|
251
252
|
function quantileForConfidence(confidence) {
|
|
252
253
|
return inverseNormal(0.5 + confidence / 2);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
import { conditionBreakpointValues, evaluateSurfaceScenarios } from './evaluation.js';
|
|
3
|
+
import { scenarioPathValue, withScenarioPathValue } from './scenario-state.js';
|
|
3
4
|
const MAX_SWEEP_POINTS = 250_000;
|
|
4
5
|
function declaredValues(surface, scenario, path, steps) {
|
|
5
6
|
const uncertain = scenario.uncertainInputs?.find((input) => input.path === path);
|
|
@@ -15,7 +16,7 @@ function declaredValues(surface, scenario, path, steps) {
|
|
|
15
16
|
if (values.length > 0)
|
|
16
17
|
return [...new Set(values)].sort((left, right) => left - right);
|
|
17
18
|
}
|
|
18
|
-
const current = scenario
|
|
19
|
+
const current = scenarioPathValue(scenario, path);
|
|
19
20
|
if (typeof current === 'number')
|
|
20
21
|
return [current];
|
|
21
22
|
throw new ServiceError('PROBABILITY_SWEEP_RANGE_REQUIRED', 'Every sweep path requires a scenario range, numeric alternatives, or numeric state value', { scenarioId: scenario.id, path });
|
|
@@ -26,7 +27,10 @@ function values(range, steps) {
|
|
|
26
27
|
return Array.from({ length: steps }, (_, index) => range.min + ((range.max - range.min) * index) / (steps - 1));
|
|
27
28
|
}
|
|
28
29
|
function point(surface, scenario, definitions, path, value, assigned) {
|
|
29
|
-
const
|
|
30
|
+
const base = { ...scenario, uncertainInputs: [] };
|
|
31
|
+
const evaluated = evaluateSurfaceScenarios(surface, [
|
|
32
|
+
Object.entries(assigned).reduce((updated, [assignedPath, assignedValue]) => withScenarioPathValue(updated, assignedPath, assignedValue), base),
|
|
33
|
+
], definitions)[0];
|
|
30
34
|
return {
|
|
31
35
|
scenarioId: scenario.id,
|
|
32
36
|
path,
|