hoi4-agent-tools 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -1
  4. package/dist/hoi4_agent_tools/mcp/server/progress.js +57 -0
  5. package/dist/hoi4_agent_tools/mcp/tools/gui.js +15 -15
  6. package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -0
  7. package/dist/hoi4_agent_tools/probability/adapters.js +5 -0
  8. package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
  9. package/dist/hoi4_agent_tools/probability/scenario-state.js +165 -0
  10. package/dist/hoi4_agent_tools/probability/scope-pools.js +199 -0
  11. package/dist/hoi4_agent_tools/probability/simulation.js +4 -3
  12. package/dist/hoi4_agent_tools/probability/sweep.js +6 -2
  13. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +35 -28
  14. package/dist/hoi4_agent_tools/probability/value-evaluator.js +4 -0
  15. package/dist/hoi4_agent_tools/schemas/probability.js +90 -0
  16. package/dist/hoi4_agent_tools/version.js +1 -1
  17. package/docs/gui.md +2 -0
  18. package/docs/probability.md +46 -0
  19. package/docs/research/probability-adapter-evidence.md +2 -1
  20. package/examples/probability/scoped-dynamic-pools-evaluate-input.json +50 -0
  21. package/package.json +1 -1
  22. package/schemas/configuration.schema.json +1 -1
  23. package/schemas/continuous-focus-palette.schema.json +1 -1
  24. package/schemas/custom-weighted-pool.schema.json +1 -1
  25. package/schemas/event-feature-manifest.schema.json +1 -1
  26. package/schemas/focus-plan.schema.json +1 -1
  27. package/schemas/focus-planning-sidecar.schema.json +1 -1
  28. package/schemas/gui-animation-source.schema.json +1 -1
  29. package/schemas/gui-helper.schema.json +1 -1
  30. package/schemas/gui-scenario.schema.json +1 -1
  31. package/schemas/map-operation.schema.json +1 -1
  32. package/schemas/operation-result.schema.json +1 -1
  33. package/schemas/probability-analysis-result.schema.json +175 -66
  34. package/schemas/probability-compare-input.schema.json +251 -66
  35. package/schemas/probability-evaluate-input.schema.json +252 -67
  36. package/schemas/probability-inspect-input.schema.json +1 -1
  37. package/schemas/probability-render-input.schema.json +1 -1
  38. package/schemas/probability-scenario-set.schema.json +231 -46
  39. package/schemas/probability-sequence-input.schema.json +273 -88
  40. package/schemas/probability-simulate-input.schema.json +252 -67
  41. package/schemas/probability-sweep-input.schema.json +254 -69
  42. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.8.1 - 2026-08-27
8
+
9
+ - Added periodic MCP progress heartbeats for long GUI inspections, renders, and rewrites, with repeated progress notifications that preserve monotonic work values and still honor cancellation.
10
+
11
+ ## 2.8.0 - 2026-08-26
12
+
13
+ - Added structured `ROOT`, `THIS`, `PREV`, `FROM` chain, saved-scope, and event-target bindings to probability scenarios.
14
+ - Added deterministic enumeration and exact complete-pool shares for declared dynamic destination, opposition, donor, and other runtime scope pools.
15
+ - Extended exact branching, sweeps, and simulation to update scoped and pool-candidate state without flattening it into the root scenario.
16
+ - Added MCP resource counts, a published callable example, 2,048-candidate coverage, and source-linked trigger traces for dynamic scope pools.
17
+
7
18
  ## 2.7.0 - 2026-08-24
8
19
 
9
20
  - 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
 
@@ -8,7 +8,7 @@ import { registerEventTools } from '../tools/event.js';
8
8
  import { registerTechnologyTools } from '../tools/technology.js';
9
9
  import { registerProbabilityTools } from '../tools/probability.js';
10
10
  import { registerMcpResources } from '../resources/register.js';
11
- export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Large evidence is linked as resources.';
11
+ export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Long GUI calls emit periodic progress heartbeats when the client requests progress. Large evidence is linked as resources.';
12
12
  export function createMcpServer(engine, context = {}) {
13
13
  const server = new McpServer({
14
14
  name: PACKAGE_NAME,
@@ -1,9 +1,18 @@
1
+ /**
2
+ * Interval used for long-running MCP operations. It is shorter than the request timeout used by
3
+ * common MCP clients, while still keeping notification traffic negligible for ordinary calls.
4
+ */
5
+ export const PROGRESS_HEARTBEAT_INTERVAL_MS = 10_000;
1
6
  export function progressReporter(extra) {
2
7
  let latest = Number.NEGATIVE_INFINITY;
8
+ let latestTotal = 1;
9
+ let latestMessage = '';
3
10
  return {
4
11
  signal: extra.signal,
5
12
  async report(progress, total, message) {
6
13
  extra.signal.throwIfAborted();
14
+ latestTotal = total;
15
+ latestMessage = message;
7
16
  const normalized = Math.max(latest, Math.min(progress, total));
8
17
  const progressToken = extra._meta?.progressToken;
9
18
  if (progressToken === undefined)
@@ -16,5 +25,53 @@ export function progressReporter(extra) {
16
25
  params: { progressToken, progress: normalized, total, message },
17
26
  });
18
27
  },
28
+ async pulse(message = latestMessage) {
29
+ extra.signal.throwIfAborted();
30
+ const progressToken = extra._meta?.progressToken;
31
+ if (progressToken === undefined)
32
+ return;
33
+ await extra.sendNotification({
34
+ method: 'notifications/progress',
35
+ params: {
36
+ progressToken,
37
+ progress: Number.isFinite(latest) ? latest : 0,
38
+ total: latestTotal,
39
+ message,
40
+ },
41
+ });
42
+ },
43
+ };
44
+ }
45
+ /**
46
+ * Keep an MCP request alive while a domain service is in a long, opaque asynchronous stage.
47
+ * Progress notifications are best-effort: a disconnected client must not turn a successful
48
+ * server operation into a partial result, while the request's AbortSignal still cancels work.
49
+ */
50
+ export async function withProgressHeartbeat(operation, reporter, message, intervalMs = PROGRESS_HEARTBEAT_INTERVAL_MS) {
51
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
52
+ throw new RangeError('Progress heartbeat interval must be a positive finite number');
53
+ }
54
+ reporter.signal.throwIfAborted();
55
+ let active = true;
56
+ let inFlight = false;
57
+ const tick = () => {
58
+ if (!active || inFlight || reporter.signal.aborted)
59
+ return;
60
+ inFlight = true;
61
+ void reporter
62
+ .pulse(message)
63
+ .catch(() => undefined)
64
+ .finally(() => {
65
+ inFlight = false;
66
+ });
19
67
  };
68
+ const timer = setInterval(tick, intervalMs);
69
+ timer.unref();
70
+ try {
71
+ return await (typeof operation === 'function' ? operation() : operation);
72
+ }
73
+ finally {
74
+ active = false;
75
+ clearInterval(timer);
76
+ }
20
77
  }
@@ -12,7 +12,7 @@ import { requireServerScope, resolveServerWorkspaceId, } from '../server/base-to
12
12
  import { compactValidatedInputSchema } from '../server/context-schemas.js';
13
13
  import { autonomousFailureContext, autonomousResultArtifacts, executePlannedTransaction, } from '../server/transaction-execution.js';
14
14
  import { indexSkippedSourceSchema, nonNegativeIntegerSchema, sha256Schema, } from '../server/output-schemas.js';
15
- import { progressReporter } from '../server/progress.js';
15
+ import { progressReporter, withProgressHeartbeat } from '../server/progress.js';
16
16
  import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
17
17
  const countRecordSchema = z.record(z.string().max(256), nonNegativeIntegerSchema);
18
18
  const guiScanOutputSchema = strictOperationResultSchema(z
@@ -156,21 +156,21 @@ export function registerGuiTools(server, engine, context) {
156
156
  try {
157
157
  const progress = progressReporter(extra);
158
158
  await progress.report(0, 3, 'Building GUI source graph');
159
- const inspection = windowName === undefined || scenario === undefined
160
- ? studio
161
- .scan(workspaceId, context.principal, progress.signal)
162
- .then(({ graph }) => ({ graph, linted: undefined }))
163
- : studio
164
- .lint({
159
+ const inspected = await withProgressHeartbeat(async () => {
160
+ if (windowName === undefined || scenario === undefined) {
161
+ const { graph } = await studio.scan(workspaceId, context.principal, progress.signal);
162
+ return { graph, linted: undefined };
163
+ }
164
+ const linted = await studio.lint({
165
165
  workspaceId,
166
166
  windowName,
167
167
  scenario,
168
168
  ...(relatedScenarios === undefined ? {} : { relatedScenarios }),
169
169
  ...(context.principal === undefined ? {} : { principal: context.principal }),
170
170
  signal: progress.signal,
171
- })
172
- .then((linted) => ({ graph: linted.graph, linted }));
173
- const inspected = await inspection;
171
+ });
172
+ return { graph: linted.graph, linted };
173
+ }, progress, 'Inspecting GUI sources and linked assets');
174
174
  const { graph, linted } = inspected;
175
175
  const sharedRevision = hashCanonical(graph.sourceHashes);
176
176
  const workspace = engine.resolver.get(workspaceId, context.principal);
@@ -268,7 +268,7 @@ export function registerGuiTools(server, engine, context) {
268
268
  try {
269
269
  const progress = progressReporter(extra);
270
270
  await progress.report(0, 4, 'Building GUI source graph');
271
- const rendered = await studio.renderAndStore({
271
+ const rendered = await withProgressHeartbeat(() => studio.renderAndStore({
272
272
  workspaceId,
273
273
  windowName,
274
274
  scenario: input.scenario,
@@ -290,7 +290,7 @@ export function registerGuiTools(server, engine, context) {
290
290
  : { comparisonScenario: input.comparisonScenario }),
291
291
  ...(context.principal === undefined ? {} : { principal: context.principal }),
292
292
  signal: progress.signal,
293
- });
293
+ }), progress, 'Rendering GUI variants and visual diagnostics');
294
294
  await progress.report(3, 4, 'Preparing GUI result');
295
295
  const diagnostics = rendered.validation.diagnostics;
296
296
  const result = emptyServiceResult(workspaceId, {
@@ -471,7 +471,7 @@ export function registerGuiTools(server, engine, context) {
471
471
  const progress = progressReporter(extra);
472
472
  await progress.report(0, 3, 'Validating the requested GUI result');
473
473
  const compilation = input.mode === 'helpers' ? compileGuiHelpers(input.helper) : undefined;
474
- const plannedTransaction = await studio.planSource({
474
+ const plannedTransaction = await withProgressHeartbeat(() => studio.planSource({
475
475
  workspaceId,
476
476
  relativePath,
477
477
  ...(input.mode === 'patches'
@@ -489,9 +489,9 @@ export function registerGuiTools(server, engine, context) {
489
489
  scenario,
490
490
  ...(context.principal === undefined ? {} : { principal: context.principal }),
491
491
  signal: progress.signal,
492
- });
492
+ }), progress, 'Preparing GUI rewrite');
493
493
  await progress.report(2, 3, 'Applying and validating the GUI rewrite');
494
- const execution = await executePlannedTransaction(engine, plannedTransaction, context.principal, progress.signal);
494
+ const execution = await withProgressHeartbeat(() => executePlannedTransaction(engine, plannedTransaction, context.principal, progress.signal), progress, 'Applying GUI rewrite');
495
495
  const transaction = execution.transaction;
496
496
  const planned = {
497
497
  transaction,
@@ -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) => ({ ...branch, state: { ...branch.state, [input.path]: 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
- const state = { ...scenario.state };
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
- state[input.path] = value;
248
+ sampled = withScenarioPathValue(sampled, input.path, value);
248
249
  }
249
- return { ...scenario, state, uncertainInputs: [] };
250
+ return { ...sampled, uncertainInputs: [] };
250
251
  }
251
252
  function quantileForConfidence(confidence) {
252
253
  return inverseNormal(0.5 + confidence / 2);