hoi4-agent-tools 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +30 -21
  3. package/dist/bin/stdio.js +0 -4
  4. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
  5. package/dist/hoi4_agent_tools/core/engine.js +5 -0
  6. package/dist/hoi4_agent_tools/core/index.js +57 -1
  7. package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
  8. package/dist/hoi4_agent_tools/core/source/index.js +1 -0
  9. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  10. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  11. package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
  12. package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
  13. package/dist/hoi4_agent_tools/probability/compare.js +178 -0
  14. package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
  15. package/dist/hoi4_agent_tools/probability/index.js +13 -0
  16. package/dist/hoi4_agent_tools/probability/model.js +5 -0
  17. package/dist/hoi4_agent_tools/probability/rational.js +145 -0
  18. package/dist/hoi4_agent_tools/probability/render.js +307 -0
  19. package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
  20. package/dist/hoi4_agent_tools/probability/service.js +1168 -0
  21. package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
  22. package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
  23. package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
  24. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
  25. package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
  26. package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
  27. package/dist/hoi4_agent_tools/version.js +1 -1
  28. package/docs/README.md +1 -0
  29. package/docs/development.md +2 -0
  30. package/docs/probability.md +80 -0
  31. package/docs/research/probability-adapter-evidence.md +64 -0
  32. package/examples/clients/codex-config.toml +3 -0
  33. package/examples/clients/generic.json +8 -0
  34. package/examples/probability/adaptive-event-pool.json +92 -0
  35. package/examples/probability/adaptive-event-sequence-input.json +57 -0
  36. package/examples/probability/event-mtth-scenarios.json +57 -0
  37. package/examples/probability/event-mtth-simulate-input.json +37 -0
  38. package/examples/probability/focus-route-scenarios.json +61 -0
  39. package/examples/probability/focus-route-sweep-input.json +36 -0
  40. package/package.json +17 -8
  41. package/schemas/configuration.schema.json +747 -0
  42. package/schemas/continuous-focus-palette.schema.json +325 -0
  43. package/schemas/custom-weighted-pool.schema.json +298 -0
  44. package/schemas/event-feature-manifest.schema.json +63 -0
  45. package/schemas/focus-plan.schema.json +927 -0
  46. package/schemas/focus-planning-sidecar.schema.json +407 -0
  47. package/schemas/gui-animation-source.schema.json +176 -0
  48. package/schemas/gui-helper.schema.json +301 -0
  49. package/schemas/gui-scenario.schema.json +298 -0
  50. package/schemas/map-operation.schema.json +2284 -0
  51. package/schemas/operation-result.schema.json +327 -0
  52. package/schemas/probability-analysis-result.schema.json +2211 -0
  53. package/schemas/probability-compare-input.schema.json +664 -0
  54. package/schemas/probability-evaluate-input.schema.json +665 -0
  55. package/schemas/probability-inspect-input.schema.json +97 -0
  56. package/schemas/probability-render-input.schema.json +141 -0
  57. package/schemas/probability-scenario-set.schema.json +430 -0
  58. package/schemas/probability-sequence-input.schema.json +886 -0
  59. package/schemas/probability-simulate-input.schema.json +691 -0
  60. package/schemas/probability-sweep-input.schema.json +702 -0
  61. package/server.json +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -530
@@ -0,0 +1,458 @@
1
+ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ import { assignments, astPathFor, childBlocks, firstScalar, nodeLocation, parseClausewitz, } from '../core/source/index.js';
4
+ import { probabilityAdapter } from './adapters.js';
5
+ const eventKeys = new Set([
6
+ 'country_event',
7
+ 'news_event',
8
+ 'state_event',
9
+ 'unit_leader_event',
10
+ 'operative_leader_event',
11
+ ]);
12
+ function normalized(value) {
13
+ return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
14
+ }
15
+ function walkAssignments(block, ancestors = [], output = []) {
16
+ for (const assignment of assignments(block)) {
17
+ output.push({ assignment, ancestors });
18
+ if (assignment.value.type === 'block') {
19
+ walkAssignments(assignment.value, [...ancestors, { key: assignment.key.value, block, assignment }], output);
20
+ }
21
+ }
22
+ return output;
23
+ }
24
+ function provenance(context, node, symbol) {
25
+ return {
26
+ path: context.file.displayPath,
27
+ rootKind: context.file.rootKind,
28
+ loadOrder: context.file.loadOrder,
29
+ sourceHash: context.file.sha256,
30
+ location: nodeLocation(context.document, node, symbol),
31
+ ...(astPathFor(context.document, node) === undefined
32
+ ? {}
33
+ : { astPath: astPathFor(context.document, node) }),
34
+ ...(symbol === undefined ? {} : { symbol }),
35
+ };
36
+ }
37
+ function weightCandidate(adapterId, id, sourceKind, defaultValue, context, definition, options = {}) {
38
+ return {
39
+ id,
40
+ adapterId,
41
+ sourceKind,
42
+ defaultValue,
43
+ ...(options.valueExpression === undefined ? {} : { valueExpression: options.valueExpression }),
44
+ ...(options.weightBlock === undefined ? {} : { weightBlock: options.weightBlock }),
45
+ ...(options.eligibilityBlock === undefined
46
+ ? {}
47
+ : { eligibilityBlock: options.eligibilityBlock }),
48
+ document: context.document,
49
+ provenance: [provenance(context, definition, id)],
50
+ metadata: options.metadata ?? {},
51
+ ...(options.parentRandomPools === undefined
52
+ ? {}
53
+ : { parentRandomPools: options.parentRandomPools }),
54
+ };
55
+ }
56
+ function eventCandidates(context, adapterId) {
57
+ const output = [];
58
+ for (const { assignment, ancestors } of walkAssignments(context.document.root)) {
59
+ if (assignment.value.type !== 'block' ||
60
+ ancestors.length !== 0 ||
61
+ !eventKeys.has(assignment.key.value) ||
62
+ firstScalar(assignment.value, 'id') === undefined)
63
+ continue;
64
+ const event = assignment.value;
65
+ const eventId = firstScalar(event, 'id').value;
66
+ if (adapterId === 'event_mean_time_to_happen') {
67
+ const mtth = childBlocks(event, 'mean_time_to_happen')[0];
68
+ if (mtth === undefined)
69
+ continue;
70
+ output.push(weightCandidate(adapterId, eventId, assignment.key.value, '0', context, assignment, {
71
+ weightBlock: mtth,
72
+ ...(childBlocks(event, 'trigger')[0] === undefined
73
+ ? {}
74
+ : { eligibilityBlock: childBlocks(event, 'trigger')[0] }),
75
+ metadata: { eventId },
76
+ }));
77
+ continue;
78
+ }
79
+ const options = childBlocks(event, 'option');
80
+ for (const [index, option] of options.entries()) {
81
+ const chance = childBlocks(option, 'ai_chance')[0];
82
+ const name = firstScalar(option, 'name')?.value ?? `${eventId}.option.${index + 1}`;
83
+ output.push(weightCandidate(adapterId, name, 'event_option', '1', context, option, {
84
+ ...(chance === undefined ? {} : { weightBlock: chance }),
85
+ ...(childBlocks(option, 'trigger')[0] === undefined
86
+ ? {}
87
+ : { eligibilityBlock: childBlocks(option, 'trigger')[0] }),
88
+ metadata: { eventId, optionIndex: index },
89
+ }));
90
+ }
91
+ }
92
+ return output;
93
+ }
94
+ function focusCandidates(context) {
95
+ const output = [];
96
+ for (const { assignment, ancestors } of walkAssignments(context.document.root)) {
97
+ if (assignment.key.value !== 'focus' ||
98
+ assignment.value.type !== 'block' ||
99
+ ancestors.at(-1)?.key !== 'focus_tree')
100
+ continue;
101
+ const id = firstScalar(assignment.value, 'id')?.value;
102
+ if (id === undefined)
103
+ continue;
104
+ const weight = childBlocks(assignment.value, 'ai_will_do')[0];
105
+ output.push(weightCandidate('national_focus_ai_will_do', id, 'focus', '1', context, assignment, {
106
+ ...(weight === undefined ? {} : { weightBlock: weight }),
107
+ ...(childBlocks(assignment.value, 'available')[0] === undefined
108
+ ? {}
109
+ : { eligibilityBlock: childBlocks(assignment.value, 'available')[0] }),
110
+ metadata: {
111
+ prerequisiteBlocks: childBlocks(assignment.value, 'prerequisite').length,
112
+ hasBypass: childBlocks(assignment.value, 'bypass').length > 0,
113
+ },
114
+ }));
115
+ }
116
+ return output;
117
+ }
118
+ function decisionCandidates(context, adapterId) {
119
+ const output = [];
120
+ const ignored = new Set([
121
+ 'allowed',
122
+ 'available',
123
+ 'icon',
124
+ 'picture',
125
+ 'priority',
126
+ 'scripted_gui',
127
+ 'target_root_trigger',
128
+ 'visible',
129
+ 'visible_when_empty',
130
+ ]);
131
+ for (const { assignment, ancestors } of walkAssignments(context.document.root)) {
132
+ if (assignment.value.type !== 'block' ||
133
+ ancestors.length !== 1 ||
134
+ ignored.has(assignment.key.value))
135
+ continue;
136
+ const mission = firstScalar(assignment.value, 'is_mission')?.value === 'yes' ||
137
+ firstScalar(assignment.value, 'days_mission_timeout') !== undefined ||
138
+ firstScalar(assignment.value, 'days_remove') !== undefined;
139
+ if ((adapterId === 'mission_ai_will_do') !== mission)
140
+ continue;
141
+ const weight = childBlocks(assignment.value, 'ai_will_do')[0];
142
+ if (weight === undefined)
143
+ continue;
144
+ output.push(weightCandidate(adapterId, assignment.key.value, mission ? 'mission' : 'decision', '1', context, assignment, {
145
+ weightBlock: weight,
146
+ ...(childBlocks(assignment.value, 'available')[0] === undefined
147
+ ? {}
148
+ : { eligibilityBlock: childBlocks(assignment.value, 'available')[0] }),
149
+ metadata: { category: ancestors[0].key },
150
+ }));
151
+ }
152
+ return output;
153
+ }
154
+ function technologyCandidates(context, adapterId) {
155
+ const output = [];
156
+ const sourcePath = normalized(context.file.relativePath);
157
+ for (const { assignment, ancestors } of walkAssignments(context.document.root)) {
158
+ if (assignment.value.type !== 'block')
159
+ continue;
160
+ const classic = adapterId === 'technology_ai_will_do' &&
161
+ sourcePath.startsWith('common/technologies/') &&
162
+ ancestors.length === 1 &&
163
+ ancestors[0]?.key === 'technologies' &&
164
+ !assignment.key.value.startsWith('@');
165
+ const doctrine = adapterId === 'doctrine_ai_will_do' &&
166
+ sourcePath.startsWith('common/doctrines/') &&
167
+ ancestors.length === 0 &&
168
+ !assignment.key.value.startsWith('@');
169
+ if (!classic && !doctrine)
170
+ continue;
171
+ const weight = childBlocks(assignment.value, 'ai_will_do')[0];
172
+ if (weight === undefined)
173
+ continue;
174
+ output.push(weightCandidate(adapterId, assignment.key.value, doctrine ? 'doctrine' : 'technology', '1', context, assignment, {
175
+ weightBlock: weight,
176
+ ...((childBlocks(assignment.value, 'allow')[0] ??
177
+ childBlocks(assignment.value, 'available')[0]) === undefined
178
+ ? {}
179
+ : {
180
+ eligibilityBlock: childBlocks(assignment.value, 'allow')[0] ??
181
+ childBlocks(assignment.value, 'available')[0],
182
+ }),
183
+ metadata: { sourcePath },
184
+ }));
185
+ }
186
+ return output;
187
+ }
188
+ function randomListEntryCandidates(context, list) {
189
+ if (list.value.type !== 'block')
190
+ return [];
191
+ const location = nodeLocation(context.document, list);
192
+ const blockId = `${context.file.relativePath}:${location.start.line}`;
193
+ return assignments(list.value).flatMap((entry, index) => {
194
+ if (entry.value.type !== 'block')
195
+ return [];
196
+ const trigger = childBlocks(entry.value, 'trigger')[0] ?? childBlocks(entry.value, 'limit')[0];
197
+ return [
198
+ weightCandidate('random_list', `${blockId}.entry.${index + 1}`, 'random_list_entry', entry.key.value, context, entry, {
199
+ weightBlock: entry.value,
200
+ ...(trigger === undefined ? {} : { eligibilityBlock: trigger }),
201
+ metadata: {
202
+ randomListId: blockId,
203
+ entryIndex: index,
204
+ poolStartLine: location.start.line,
205
+ poolEndLine: location.end.line,
206
+ },
207
+ }),
208
+ ];
209
+ });
210
+ }
211
+ function parentRandomPools(context, ancestors) {
212
+ const output = [];
213
+ for (let index = 0; index + 1 < ancestors.length; index += 1) {
214
+ const list = ancestors[index].assignment;
215
+ const selectedEntry = ancestors[index + 1].assignment;
216
+ if (list.key.value !== 'random_list' || list.value.type !== 'block')
217
+ continue;
218
+ const entries = assignments(list.value).filter(({ value }) => value.type === 'block');
219
+ const selectedEntryIndex = entries.indexOf(selectedEntry);
220
+ if (selectedEntryIndex < 0)
221
+ continue;
222
+ const candidates = randomListEntryCandidates(context, list);
223
+ const randomListId = candidates[0]?.metadata.randomListId;
224
+ output.push({
225
+ id: typeof randomListId === 'string' ? randomListId : `random-list-${index}`,
226
+ selectedEntryIndex,
227
+ candidates,
228
+ });
229
+ }
230
+ return output;
231
+ }
232
+ function randomCandidates(context, adapterId) {
233
+ const output = [];
234
+ for (const { assignment, ancestors } of walkAssignments(context.document.root)) {
235
+ if (assignment.key.value !== (adapterId === 'direct_random' ? 'random' : 'random_list'))
236
+ continue;
237
+ if (assignment.value.type !== 'block')
238
+ continue;
239
+ const location = nodeLocation(context.document, assignment);
240
+ const blockId = `${context.file.relativePath}:${location.start.line}`;
241
+ if (adapterId === 'direct_random') {
242
+ const chance = firstScalar(assignment.value, 'chance');
243
+ if (chance === undefined)
244
+ continue;
245
+ output.push(weightCandidate(adapterId, blockId, 'random', '0', context, assignment, {
246
+ valueExpression: chance.value,
247
+ metadata: { randomBlockId: blockId },
248
+ }));
249
+ continue;
250
+ }
251
+ const parents = parentRandomPools(context, ancestors);
252
+ output.push(...randomListEntryCandidates(context, assignment).map((candidate) => ({
253
+ ...candidate,
254
+ ...(parents.length === 0 ? {} : { parentRandomPools: parents }),
255
+ })));
256
+ }
257
+ return output;
258
+ }
259
+ function aiStrategyCandidates(context) {
260
+ const output = [];
261
+ for (const { assignment } of walkAssignments(context.document.root)) {
262
+ if (assignment.value.type !== 'block')
263
+ continue;
264
+ const type = firstScalar(assignment.value, 'type')?.value;
265
+ if (type !== 'research_weight_factor' && type !== 'research_tech' && type !== 'focus')
266
+ continue;
267
+ const value = firstScalar(assignment.value, 'value');
268
+ if (value === undefined)
269
+ continue;
270
+ const target = firstScalar(assignment.value, 'id')?.value ?? 'unspecified';
271
+ const location = nodeLocation(context.document, assignment);
272
+ const id = `${type}:${target}:${context.file.relativePath}:${location.start.line}`;
273
+ output.push(weightCandidate('ai_strategy_factor', id, 'ai_strategy', '0', context, assignment, {
274
+ valueExpression: value.value,
275
+ metadata: { strategyType: type, target },
276
+ }));
277
+ }
278
+ return output;
279
+ }
280
+ function candidatesFor(context, adapterId) {
281
+ switch (adapterId) {
282
+ case 'event_mean_time_to_happen':
283
+ case 'event_option_ai_chance':
284
+ return eventCandidates(context, adapterId);
285
+ case 'decision_ai_will_do':
286
+ case 'mission_ai_will_do':
287
+ return decisionCandidates(context, adapterId);
288
+ case 'national_focus_ai_will_do':
289
+ return focusCandidates(context);
290
+ case 'technology_ai_will_do':
291
+ case 'doctrine_ai_will_do':
292
+ return technologyCandidates(context, adapterId);
293
+ case 'direct_random':
294
+ case 'random_list':
295
+ return randomCandidates(context, adapterId);
296
+ case 'ai_strategy_factor':
297
+ return aiStrategyCandidates(context);
298
+ case 'custom_weighted_pool':
299
+ return [];
300
+ }
301
+ }
302
+ function sourceContexts(snapshot, source) {
303
+ if (source.inlineClausewitz !== undefined || source.virtualPatch !== undefined) {
304
+ const text = source.virtualPatch ?? source.inlineClausewitz ?? '';
305
+ const bytes = Buffer.from(text, 'utf8');
306
+ const sourceHash = hashCanonical({ text });
307
+ const file = {
308
+ absolutePath: '<inline>',
309
+ displayPath: 'proposed:inline-probability-source.txt',
310
+ relativePath: 'proposed/inline-probability-source.txt',
311
+ rootKind: 'fixture',
312
+ loadOrder: Number.MAX_SAFE_INTEGER,
313
+ size: bytes.length,
314
+ modifiedMs: 0,
315
+ sha256: sourceHash,
316
+ bytes,
317
+ };
318
+ return [{ file, document: parseClausewitz(bytes, file.displayPath) }];
319
+ }
320
+ const requestedPath = source.path === undefined ? undefined : normalized(source.path);
321
+ const selected = snapshot.files.filter(({ relativePath, shadowedBy }) => {
322
+ if (shadowedBy !== undefined || !relativePath.toLowerCase().endsWith('.txt'))
323
+ return false;
324
+ if (requestedPath === undefined)
325
+ return true;
326
+ const relative = normalized(relativePath);
327
+ return relative === requestedPath || relative.endsWith(`/${requestedPath}`);
328
+ });
329
+ if (requestedPath !== undefined && selected.length === 0)
330
+ throw new ServiceError('PROBABILITY_SOURCE_NOT_FOUND', 'Probability source path was not found', {
331
+ path: source.path,
332
+ });
333
+ if (source.expectedSourceHash !== undefined &&
334
+ selected.some(({ sha256 }) => sha256 !== source.expectedSourceHash))
335
+ throw new ServiceError('PROBABILITY_SOURCE_STALE', 'Probability source hash is stale', {
336
+ expectedSourceHash: source.expectedSourceHash,
337
+ actualSourceHashes: selected.map(({ sha256 }) => sha256),
338
+ });
339
+ return selected.map((file) => ({
340
+ file,
341
+ document: parseClausewitz(file.bytes, file.displayPath),
342
+ }));
343
+ }
344
+ function identifierMatches(candidate, identifier) {
345
+ if (candidate.id === identifier)
346
+ return true;
347
+ return Object.values(candidate.metadata).some((value) => value === identifier);
348
+ }
349
+ function samePool(candidate, selected) {
350
+ if (candidate.adapterId === 'event_option_ai_chance')
351
+ return candidate.metadata.eventId === selected.metadata.eventId;
352
+ if (candidate.adapterId === 'random_list')
353
+ return candidate.metadata.randomListId === selected.metadata.randomListId;
354
+ return candidate.id === selected.id;
355
+ }
356
+ export function discoverWeightedSurface(snapshot, adapterId, source, candidatePool = []) {
357
+ const contexts = sourceContexts(snapshot, source);
358
+ let candidates = contexts.flatMap((context) => candidatesFor(context, adapterId));
359
+ candidates.sort((left, right) => compareCodeUnits(left.id, right.id));
360
+ if (source.line !== undefined) {
361
+ candidates = candidates.filter(({ metadata, provenance: [first] }) => {
362
+ const location = first?.location;
363
+ const poolStartLine = metadata.poolStartLine;
364
+ const poolEndLine = metadata.poolEndLine;
365
+ return ((location !== undefined &&
366
+ source.line >= location.start.line &&
367
+ source.line <= location.end.line) ||
368
+ (typeof poolStartLine === 'number' &&
369
+ typeof poolEndLine === 'number' &&
370
+ source.line >= poolStartLine &&
371
+ source.line <= poolEndLine));
372
+ });
373
+ if (adapterId === 'event_option_ai_chance' || adapterId === 'random_list') {
374
+ const poolProperty = adapterId === 'event_option_ai_chance' ? 'eventId' : 'randomListId';
375
+ const pools = new Map();
376
+ for (const candidate of candidates) {
377
+ const start = candidate.metadata.poolStartLine;
378
+ const end = candidate.metadata.poolEndLine;
379
+ const span = typeof start === 'number' && typeof end === 'number'
380
+ ? end - start
381
+ : Number.MAX_SAFE_INTEGER;
382
+ const key = candidate.metadata[poolProperty];
383
+ const existing = pools.get(key);
384
+ if (existing === undefined || span < existing.span)
385
+ pools.set(key, { span });
386
+ }
387
+ const selectedPool = [...pools].sort(([leftKey, left], [rightKey, right]) => left.span - right.span || compareCodeUnits(String(leftKey), String(rightKey)))[0]?.[0];
388
+ if (selectedPool !== undefined)
389
+ candidates = candidates.filter((candidate) => candidate.metadata[poolProperty] === selectedPool);
390
+ }
391
+ }
392
+ const localCategoricalPool = adapterId === 'event_option_ai_chance' || adapterId === 'random_list';
393
+ let completeLocalPoolIds;
394
+ if (source.identifier !== undefined) {
395
+ const selected = candidates.find((candidate) => identifierMatches(candidate, source.identifier));
396
+ if (selected === undefined)
397
+ throw new ServiceError('PROBABILITY_IDENTIFIER_NOT_FOUND', 'No weighted source matched the requested identifier', { identifier: source.identifier, adapterId });
398
+ if (localCategoricalPool) {
399
+ candidates = candidates.filter((candidate) => samePool(candidate, selected));
400
+ completeLocalPoolIds = new Set(candidates.map(({ id }) => id));
401
+ }
402
+ else if (candidatePool.length === 0)
403
+ candidates = [selected];
404
+ }
405
+ const candidatesBeforeExplicitPool = candidates;
406
+ if (candidatePool.length > 0) {
407
+ const wanted = new Set(candidatePool);
408
+ candidates = candidates.filter(({ id }) => wanted.has(id));
409
+ }
410
+ if (candidates.length === 0 && adapterId !== 'custom_weighted_pool')
411
+ throw new ServiceError('PROBABILITY_SURFACE_EMPTY', 'No weighted blocks matched this request', {
412
+ adapterId,
413
+ source,
414
+ });
415
+ const missingCandidates = candidatePool.filter((id) => !candidates.some((candidate) => candidate.id === id));
416
+ const unsupported = missingCandidates.map((candidateId) => ({
417
+ code: 'CANDIDATE_NOT_FOUND',
418
+ message: 'Declared candidate was not found in active source',
419
+ candidateId,
420
+ }));
421
+ const inherentPool = adapterId === 'direct_random' || adapterId === 'event_mean_time_to_happen';
422
+ if (localCategoricalPool && completeLocalPoolIds === undefined) {
423
+ const poolKey = (candidate) => String(adapterId === 'event_option_ai_chance'
424
+ ? candidate.metadata.eventId
425
+ : candidate.metadata.randomListId);
426
+ const selectedKeys = new Set(candidates.map(poolKey));
427
+ if (selectedKeys.size === 1) {
428
+ const [selectedKey] = selectedKeys;
429
+ completeLocalPoolIds = new Set(candidatesBeforeExplicitPool
430
+ .filter((candidate) => poolKey(candidate) === selectedKey)
431
+ .map(({ id }) => id));
432
+ }
433
+ else
434
+ unsupported.push({
435
+ code: 'MULTIPLE_CATEGORICAL_POOLS',
436
+ message: 'Select one event-option or random-list pool before requesting normalized probabilities',
437
+ });
438
+ }
439
+ const localPoolIds = completeLocalPoolIds;
440
+ const localPoolComplete = localPoolIds?.size === candidates.length && candidates.every(({ id }) => localPoolIds.has(id));
441
+ const poolComplete = missingCandidates.length === 0 &&
442
+ (inherentPool ||
443
+ (localCategoricalPool && localPoolComplete) ||
444
+ (!localCategoricalPool &&
445
+ candidatePool.length > 0 &&
446
+ candidates.length === candidatePool.length));
447
+ const hashes = Object.fromEntries(contexts.map(({ file }) => [file.displayPath, file.sha256]));
448
+ return {
449
+ id: source.identifier ?? source.path ?? `${adapterId}-workspace-scan`,
450
+ adapter: probabilityAdapter(adapterId),
451
+ candidates,
452
+ poolComplete,
453
+ sourceRevision: snapshot.revision,
454
+ sourceHash: hashCanonical(hashes),
455
+ filesScanned: contexts.map(({ file }) => file.displayPath),
456
+ unsupported,
457
+ };
458
+ }