hoi4-agent-tools 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -0
  3. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +11 -1
  4. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -9
  5. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
  7. package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  9. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  10. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  11. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  12. package/dist/hoi4_agent_tools/event/service.js +21 -2
  13. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  14. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  15. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  16. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  17. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  18. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  19. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  20. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  23. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  24. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  25. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  26. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  27. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  28. package/dist/hoi4_agent_tools/map/render.js +70 -1
  29. package/dist/hoi4_agent_tools/map/service.js +30 -6
  30. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
  33. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
  34. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  35. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  36. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  37. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  38. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  39. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  40. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  41. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  42. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  43. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  44. package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
  45. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  46. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  47. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  48. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  49. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  50. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  51. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  52. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  53. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  54. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  55. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  56. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  57. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  58. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  59. package/dist/hoi4_agent_tools/version.js +1 -1
  60. package/docs/README.md +1 -0
  61. package/docs/events.md +3 -1
  62. package/docs/focus.md +10 -0
  63. package/docs/gui.md +49 -3
  64. package/docs/map.md +8 -0
  65. package/docs/mechanics.md +70 -0
  66. package/docs/probability.md +3 -1
  67. package/docs/technology.md +7 -2
  68. package/package.json +2 -1
  69. package/schemas/configuration.schema.json +1 -1
  70. package/schemas/continuous-focus-palette.schema.json +1 -1
  71. package/schemas/custom-weighted-pool.schema.json +1 -1
  72. package/schemas/decision-inspect-input.schema.json +1 -1
  73. package/schemas/event-feature-manifest.schema.json +1 -1
  74. package/schemas/focus-plan.schema.json +1 -1
  75. package/schemas/focus-planning-sidecar.schema.json +1 -1
  76. package/schemas/gui-animation-source.schema.json +1 -1
  77. package/schemas/gui-helper.schema.json +1 -1
  78. package/schemas/gui-scenario.schema.json +1 -1
  79. package/schemas/helper-expansion-request.schema.json +1 -1
  80. package/schemas/helper-expansion-summary.schema.json +1 -1
  81. package/schemas/impact-inspect-input.schema.json +1 -1
  82. package/schemas/map-operation.schema.json +1 -1
  83. package/schemas/mechanic-test-input.schema.json +650 -0
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +164 -0
  86. package/schemas/package-manifest.schema.json +142 -0
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/scenario-suite.schema.json +1004 -0
  97. package/schemas/scenario-test-input.schema.json +1057 -0
  98. package/schemas/shared-scenario.schema.json +237 -0
  99. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.4.0 - 2026-09-20
6
+
7
+ - Add branch-local focus cleanup with pinned anchors and symmetry groups, connector measurements, source-linked crops, and scenario-backed structural route diagnostics.
8
+ - Report GUI visibility and enabled branches, measured label and background boxes, and matched source-baseline renders alongside scenario comparisons.
9
+ - Expose complete source-backed probability catalogs, actionable missing scenario inputs, and eligible, excluded, and unresolved inventories.
10
+ - Add selected event-chain comparisons and revision-bound bounded helper expansion while retaining explicit lifecycle coverage limits.
11
+ - Compose technology folders from source pixel placements, native item skins, icon and subtechnology positions, panel and background artwork, year labels, and supplied research scenarios; report unresolved placement and asset coverage honestly.
12
+ - Resolve source `GFX_*_medium` artwork and design-team overlays at native item size for chemical and biological warfare folders.
13
+ - Compose installed GUI backgrounds, focus inlays, container scrolling, native button and progress shaders, and scenario-driven rows from source assets and control states.
14
+ - Bound focus connector and overlap diagnostics on anchored trees with thousands of nodes.
15
+ - Add focused map lookups, reusable render tiles, affected-area output, and typed external script-reference updates for map renumbering.
16
+
17
+ ## 3.3.0 - 2026-09-20
18
+
19
+ - Add `hoi4.mechanic_test` with copied versioned scenario state, bounded source effects, explicit time advancement, traces, and typed gameplay assertions.
20
+ - Add `hoi4.package_check` for declarative package definitions, calls, registrations, localisation, assets, and required suite-case references.
21
+ - Add `hoi4.scenario_test` for named inline or workspace-owned suites through fixed read-only operations, case artifacts, and authenticated revision-bound continuation.
22
+ - Share declared condition state across GUI and probability adapters while preserving their public inputs and specialized evaluation semantics.
23
+
5
24
  ## 3.2.0 - 2026-09-20
6
25
 
7
26
  - Add read-only `hoi4.impact_inspect` with active and overridden definitions, direct and transitive source consumers, state-key access roles, cycle and coverage evidence, and in-memory source comparisons.
package/README.md CHANGED
@@ -74,6 +74,9 @@ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repo
74
74
  | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
75
75
  | `hoi4.impact_inspect` | Trace symbol and changed-file consumers across source systems and compare proposals. |
76
76
  | `hoi4.decision_inspect` | Inventory and evaluate decisions or missions under declared scenarios and source changes. |
77
+ | `hoi4.mechanic_test` | Execute bounded source effects on a copied declared scenario and check assertions. |
78
+ | `hoi4.package_check` | Check declarative package definitions, calls, registrations, assets, and case links. |
79
+ | `hoi4.scenario_test` | Run named source and domain cases in resumable, revision-bound batches. |
77
80
  | `hoi4.job_inspect` | Inspect durable background work or retrieve its completed tool result. |
78
81
  | `hoi4.job_cancel` | Durably request cancellation of authorized background work. |
79
82
  | `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
@@ -108,6 +111,7 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
108
111
  - Maps: "Render the whole map and find this state by name," "Create a state from these provinces," "Create a province inside this exact rectangle," or "Swap these state IDs and update connected references." See [Maps](docs/map.md).
109
112
  - Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
110
113
  - Cross-system impact and decisions: "Find every direct and transitive consumer of this idea," or "Compare who can take this decision, what they pay, and its mission end paths for these actor and target scenarios." See [Cross-system impact and decisions](docs/analysis.md).
114
+ - Mechanics and packages: "Test this transfer's conservation and payment," or "Check every declared package link and resume its named scenario suite." See [Mechanic tests and scenario suites](docs/mechanics.md).
111
115
  - Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
112
116
  - AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
113
117
 
@@ -1,14 +1,21 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { DecisionAnalyzer } from '../decision/service.js';
3
3
  import { ImpactAnalyzer } from '../impact/service.js';
4
+ import { MechanicAnalyzer } from '../mechanic/service.js';
5
+ import { PackageAnalyzer } from '../package-check/service.js';
6
+ import { ScenarioAnalyzer } from '../scenario-suite/service.js';
4
7
  import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
8
+ import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
5
9
  import { errorResult, toolResult } from './operation-result.js';
6
10
  import { ServiceError } from './result.js';
7
11
  const wireResult = z.record(z.string(), z.json());
8
- /** Registers the two read-only analysis services for durable ordinary and native tasks. */
12
+ /** Registers read-only analysis services for durable ordinary and native tasks. */
9
13
  export function registerAnalysisJobs(operations, engine) {
10
14
  const impact = new ImpactAnalyzer(engine);
11
15
  const decision = new DecisionAnalyzer(engine);
16
+ const mechanic = new MechanicAnalyzer(engine);
17
+ const packages = new PackageAnalyzer(engine);
18
+ const scenarios = new ScenarioAnalyzer(engine);
12
19
  const register = (name, schema, execute, progressMessage) => {
13
20
  operations.registerRead(name, schema, async (input, context) => {
14
21
  try {
@@ -35,4 +42,7 @@ export function registerAnalysisJobs(operations, engine) {
35
42
  };
36
43
  register('hoi4.impact_inspect', impactInspectRequestSchema, (input) => impact.inspect(input), 'Tracing cross-system impact');
37
44
  register('hoi4.decision_inspect', decisionInspectRequestSchema, (input) => decision.inspect(input), 'Analyzing decisions and missions');
45
+ register('hoi4.mechanic_test', mechanicTestRequestSchema, (input) => mechanic.test(input), 'Testing declared mechanic steps');
46
+ register('hoi4.package_check', packageCheckRequestSchema, (input) => packages.check(input), 'Checking package connections');
47
+ register('hoi4.scenario_test', scenarioTestRequestSchema, (input) => scenarios.test(input), 'Running a bounded scenario suite');
38
48
  }
@@ -96,7 +96,7 @@ function numberValue(expression, scenario, context, definitions, path, seen = ne
96
96
  }
97
97
  return undefined;
98
98
  }
99
- function unresolved(assignment, candidate, reason) {
99
+ function unresolved(assignment, candidate, reason, scenarioInput) {
100
100
  return {
101
101
  state: 'unresolved',
102
102
  unresolved: [
@@ -104,6 +104,7 @@ function unresolved(assignment, candidate, reason) {
104
104
  code: 'TRIGGER_UNRESOLVED',
105
105
  message: reason,
106
106
  path: assignment.key.value,
107
+ ...(scenarioInput === undefined ? {} : { details: { scenarioInput } }),
107
108
  candidateId: candidate.id,
108
109
  ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
109
110
  },
@@ -136,7 +137,11 @@ function evaluateCheckVariable(block, scenario, candidate, assignment, scopeCont
136
137
  const leftNumber = numberValue(variable, scenario, scopeContext, definitions, path);
137
138
  const rightNumber = numberValue(target, scenario, scopeContext, definitions, path);
138
139
  if (leftNumber === undefined || rightNumber === undefined)
139
- return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`);
140
+ return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`, leftNumber === undefined && !/^(?:@|constant:)/u.test(variable)
141
+ ? `variable.${variable}`
142
+ : rightNumber === undefined && !/^(?:@|constant:)/u.test(target)
143
+ ? `variable.${target}`
144
+ : undefined);
140
145
  const compareName = firstScalar(block, 'compare')?.value ??
141
146
  (short?.operator.text === '>'
142
147
  ? 'greater_than'
@@ -202,7 +207,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
202
207
  const array = scopeStateValue(scenario, `array.${arrayId}`, scopeContext) ??
203
208
  rootStateValue(scenario, `array.${arrayId}`);
204
209
  if (!Array.isArray(array))
205
- return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`);
210
+ return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`, `array.${arrayId}`);
206
211
  const compared = ['THIS', 'ROOT', 'PREV'].includes(declaredValue.toUpperCase()) ||
207
212
  resolveScopeContext(scenario, declaredValue, scopeContext) !== undefined
208
213
  ? scopeIdentity(resolveScopeContext(scenario, declaredValue, scopeContext) ?? scopeContext)
@@ -244,7 +249,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
244
249
  const declaredScope = bool(rootStateValue(scenario, `scope.${key}`));
245
250
  if (declaredScope !== undefined)
246
251
  return { state: declaredScope ? 'true' : 'false', unresolved: [] };
247
- return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`);
252
+ return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`, `scope.${key}`);
248
253
  }
249
254
  const right = assignment.value.value;
250
255
  if (key === 'always') {
@@ -257,9 +262,13 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
257
262
  const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
258
263
  if (explicit !== undefined)
259
264
  return { state: explicit ? 'true' : 'false', unresolved: [] };
260
- const flags = scopeContext.expression === 'ROOT' ? scenario.flags : scopeContext.binding.flags;
265
+ const flags = key === 'has_global_flag' && scenario.globalFlags !== undefined
266
+ ? scenario.globalFlags
267
+ : scopeContext.expression === 'ROOT'
268
+ ? scenario.flags
269
+ : scopeContext.binding.flags;
261
270
  if (scenario.closedFlags === false && flags?.includes(right) !== true)
262
- return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
271
+ return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`, `flag.${right}`);
263
272
  return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
264
273
  }
265
274
  if (key === 'has_event_target') {
@@ -274,7 +283,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
274
283
  ? scenario.actor
275
284
  : (scopeContext.binding.actor ?? scopeContext.binding.id);
276
285
  if (actor === undefined)
277
- return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`);
286
+ return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`, 'actor');
278
287
  return {
279
288
  state: compare(actor, right, assignment.operator.text) ? 'true' : 'false',
280
289
  unresolved: [],
@@ -282,7 +291,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
282
291
  }
283
292
  if (key === 'date') {
284
293
  if (scenario.date === undefined)
285
- return unresolved(assignment, candidate, 'Scenario does not declare date');
294
+ return unresolved(assignment, candidate, 'Scenario does not declare date', 'date');
286
295
  const dateNumber = (value) => {
287
296
  const parts = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/u.exec(value);
288
297
  if (parts === null)
@@ -332,7 +341,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
332
341
  const declared = scopeStateValue(scenario, key, scopeContext) ??
333
342
  numberValue(key, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
334
343
  if (declared === undefined)
335
- return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`);
344
+ return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`, key);
336
345
  const expectedBoolean = bool(right);
337
346
  const actualBoolean = bool(declared);
338
347
  if (expectedBoolean !== undefined && actualBoolean !== undefined)
@@ -104,6 +104,9 @@ export async function inspectHelperExpansion(engine, workspaceId, inventory, req
104
104
  kind,
105
105
  rootId: input.roots[root],
106
106
  path: path.map((index) => inventory.edges[index].id),
107
+ ...(inventory.recordDetails === undefined
108
+ ? {}
109
+ : inventory.recordDetails(path.map((index) => inventory.edges[index]))),
107
110
  }));
108
111
  const continuationName = `${inventory.domain}-helper-continuation.json`;
109
112
  const report = {
@@ -39,6 +39,9 @@ export class JobWorkerHost {
39
39
  if (![
40
40
  'hoi4.impact_inspect',
41
41
  'hoi4.decision_inspect',
42
+ 'hoi4.mechanic_test',
43
+ 'hoi4.package_check',
44
+ 'hoi4.scenario_test',
42
45
  'hoi4.event_inspect',
43
46
  'hoi4.event_render',
44
47
  'hoi4.event_compare',
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
3
+ import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
3
4
  import { eventCompareRequestSchema, eventInspectRequestSchema, eventRenderRequestSchema, } from '../schemas/event.js';
4
5
  import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
5
6
  import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
@@ -28,6 +29,9 @@ export const operationTaskCallSchema = z
28
29
  name: z.enum([
29
30
  'hoi4.impact_inspect',
30
31
  'hoi4.decision_inspect',
32
+ 'hoi4.mechanic_test',
33
+ 'hoi4.package_check',
34
+ 'hoi4.scenario_test',
31
35
  'hoi4.event_inspect',
32
36
  'hoi4.event_render',
33
37
  'hoi4.event_compare',
@@ -58,6 +62,9 @@ export const operationTaskCallSchema = z
58
62
  const taskArgumentSchemas = {
59
63
  'hoi4.impact_inspect': impactInspectRequestSchema,
60
64
  'hoi4.decision_inspect': decisionInspectRequestSchema,
65
+ 'hoi4.mechanic_test': mechanicTestRequestSchema,
66
+ 'hoi4.package_check': packageCheckRequestSchema,
67
+ 'hoi4.scenario_test': scenarioTestRequestSchema,
61
68
  'hoi4.event_inspect': eventInspectRequestSchema,
62
69
  'hoi4.event_render': eventRenderRequestSchema,
63
70
  'hoi4.event_compare': eventCompareRequestSchema,
@@ -0,0 +1,85 @@
1
+ export function copyScenario(scenario) {
2
+ return {
3
+ scenario: structuredClone(scenario),
4
+ unknown: new Set(),
5
+ day: 0,
6
+ };
7
+ }
8
+ export function scenarioValue(state, key, scope = 'ROOT') {
9
+ if (state.unknown.has('*') || state.unknown.has(`${scope}.${key}`))
10
+ return undefined;
11
+ if (scope === 'ROOT' || scope === 'THIS')
12
+ return state.scenario.state[key];
13
+ return state.scenario.scopes?.[scope]?.state[key];
14
+ }
15
+ export function setScenarioValue(state, key, value, scope = 'ROOT') {
16
+ const identity = `${scope}.${key}`;
17
+ if (value === undefined)
18
+ state.unknown.add(identity);
19
+ else {
20
+ state.unknown.delete(identity);
21
+ if (scope === 'ROOT' || scope === 'THIS')
22
+ state.scenario.state[key] = value;
23
+ else if (state.scenario.scopes?.[scope] !== undefined)
24
+ state.scenario.scopes[scope].state[key] = value;
25
+ }
26
+ }
27
+ /** Preserve the GUI's existing input precedence and exploratory flag semantics. */
28
+ export function scenarioFromGui(base, values = base.values) {
29
+ const state = {
30
+ ...base.stateValues,
31
+ ...base.variables,
32
+ ...values,
33
+ };
34
+ for (const [key, value] of Object.entries(base.flags))
35
+ state[`flag.${key}`] = value;
36
+ const scopes = structuredClone(base.scopes ?? {});
37
+ for (const [key, value] of Object.entries(state)) {
38
+ const match = /^(ROOT|THIS|FROM(?:\.FROM)*|PREV)\.(.+)$/u.exec(key);
39
+ if (match === null)
40
+ continue;
41
+ if (match[1] === 'ROOT' || match[1] === 'THIS')
42
+ state[match[2]] ??= value;
43
+ else {
44
+ const scope = (scopes[match[1]] ??= { id: match[1], state: {} });
45
+ scope.state[match[2]] ??= value;
46
+ }
47
+ }
48
+ const actor = base.country?.tag ?? base.country?.countryTag ?? base.country?.GetTag;
49
+ return {
50
+ schemaVersion: '1.0',
51
+ id: base.id,
52
+ state,
53
+ scopes,
54
+ closedFlags: false,
55
+ ...(typeof actor === 'string' ? { actor } : {}),
56
+ };
57
+ }
58
+ /** Probability retains pools, uncertainty, correlations and schedule in its own adapter. */
59
+ export function scenarioFromProbability(base) {
60
+ return {
61
+ schemaVersion: '1.0',
62
+ id: base.id,
63
+ ...(base.actor === undefined ? {} : { actor: base.actor }),
64
+ ...(base.date === undefined ? {} : { date: base.date }),
65
+ state: structuredClone(base.state),
66
+ ...(base.flags === undefined ? {} : { flags: [...base.flags] }),
67
+ ...(base.eventTargets === undefined ? {} : { eventTargets: { ...base.eventTargets } }),
68
+ ...(base.scopes === undefined ? {} : { scopes: structuredClone(base.scopes) }),
69
+ ...(base.candidateOverrides === undefined
70
+ ? {}
71
+ : { candidateOverrides: { ...base.candidateOverrides } }),
72
+ closedFlags: true,
73
+ ...(base.scopePools === undefined
74
+ ? {}
75
+ : {
76
+ scopeCatalogs: Object.fromEntries(base.scopePools.map((pool) => [
77
+ pool.id,
78
+ {
79
+ complete: pool.complete,
80
+ members: pool.candidates.map((candidate) => candidate.id),
81
+ },
82
+ ])),
83
+ }),
84
+ };
85
+ }
@@ -0,0 +1,78 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { traceEventGraph } from './algorithms.js';
3
+ import { selectEventNodes } from './queries.js';
4
+ /** Project a whole-graph comparison onto the downstream chain in each revision. */
5
+ export function selectEventComparison(before, after, comparison, selector, maxChainNodes, signal) {
6
+ const trace = (graph) => {
7
+ const starts = selectEventNodes(graph, selector, signal).map(({ id }) => id);
8
+ return traceEventGraph(graph, starts, {
9
+ maxDepth: 64,
10
+ maxNodes: maxChainNodes,
11
+ maxEdges: Math.max(1, Math.min(20_000, graph.edges.length)),
12
+ direction: 'downstream',
13
+ expandHelpers: true,
14
+ }, signal);
15
+ };
16
+ const beforeTrace = trace(before);
17
+ const afterTrace = trace(after);
18
+ const beforeNodeIds = beforeTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
19
+ const afterNodeIds = afterTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
20
+ const selectedNodes = new Set([...beforeNodeIds, ...afterNodeIds]);
21
+ const edges = new Map([...before.edges, ...after.edges].map((edge) => [edge.id, edge]));
22
+ const accesses = new Map([...before.stateAccesses, ...after.stateAccesses].map((access) => [access.id, access]));
23
+ const links = new Map([...before.stateLinks, ...after.stateLinks].map((link) => [link.id, link]));
24
+ const unresolved = new Map([...before.unresolved, ...after.unresolved].map((item) => [item.id, item]));
25
+ const association = (change) => {
26
+ const { kind, subjectId } = change;
27
+ if (kind.startsWith('node_') || kind === 'caller_removed' || kind.endsWith('_disconnected'))
28
+ return selectedNodes.has(subjectId);
29
+ if (kind.startsWith('edge_')) {
30
+ const edge = edges.get(subjectId);
31
+ return edge === undefined
32
+ ? undefined
33
+ : selectedNodes.has(edge.from) || selectedNodes.has(edge.to);
34
+ }
35
+ if (kind.startsWith('state_access_')) {
36
+ const access = accesses.get(subjectId);
37
+ return access === undefined ? undefined : selectedNodes.has(access.ownerId);
38
+ }
39
+ if (kind.startsWith('state_link_')) {
40
+ const link = links.get(subjectId);
41
+ if (link === undefined)
42
+ return undefined;
43
+ const producer = accesses.get(link.producerId);
44
+ const consumer = accesses.get(link.consumerId);
45
+ return producer === undefined && consumer === undefined
46
+ ? undefined
47
+ : (producer !== undefined && selectedNodes.has(producer.ownerId)) ||
48
+ (consumer !== undefined && selectedNodes.has(consumer.ownerId));
49
+ }
50
+ if (kind.startsWith('unresolved_')) {
51
+ const item = unresolved.get(subjectId);
52
+ return item?.ownerId === undefined ? undefined : selectedNodes.has(item.ownerId);
53
+ }
54
+ return undefined;
55
+ };
56
+ const selectedChangeIds = [];
57
+ const unattributedChangeIds = [];
58
+ for (const [index, change] of comparison.changes.entries()) {
59
+ if ((index & 255) === 0)
60
+ signal?.throwIfAborted();
61
+ const relevant = association(change);
62
+ if (relevant === true)
63
+ selectedChangeIds.push(change.id);
64
+ else if (relevant === undefined)
65
+ unattributedChangeIds.push(change.id);
66
+ }
67
+ return {
68
+ selector,
69
+ maxChainNodes,
70
+ beforeNodeIds,
71
+ afterNodeIds,
72
+ selectedChangeIds,
73
+ selectedChangeCount: selectedChangeIds.length,
74
+ omittedChangeCount: comparison.changes.length - selectedChangeIds.length,
75
+ unattributedChangeIds,
76
+ truncated: beforeTrace.truncated || afterTrace.truncated,
77
+ };
78
+ }
@@ -2,6 +2,14 @@ import { compareCodeUnits } from '../core/canonical.js';
2
2
  export function eventHelperInventory(graph) {
3
3
  const helpers = new Set(graph.nodes.filter(({ kind }) => kind === 'helper').map(({ id }) => id));
4
4
  const structural = graph.edges.filter(({ derived }) => !derived);
5
+ const structuralById = new Map(structural.map((edge) => [edge.id, edge]));
6
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
7
+ const optionParents = new Map(graph.edges
8
+ .filter(({ reason }) => reason === 'option_branch')
9
+ .map(({ from, to }) => [to, from]));
10
+ const accessById = new Map(graph.stateAccesses
11
+ .filter(({ ownerId }) => helpers.has(ownerId))
12
+ .map((access) => [`state-access:${access.id}`, access]));
5
13
  return {
6
14
  domain: 'event',
7
15
  sourceRevision: graph.revision,
@@ -29,5 +37,45 @@ export function eventHelperInventory(graph) {
29
37
  .map(({ id }) => id)
30
38
  .sort(compareCodeUnits),
31
39
  branches: [...helpers].sort(compareCodeUnits),
40
+ recordDetails: (path) => {
41
+ let current = 'unknown';
42
+ const root = structuralById.get(path[0]?.id ?? '');
43
+ if (root !== undefined) {
44
+ const caller = nodesById.get(optionParents.get(root.from) ?? root.from);
45
+ const candidate = root.scope?.source ?? caller?.metadata.expectedScope ?? caller?.metadata.scope;
46
+ if (candidate === 'country' ||
47
+ candidate === 'state' ||
48
+ candidate === 'province' ||
49
+ candidate === 'unit_leader' ||
50
+ candidate === 'operative' ||
51
+ candidate === 'character' ||
52
+ candidate === 'global')
53
+ current = candidate;
54
+ }
55
+ const transitions = [];
56
+ for (const { id } of path) {
57
+ const edge = structuralById.get(id);
58
+ if (edge?.scope !== undefined) {
59
+ const { source, destination, confidence } = edge.scope;
60
+ current =
61
+ current !== 'unknown' && source !== 'unknown' && current !== source
62
+ ? 'unknown'
63
+ : destination;
64
+ transitions.push({ edgeId: id, source, destination: current, confidence });
65
+ }
66
+ const access = accessById.get(id);
67
+ if (access !== undefined) {
68
+ return {
69
+ scopeEvidence: {
70
+ stateAccessId: access.id,
71
+ declaredScope: access.scope,
72
+ resolvedScope: access.scope === 'unknown' ? current : access.scope,
73
+ transitions,
74
+ },
75
+ };
76
+ }
77
+ }
78
+ return { scopeEvidence: { resolvedScope: current, transitions } };
79
+ },
32
80
  };
33
81
  }
@@ -130,6 +130,15 @@ export async function compareEvents(viewer, input) {
130
130
  ...(compared.render === undefined ? {} : { renderHashes: compared.render.hashes }),
131
131
  counts: {
132
132
  changes: comparison.changes.length,
133
+ ...(compared.selection === undefined
134
+ ? {}
135
+ : {
136
+ selectedChanges: compared.selection.selectedChangeCount,
137
+ omittedChanges: compared.selection.omittedChangeCount,
138
+ unattributedChanges: compared.selection.unattributedChangeIds.length,
139
+ selectedBeforeNodes: compared.selection.beforeNodeIds.length,
140
+ selectedAfterNodes: compared.selection.afterNodeIds.length,
141
+ }),
133
142
  addedNodes: comparison.addedNodeIds.length,
134
143
  removedNodes: comparison.removedNodeIds.length,
135
144
  changedNodes: comparison.changedNodeIds.length,
@@ -155,6 +164,13 @@ export async function compareEvents(viewer, input) {
155
164
  },
156
165
  boundary: {
157
166
  proposedSources: input.proposedSources?.length ?? 0,
167
+ ...(compared.selection === undefined
168
+ ? {}
169
+ : {
170
+ selector: compared.selection.selector,
171
+ maxChainNodes: compared.selection.maxChainNodes,
172
+ selectionTruncated: compared.selection.truncated,
173
+ }),
158
174
  render: input.render ?? true,
159
175
  maxRenderNodes: input.maxRenderNodes ?? 120,
160
176
  refresh: compared.refresh,
@@ -13,6 +13,7 @@ import { buildEventGraph, buildEventGraphAsync } from './graph.js';
13
13
  import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
14
14
  import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
15
15
  import { compareEventGraphs, eventGraphHash } from './compare.js';
16
+ import { selectEventComparison } from './comparison-selection.js';
16
17
  import { eventFlowEdges } from './algorithms.js';
17
18
  import { validateEventGraphArtifact } from './artifact-validation.js';
18
19
  import { renderEventGraph, } from './render.js';
@@ -1331,16 +1332,29 @@ export class EventChainViewer {
1331
1332
  after = current;
1332
1333
  }
1333
1334
  const comparison = compareEventGraphs(before, after, input.signal);
1335
+ const selection = input.selector === undefined
1336
+ ? undefined
1337
+ : selectEventComparison(before, after, comparison, input.selector, input.maxChainNodes ?? 1_000, input.signal);
1338
+ if (selection?.beforeNodeIds.length === 0 && selection.afterNodeIds.length === 0)
1339
+ throw new ServiceError('EVENT_COMPARISON_SELECTOR_NOT_FOUND', 'The selector matched no event-chain nodes in either revision');
1334
1340
  graphHashCache.set(before, comparison.beforeGraphHash);
1335
1341
  graphHashCache.set(after, comparison.afterGraphHash);
1336
1342
  let render;
1337
1343
  if (input.render !== false) {
1338
1344
  const projection = comparisonRenderProjection(before, after, comparison, input.signal);
1345
+ const selectedChainIds = selection === undefined
1346
+ ? undefined
1347
+ : new Set([...selection.beforeNodeIds, ...selection.afterNodeIds]);
1348
+ const changedNodeIds = selectedChainIds === undefined
1349
+ ? projection.selectedNodeIds
1350
+ : projection.selectedNodeIds.filter((id) => selectedChainIds.has(id));
1339
1351
  const selector = {
1340
1352
  kind: 'manifest',
1341
1353
  manifest: {
1342
1354
  id: 'comparison',
1343
- nodeIds: projection.selectedNodeIds,
1355
+ nodeIds: changedNodeIds.length > 0
1356
+ ? changedNodeIds
1357
+ : (selection?.afterNodeIds.slice(0, 1) ?? selection?.beforeNodeIds.slice(0, 1) ?? []),
1344
1358
  },
1345
1359
  };
1346
1360
  render = await renderEventGraph(projection.graph, {
@@ -1354,7 +1368,7 @@ export class EventChainViewer {
1354
1368
  });
1355
1369
  }
1356
1370
  const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
1357
- const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}`);
1371
+ const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}${selection === undefined ? '' : `-${hashCanonical({ selector: selection.selector, maxChainNodes: selection.maxChainNodes }).slice(0, 8)}`}`);
1358
1372
  const resourceNames = {
1359
1373
  json: `${prefix}.json`,
1360
1374
  ...(render === undefined
@@ -1380,6 +1394,9 @@ export class EventChainViewer {
1380
1394
  filters: {
1381
1395
  ...(input.before === undefined ? {} : { before: input.before }),
1382
1396
  ...(input.after === undefined ? {} : { after: input.after }),
1397
+ ...(input.selector === undefined
1398
+ ? {}
1399
+ : { selector: input.selector, maxChainNodes: input.maxChainNodes ?? 1_000 }),
1383
1400
  proposedSources: input.proposedSources?.map(({ relativePath, source, expectedSourceHash }) => ({
1384
1401
  relativePath,
1385
1402
  operation: source === null ? 'delete' : 'overlay',
@@ -1397,6 +1414,7 @@ export class EventChainViewer {
1397
1414
  evidence: comparisonEntityEvidence(before, after, comparison, input.signal),
1398
1415
  resources: resourceNames,
1399
1416
  comparison,
1417
+ ...(selection === undefined ? {} : { selection }),
1400
1418
  })}\n`;
1401
1419
  const provenance = eventProvenance(after, 'event-comparison', {
1402
1420
  beforeRevision: before.revision,
@@ -1447,6 +1465,7 @@ export class EventChainViewer {
1447
1465
  after,
1448
1466
  refresh,
1449
1467
  comparison,
1468
+ ...(selection === undefined ? {} : { selection }),
1450
1469
  comparisonJson,
1451
1470
  ...(render === undefined ? {} : { render }),
1452
1471
  artifacts,