hoi4-agent-tools 2.3.10 → 2.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.12 - 2026-08-05
4
+
5
+ - Made compact focus rewrites choose among preservation, row compression, linear-chain repair, sibling balancing, and combined strategies instead of forcing one aggressive transformation.
6
+ - Limited rewrite rejection to invalid geometry while retaining long paths, fake complexity, branch balance, and compactness as prioritized cleanup objectives.
7
+ - Added whole-cohort sibling refinement, collision-aware incremental placement, stable repeat rewrites, and regression coverage through the public Ireland and 1,024-focus MCP workflows.
8
+
9
+ ## 2.3.11 - 2026-08-05
10
+
11
+ - Made compact focus rewrites straighten offset linear chains and reject artificial staircase or zigzag geometry.
12
+ - Tightened compact layout acceptance so long connectors, connector paths through focuses, crossings, asymmetric sibling groups, and off-anchor branches cannot survive a successful cleanup.
13
+ - Kept the stricter rewrite deterministic and verified it through the public 1,024-focus MCP workflow.
14
+
3
15
  ## 2.3.10 - 2026-08-04
4
16
 
5
17
  - Routed focus operations to the unique discovered mod containing a requested source when a stale client root points at another mod.
@@ -10,28 +10,23 @@ const LARGE_COMPACT_SCALES = [0.55, 0.6, 0.7, 0.8];
10
10
  const COMPACT_LAYOUT_WORK_MAX = FOCUS_LAYOUT_WORK_MAX * 10;
11
11
  const COMPACT_HARD_DIAGNOSTICS = new Set([
12
12
  'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
13
+ 'FOCUS_LAYOUT_CONNECTOR_THROUGH_NODE',
13
14
  'FOCUS_LAYOUT_COORDINATE_CONFLICT',
14
15
  'FOCUS_LAYOUT_LANE_BOUNDS_UNSATISFIED',
15
16
  'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
17
+ 'FOCUS_LAYOUT_PARENT_NOT_ABOVE',
16
18
  'FOCUS_LAYOUT_PARENT_ORDER_UNSATISFIED',
17
19
  'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
18
20
  'FOCUS_LAYOUT_VISIBLE_OVERLAP',
19
21
  ]);
20
- function compactLimits(focusCount, connectorCount) {
21
- const graphScale = Math.sqrt(Math.max(1, focusCount));
22
- const maximumHorizontalSpan = Math.max(14, Math.ceil(graphScale * 3));
23
- const maximumVerticalSpan = Math.max(4, Math.ceil(Math.log2(Math.max(2, focusCount))));
24
- return {
25
- maximumColumns: Math.max(16, Math.ceil(graphScale * 5)),
26
- maximumHorizontalSpan,
27
- maximumVerticalSpan,
28
- maximumManhattanSpan: maximumHorizontalSpan + maximumVerticalSpan,
29
- maximumLongConnectors: Math.ceil(connectorCount * 0.05),
30
- maximumNodeIntersections: Math.ceil(connectorCount * 0.12),
31
- maximumSiblingDeviation: Math.max(2, Math.ceil(graphScale * 2)),
32
- maximumTotalSiblingDeviation: Math.max(4, Math.ceil(focusCount * 0.5)),
33
- };
34
- }
22
+ const COMPACT_AESTHETIC_DIAGNOSTICS = new Set([
23
+ 'FOCUS_LAYOUT_LINEAR_DETOUR',
24
+ 'FOCUS_LAYOUT_LONG_CONNECTOR',
25
+ 'FOCUS_LAYOUT_SIBLING_ANCHOR_DEVIATION',
26
+ 'FOCUS_LAYOUT_SIBLING_ASYMMETRY',
27
+ 'FOCUS_LAYOUT_STAIRCASE_CHAIN',
28
+ 'FOCUS_LAYOUT_ZIGZAG_CHAIN',
29
+ ]);
35
30
  function requiredMetrics(layout) {
36
31
  if (layout.metrics !== undefined)
37
32
  return layout.metrics;
@@ -39,79 +34,16 @@ function requiredMetrics(layout) {
39
34
  }
40
35
  function absoluteCompactRegressions(layout) {
41
36
  const metrics = requiredMetrics(layout);
42
- const limits = compactLimits(layout.nodes.length, metrics.connectors.count);
43
37
  return [
44
38
  ...(layout.diagnostics.some(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code))
45
39
  ? ['hardLayoutDiagnostics']
46
40
  : []),
47
41
  ...(metrics.connectors.crossingCount > 0 ? ['connectorCrossingCount'] : []),
48
- ...(metrics.connectors.nodeIntersectionCount > limits.maximumNodeIntersections
49
- ? ['connectorNodeIntersectionBudget']
50
- : []),
42
+ ...(metrics.connectors.nodeIntersectionCount > 0 ? ['connectorNodeIntersections'] : []),
51
43
  ...(metrics.spacing.tooCloseSameRowPairCount > 0 ? ['sameRowSpacing'] : []),
52
- ...(metrics.symmetry.maximumSiblingDeviation > 1 ? ['siblingMirrorDeviation'] : []),
53
- ...(metrics.symmetry.totalSiblingDeviation > metrics.symmetry.siblingCohortCount
54
- ? ['totalSiblingMirrorDeviation']
55
- : []),
56
- ...(metrics.symmetry.maximumSiblingAnchorDeviation > limits.maximumSiblingDeviation
57
- ? ['maximumSiblingDeviationBudget']
58
- : []),
59
- ...(metrics.symmetry.totalSiblingAnchorDeviation > limits.maximumTotalSiblingDeviation
60
- ? ['totalSiblingDeviationBudget']
61
- : []),
62
44
  ...(metrics.symmetry.boundingCenterOffsetTwice > 1 ? ['boundingCenter'] : []),
63
- ...(metrics.bounds.columnCount > limits.maximumColumns ? ['columnBudget'] : []),
64
- ...(metrics.connectors.maximumHorizontalSpan > limits.maximumHorizontalSpan
65
- ? ['maximumHorizontalConnectorBudget']
66
- : []),
67
- ...(metrics.connectors.maximumVerticalSpan > limits.maximumVerticalSpan
68
- ? ['maximumVerticalConnectorBudget']
69
- : []),
70
- ...(metrics.connectors.maximumManhattanSpan > limits.maximumManhattanSpan
71
- ? ['maximumManhattanConnectorBudget']
72
- : []),
73
- ...(metrics.connectors.longConnectorCount > limits.maximumLongConnectors
74
- ? ['longConnectorBudget']
75
- : []),
76
- ];
77
- }
78
- function relativeCompactRegressions(current, proposed) {
79
- const before = requiredMetrics(current);
80
- const after = requiredMetrics(proposed);
81
- return [
82
- ...(after.connectors.crossingCount > before.connectors.crossingCount
83
- ? ['connectorCrossingCount']
84
- : []),
85
- ...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
86
- ? ['connectorNodeIntersectionCount']
87
- : []),
88
- ...(after.connectors.maximumHorizontalSpan > before.connectors.maximumHorizontalSpan
89
- ? ['maximumHorizontalConnectorSpan']
90
- : []),
91
- ...(after.connectors.maximumVerticalSpan > before.connectors.maximumVerticalSpan
92
- ? ['maximumVerticalConnectorSpan']
93
- : []),
94
- ...(after.connectors.maximumManhattanSpan > before.connectors.maximumManhattanSpan
95
- ? ['maximumManhattanConnectorSpan']
96
- : []),
97
- ...(after.connectors.longConnectorCount > before.connectors.longConnectorCount
98
- ? ['longConnectorCount']
99
- : []),
100
- ...(after.connectors.totalHorizontalSpan > before.connectors.totalHorizontalSpan
101
- ? ['totalHorizontalConnectorSpan']
102
- : []),
103
- ...(after.connectors.totalManhattanSpan > before.connectors.totalManhattanSpan
104
- ? ['totalManhattanConnectorSpan']
105
- : []),
106
- ...(after.bounds.columnCount > before.bounds.columnCount ? ['columnCount'] : []),
107
- ...(after.bounds.rowCount > before.bounds.rowCount ? ['rowCount'] : []),
108
45
  ];
109
46
  }
110
- function compactShape(plan, layout) {
111
- return (plan.focuses.every(({ position }) => position.mode === 'auto') &&
112
- plan.laneGroups.every(({ minimumX, maximumX }) => minimumX === undefined && maximumX === undefined) &&
113
- absoluteCompactRegressions(layout).length === 0);
114
- }
115
47
  function prerequisiteIds(plan, focusId) {
116
48
  const focus = plan.focuses.find(({ id }) => id === focusId);
117
49
  return (focus?.prerequisites.groups
@@ -161,11 +93,8 @@ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
161
93
  function mirrorSiblingCohorts(plan, preferredX, preferredY) {
162
94
  const cohorts = new Map();
163
95
  const laneBases = compactLaneBases(plan);
164
- const childCounts = new Map();
165
96
  for (const focus of plan.focuses) {
166
97
  const parents = prerequisiteIds(plan, focus.id);
167
- for (const parentId of parents)
168
- childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1);
169
98
  const key = JSON.stringify([
170
99
  parents.length === 0 ? focusLaneId(plan, focus) : null,
171
100
  parents,
@@ -184,11 +113,6 @@ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
184
113
  const focus = plan.focuses.find(({ id }) => id === cohort[0]);
185
114
  if (focus === undefined)
186
115
  continue;
187
- // A leaf fan can be mirrored directly around its structural parent. For
188
- // rooted subtrees, moving only the entry focuses would push the long
189
- // connectors into their descendants; keep the subtree envelope's center
190
- // until the bounded gateway refinement can move the common parent.
191
- const leafFan = cohort.every((focusId) => (childCounts.get(focusId) ?? 0) === 0);
192
116
  const mirroredAt = (centerTwice) => {
193
117
  const mirrored = [...coordinates];
194
118
  for (let index = 0; index < pairCount; index += 1) {
@@ -209,25 +133,42 @@ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
209
133
  }
210
134
  return mirrored;
211
135
  };
212
- const selfCenterTwice = (coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0);
213
136
  const anchorCenterTwice = 2 *
214
137
  compactStructuralAnchorX(plan, focus, prerequisiteIds(plan, focus.id), preferredX, laneBases);
215
- let mirrored = mirroredAt(leafFan ? anchorCenterTwice : selfCenterTwice);
216
- if (leafFan) {
217
- const cohortIds = new Set(cohort);
218
- const y = preferredY.get(cohort[0] ?? '');
219
- const otherCoordinates = plan.focuses.flatMap((candidate) => !cohortIds.has(candidate.id) && preferredY.get(candidate.id) === y
220
- ? [preferredX.get(candidate.id) ?? 0]
221
- : []);
222
- if (mirrored.some((coordinate) => otherCoordinates.some((otherCoordinate) => Math.abs(coordinate - otherCoordinate) < COMPACT_SAME_ROW_SPACING))) {
223
- mirrored = mirroredAt(selfCenterTwice);
224
- }
225
- }
138
+ const mirrored = mirroredAt(anchorCenterTwice);
226
139
  for (const [index, focusId] of cohort.entries())
227
140
  preferredX.set(focusId, mirrored[index] ?? 0);
228
141
  }
229
142
  }
230
- function compactCandidate(plan, layout, scale, compressRows) {
143
+ function straightenLinearChains(plan, preferredX, preferredY) {
144
+ const parentIds = new Map(plan.focuses.map((focus) => [focus.id, prerequisiteIds(plan, focus.id)]));
145
+ const childIds = new Map();
146
+ for (const [focusId, parents] of parentIds) {
147
+ for (const parentId of parents) {
148
+ const children = childIds.get(parentId) ?? [];
149
+ children.push(focusId);
150
+ childIds.set(parentId, children);
151
+ }
152
+ }
153
+ const ordered = [...plan.focuses].sort((left, right) => (preferredY.get(left.id) ?? 0) - (preferredY.get(right.id) ?? 0) ||
154
+ compareCodeUnits(left.id, right.id));
155
+ for (const focus of ordered) {
156
+ const parents = parentIds.get(focus.id) ?? [];
157
+ if (parents.length !== 1)
158
+ continue;
159
+ const parentId = parents[0];
160
+ if (parentId === undefined || (childIds.get(parentId)?.length ?? 0) !== 1)
161
+ continue;
162
+ const parentX = preferredX.get(parentId);
163
+ const parentY = preferredY.get(parentId);
164
+ if (parentX === undefined || parentY === undefined)
165
+ continue;
166
+ preferredX.set(focus.id, parentX);
167
+ preferredY.set(focus.id, parentY + 1);
168
+ }
169
+ }
170
+ function compactCandidate(plan, layout, strategy) {
171
+ const { scale, compressRows, mirrorSiblings, straightenChains } = strategy;
231
172
  const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
232
173
  const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
233
174
  const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
@@ -258,7 +199,10 @@ function compactCandidate(plan, layout, scale, compressRows) {
258
199
  for (const [index, node] of row.entries())
259
200
  preferredX.set(node.id, (placed[index] ?? 0) + drift);
260
201
  }
261
- mirrorSiblingCohorts(plan, preferredX, preferredY);
202
+ if (mirrorSiblings)
203
+ mirrorSiblingCohorts(plan, preferredX, preferredY);
204
+ if (straightenChains)
205
+ straightenLinearChains(plan, preferredX, preferredY);
262
206
  const compacted = structuredClone(plan);
263
207
  compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
264
208
  id,
@@ -278,14 +222,24 @@ function compactCandidate(plan, layout, scale, compressRows) {
278
222
  }
279
223
  function compactScore(layout) {
280
224
  const metrics = requiredMetrics(layout);
225
+ const hardDiagnosticCount = layout.diagnostics.filter(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code)).length;
226
+ const aestheticDiagnosticCount = layout.diagnostics.filter(({ code }) => COMPACT_AESTHETIC_DIAGNOSTICS.has(code)).length;
281
227
  return [
228
+ hardDiagnosticCount,
229
+ metrics.connectors.crossingCount,
230
+ metrics.connectors.nodeIntersectionCount,
231
+ metrics.spacing.tooCloseSameRowPairCount,
232
+ aestheticDiagnosticCount,
282
233
  metrics.connectors.longConnectorCount,
283
- metrics.connectors.maximumHorizontalSpan,
234
+ metrics.symmetry.asymmetricSiblingCohortCount,
235
+ metrics.symmetry.offAnchorSiblingCohortCount,
236
+ metrics.symmetry.totalSiblingDeviation,
237
+ metrics.symmetry.totalSiblingAnchorDeviation,
238
+ metrics.bounds.rowCount,
284
239
  metrics.bounds.columnCount,
285
- metrics.connectors.nodeIntersectionCount,
240
+ metrics.connectors.maximumHorizontalSpan,
286
241
  metrics.connectors.totalHorizontalSpan,
287
242
  metrics.connectors.maximumManhattanSpan,
288
- metrics.bounds.rowCount,
289
243
  metrics.connectors.totalVerticalSpan,
290
244
  layout.layoutHash,
291
245
  ];
@@ -311,44 +265,60 @@ function compactScales(focusCount) {
311
265
  ? [0.55, 0.65, 0.75]
312
266
  : [0.65];
313
267
  }
268
+ function compactStrategies(focusCount) {
269
+ const preservationStrategies = [
270
+ { scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: false },
271
+ { scale: 1, compressRows: true, mirrorSiblings: false, straightenChains: false },
272
+ { scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: true },
273
+ { scale: 1, compressRows: false, mirrorSiblings: true, straightenChains: false },
274
+ ];
275
+ const compactStrategies = compactScales(focusCount).flatMap((scale) => [
276
+ { scale, compressRows: false, mirrorSiblings: true, straightenChains: true },
277
+ { scale, compressRows: true, mirrorSiblings: true, straightenChains: true },
278
+ ]);
279
+ return [...preservationStrategies, ...compactStrategies];
280
+ }
281
+ function needsPresentationNormalization(plan) {
282
+ return (plan.focuses.some(({ position }) => position.mode !== 'auto') ||
283
+ plan.laneGroups.some(({ minimumX, maximumX }) => minimumX !== undefined || maximumX !== undefined));
284
+ }
314
285
  /**
315
286
  * Produces a gameplay-neutral compact-reflow plan. Several deterministic
316
- * compression candidates are measured with the same layout engine; only a
317
- * centered, crossing-free, evenly spaced, branch-balanced candidate that
318
- * stays within absolute and source-relative connector budgets can win.
287
+ * repair and compression candidates are measured with the same layout engine.
288
+ * Invalid geometry cannot win; aesthetic tradeoffs are resolved by the score.
319
289
  */
320
290
  export function compactFocusTreePlan(plan) {
321
- const currentLayout = layoutFocusTree(plan);
322
- if (compactShape(plan, currentLayout)) {
323
- const stable = structuredClone(plan);
324
- stable.provenance.importedPlanHash = focusPlanHash(stable);
325
- return stable;
326
- }
327
- const scales = compactScales(plan.focuses.length);
291
+ const currentLayout = layoutFocusTree(plan, { aggressiveAestheticRepair: true });
292
+ const normalizedPlan = needsPresentationNormalization(plan)
293
+ ? compactCandidate(plan, currentLayout, {
294
+ scale: 1,
295
+ compressRows: false,
296
+ mirrorSiblings: false,
297
+ straightenChains: false,
298
+ })
299
+ : plan;
300
+ const normalizedLayout = normalizedPlan === plan
301
+ ? currentLayout
302
+ : layoutFocusTree(normalizedPlan, { aggressiveAestheticRepair: true });
328
303
  let selected;
329
304
  let fallback;
330
- for (const scale of scales) {
331
- for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
332
- const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
333
- let layout;
334
- try {
335
- layout = layoutFocusTree(candidate);
336
- }
337
- catch (error) {
338
- if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
339
- continue;
340
- throw error;
341
- }
342
- if (fallback === undefined ||
343
- betterScore(compactScore(layout), compactScore(fallback.layout)))
344
- fallback = { plan: candidate, layout };
345
- if (absoluteCompactRegressions(layout).length > 0 ||
346
- relativeCompactRegressions(currentLayout, layout).length > 0)
305
+ for (const strategy of compactStrategies(plan.focuses.length)) {
306
+ const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
307
+ let layout;
308
+ try {
309
+ layout = layoutFocusTree(candidate, { aggressiveAestheticRepair: true });
310
+ }
311
+ catch (error) {
312
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
347
313
  continue;
348
- if (selected === undefined ||
349
- betterScore(compactScore(layout), compactScore(selected.layout)))
350
- selected = { plan: candidate, layout };
314
+ throw error;
351
315
  }
316
+ if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
317
+ fallback = { plan: candidate, layout };
318
+ if (absoluteCompactRegressions(layout).length > 0)
319
+ continue;
320
+ if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
321
+ selected = { plan: candidate, layout };
352
322
  }
353
323
  if (selected !== undefined)
354
324
  return selected.plan;
@@ -366,42 +336,44 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
366
336
  const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
367
337
  const layoutOptions = {
368
338
  workBudget,
339
+ aggressiveAestheticRepair: true,
369
340
  ...(options.signal === undefined ? {} : { signal: options.signal }),
370
341
  };
371
342
  const currentLayout = await layoutFocusTreeAsync(plan, layoutOptions);
372
- if (compactShape(plan, currentLayout)) {
373
- const stable = structuredClone(plan);
374
- stable.provenance.importedPlanHash = focusPlanHash(stable);
375
- return { plan: stable, currentLayout, proposedLayout: currentLayout };
376
- }
343
+ const normalizedPlan = needsPresentationNormalization(plan)
344
+ ? compactCandidate(plan, currentLayout, {
345
+ scale: 1,
346
+ compressRows: false,
347
+ mirrorSiblings: false,
348
+ straightenChains: false,
349
+ })
350
+ : plan;
351
+ const normalizedLayout = normalizedPlan === plan
352
+ ? currentLayout
353
+ : await layoutFocusTreeAsync(normalizedPlan, layoutOptions);
377
354
  let selected;
378
355
  let fallback;
379
356
  let exhausted;
380
- candidateSearch: for (const scale of compactScales(plan.focuses.length)) {
381
- for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
382
- options.signal?.throwIfAborted();
383
- const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
384
- let layout;
385
- try {
386
- layout = await layoutFocusTreeAsync(candidate, layoutOptions);
387
- }
388
- catch (error) {
389
- if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
390
- exhausted = error;
391
- break candidateSearch;
392
- }
393
- throw error;
357
+ candidateSearch: for (const strategy of compactStrategies(plan.focuses.length)) {
358
+ options.signal?.throwIfAborted();
359
+ const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
360
+ let layout;
361
+ try {
362
+ layout = await layoutFocusTreeAsync(candidate, layoutOptions);
363
+ }
364
+ catch (error) {
365
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
366
+ exhausted = error;
367
+ break candidateSearch;
394
368
  }
395
- if (fallback === undefined ||
396
- betterScore(compactScore(layout), compactScore(fallback.layout)))
397
- fallback = { plan: candidate, layout };
398
- if (absoluteCompactRegressions(layout).length > 0 ||
399
- relativeCompactRegressions(currentLayout, layout).length > 0)
400
- continue;
401
- if (selected === undefined ||
402
- betterScore(compactScore(layout), compactScore(selected.layout)))
403
- selected = { plan: candidate, layout };
369
+ throw error;
404
370
  }
371
+ if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
372
+ fallback = { plan: candidate, layout };
373
+ if (absoluteCompactRegressions(layout).length > 0)
374
+ continue;
375
+ if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
376
+ selected = { plan: candidate, layout };
405
377
  }
406
378
  if (selected !== undefined) {
407
379
  return { plan: selected.plan, currentLayout, proposedLayout: selected.layout };
@@ -414,19 +386,15 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
414
386
  }
415
387
  throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
416
388
  }
417
- /** Refuses compact rewrites that are not clean or trade readability for a smaller canvas. */
418
- export function assertCompactLayoutQuality(current, proposed) {
419
- const regressions = [
420
- ...absoluteCompactRegressions(proposed),
421
- ...(current === undefined ? [] : relativeCompactRegressions(current, proposed)),
422
- ].filter((value, index, all) => all.indexOf(value) === index);
389
+ /** Refuses compact rewrites that still contain invalid or unreadable geometry. */
390
+ export function assertCompactLayoutQuality(_current, proposed) {
391
+ const regressions = [...absoluteCompactRegressions(proposed)].filter((value, index, all) => all.indexOf(value) === index);
423
392
  if (regressions.length === 0)
424
393
  return;
425
- throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the absolute or relative layout-quality gate', {
394
+ throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the layout-correctness gate', {
426
395
  treeId: proposed.treeId,
427
396
  regressions,
428
- limits: compactLimits(proposed.nodes.length, requiredMetrics(proposed).connectors.count),
429
- ...(current === undefined ? {} : { before: requiredMetrics(current) }),
397
+ ...(_current === undefined ? {} : { before: requiredMetrics(_current) }),
430
398
  proposed: requiredMetrics(proposed),
431
399
  });
432
400
  }
@@ -1258,26 +1258,60 @@ function longConnectorSpans(spans) {
1258
1258
  function longConnector(edge) {
1259
1259
  return longConnectorSpans(connectorSpans(edge));
1260
1260
  }
1261
- function betterConnectorQualityScore(candidate, current) {
1261
+ function linearDetourCount(edges) {
1262
+ const incomingCount = new Map();
1263
+ const outgoingCount = new Map();
1264
+ for (const edge of edges) {
1265
+ incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
1266
+ outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
1267
+ }
1268
+ return edges.filter((edge) => {
1269
+ if ((outgoingCount.get(edge.parentId) ?? 0) !== 1 ||
1270
+ (incomingCount.get(edge.childId) ?? 0) !== 1)
1271
+ return false;
1272
+ const spans = connectorSpans(edge);
1273
+ return spans.horizontal !== 0 || spans.vertical !== 1;
1274
+ }).length;
1275
+ }
1276
+ function betterConnectorQualityScore(candidate, current, aggressiveAestheticRepair = false) {
1262
1277
  if (candidate.longConnectorCount > current.longConnectorCount)
1263
1278
  return false;
1264
- const fields = [
1265
- 'crossingCount',
1266
- 'nodeIntersectionCount',
1267
- 'maximumManhattanSpan',
1268
- 'maximumHorizontalSpan',
1269
- 'longConnectorCount',
1270
- 'totalManhattanSpan',
1271
- 'totalHorizontalSpan',
1272
- 'asymmetricSiblingCohortCount',
1273
- 'maximumSiblingDeviation',
1274
- 'totalSiblingDeviation',
1275
- 'offAnchorSiblingCohortCount',
1276
- 'maximumSiblingAnchorDeviation',
1277
- 'totalSiblingAnchorDeviation',
1278
- 'columnCount',
1279
- 'boundingCenterOffsetTwice',
1280
- ];
1279
+ const fields = aggressiveAestheticRepair
1280
+ ? [
1281
+ 'crossingCount',
1282
+ 'nodeIntersectionCount',
1283
+ 'longConnectorCount',
1284
+ 'linearDetourCount',
1285
+ 'asymmetricSiblingCohortCount',
1286
+ 'maximumSiblingDeviation',
1287
+ 'totalSiblingDeviation',
1288
+ 'offAnchorSiblingCohortCount',
1289
+ 'maximumSiblingAnchorDeviation',
1290
+ 'totalSiblingAnchorDeviation',
1291
+ 'maximumManhattanSpan',
1292
+ 'maximumHorizontalSpan',
1293
+ 'totalManhattanSpan',
1294
+ 'totalHorizontalSpan',
1295
+ 'columnCount',
1296
+ 'boundingCenterOffsetTwice',
1297
+ ]
1298
+ : [
1299
+ 'crossingCount',
1300
+ 'nodeIntersectionCount',
1301
+ 'maximumManhattanSpan',
1302
+ 'maximumHorizontalSpan',
1303
+ 'longConnectorCount',
1304
+ 'totalManhattanSpan',
1305
+ 'totalHorizontalSpan',
1306
+ 'asymmetricSiblingCohortCount',
1307
+ 'maximumSiblingDeviation',
1308
+ 'totalSiblingDeviation',
1309
+ 'offAnchorSiblingCohortCount',
1310
+ 'maximumSiblingAnchorDeviation',
1311
+ 'totalSiblingAnchorDeviation',
1312
+ 'columnCount',
1313
+ 'boundingCenterOffsetTwice',
1314
+ ];
1281
1315
  for (const field of fields) {
1282
1316
  if (candidate[field] === current[field])
1283
1317
  continue;
@@ -1324,8 +1358,10 @@ function* affectedNodeIntersectionCount(context, edges, focusId, x) {
1324
1358
  }
1325
1359
  return count;
1326
1360
  }
1327
- function* connectorNodeIntersectionSummary(context, edges) {
1328
- const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1361
+ function* connectorNodeIntersectionSummary(context, edges, override) {
1362
+ const nodes = [...context.placed.values()]
1363
+ .map((node) => ({ ...node, x: override?.get(node.id) ?? node.x }))
1364
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
1329
1365
  const involvedFocusIds = new Set();
1330
1366
  let count = 0;
1331
1367
  let comparisonIndex = 0;
@@ -1422,14 +1458,18 @@ function siblingQuality(context, override) {
1422
1458
  parentFocusIds,
1423
1459
  };
1424
1460
  }
1425
- function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCount, symmetry) {
1461
+ function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCount, symmetry, override) {
1426
1462
  const spans = edges.map(connectorSpans);
1427
- const nodes = [...context.placed.values()];
1463
+ const nodes = [...context.placed.values()].map((node) => ({
1464
+ ...node,
1465
+ x: override?.get(node.id) ?? node.x,
1466
+ }));
1428
1467
  const minimumX = Math.min(...nodes.map(({ x }) => x));
1429
1468
  const maximumX = Math.max(...nodes.map(({ x }) => x));
1430
1469
  return {
1431
1470
  crossingCount,
1432
1471
  nodeIntersectionCount,
1472
+ linearDetourCount: linearDetourCount(edges),
1433
1473
  asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
1434
1474
  maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
1435
1475
  totalSiblingDeviation: symmetry.totalSiblingDeviation,
@@ -1445,6 +1485,115 @@ function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCo
1445
1485
  boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
1446
1486
  };
1447
1487
  }
1488
+ function edgesWithXOverride(edges, override) {
1489
+ return edges.map((edge) => ({
1490
+ ...edge,
1491
+ parent: { ...edge.parent, x: override.get(edge.parentId) ?? edge.parent.x },
1492
+ child: { ...edge.child, x: override.get(edge.childId) ?? edge.child.x },
1493
+ }));
1494
+ }
1495
+ function siblingBatchConflicts(context, cohort, override) {
1496
+ const moved = [...override].flatMap(([focusId, x]) => {
1497
+ const node = context.placed.get(focusId);
1498
+ return node === undefined ? [] : [{ ...node, x }];
1499
+ });
1500
+ for (const node of moved) {
1501
+ if (!coordinateWithinLane(context, node.id, node.x))
1502
+ return true;
1503
+ for (const other of context.placed.values()) {
1504
+ if (cohort.has(other.id) || other.y !== node.y)
1505
+ continue;
1506
+ if (Math.abs(other.x - node.x) < context.nodeSpacing)
1507
+ return true;
1508
+ }
1509
+ for (const excludedId of context.mutualExclusions.get(node.id) ?? []) {
1510
+ const other = context.placed.get(excludedId);
1511
+ if (other === undefined)
1512
+ continue;
1513
+ const otherX = override.get(excludedId) ?? other.x;
1514
+ if (Math.abs(otherX - node.x) < context.minimumMutualExclusionSpacing)
1515
+ return true;
1516
+ }
1517
+ }
1518
+ const ordered = [...moved].sort((left, right) => left.y - right.y || left.x - right.x || compareCodeUnits(left.id, right.id));
1519
+ for (let index = 1; index < ordered.length; index += 1) {
1520
+ const left = ordered[index - 1];
1521
+ const right = ordered[index];
1522
+ if (left?.y === right?.y &&
1523
+ left !== undefined &&
1524
+ right !== undefined &&
1525
+ Math.abs(right.x - left.x) < context.nodeSpacing)
1526
+ return true;
1527
+ }
1528
+ return false;
1529
+ }
1530
+ function* refineSiblingCohorts(context, edges, initial) {
1531
+ let current = initial;
1532
+ for (let pass = 0; pass < 2; pass += 1) {
1533
+ let accepted = false;
1534
+ for (const [cohortIndex, cohort] of context.siblingCohorts.entries()) {
1535
+ yield* cancellationCheckpoint(context.signal, cohortIndex, 8);
1536
+ if (cohort.some((focusId) => !canMoveForSoftConstraint(context, focusId)))
1537
+ continue;
1538
+ const nodes = cohort
1539
+ .flatMap((focusId) => {
1540
+ const node = context.placed.get(focusId);
1541
+ return node === undefined ? [] : [node];
1542
+ })
1543
+ .sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
1544
+ if (nodes.length < 2)
1545
+ continue;
1546
+ const { anchorX } = siblingCohortAnchor(context, cohort);
1547
+ const currentSpan = (nodes.at(-1)?.x ?? 0) - (nodes[0]?.x ?? 0);
1548
+ const spacing = Math.max(context.nodeSpacing, Math.ceil(currentSpan / Math.max(1, nodes.length - 1)));
1549
+ const cohortIds = new Set(cohort);
1550
+ const internalCenter = ((nodes[0]?.x ?? 0) + (nodes.at(-1)?.x ?? 0)) / 2;
1551
+ let best;
1552
+ for (const center of [...new Set([internalCenter, anchorX])]) {
1553
+ const override = new Map();
1554
+ for (const [index, node] of nodes.entries()) {
1555
+ override.set(node.id, Math.round(center + (index - (nodes.length - 1) / 2) * spacing));
1556
+ }
1557
+ if (nodes.every((node) => override.get(node.id) === node.x))
1558
+ continue;
1559
+ if (siblingBatchConflicts(context, cohortIds, override))
1560
+ continue;
1561
+ const candidateEdges = edgesWithXOverride(edges, override);
1562
+ const crossingSummary = yield* connectorCrossings(candidateEdges, context);
1563
+ if (crossingSummary.count > current.crossingCount)
1564
+ continue;
1565
+ const intersectionSummary = yield* connectorNodeIntersectionSummary(context, candidateEdges, override);
1566
+ if (intersectionSummary.count > current.nodeIntersectionCount)
1567
+ continue;
1568
+ const symmetry = siblingQuality(context, override);
1569
+ const score = connectorQualityScore(context, candidateEdges, crossingSummary.count, intersectionSummary.count, symmetry, override);
1570
+ if (!betterConnectorQualityScore(score, current, true))
1571
+ continue;
1572
+ if (best !== undefined && !betterConnectorQualityScore(score, best.score, true))
1573
+ continue;
1574
+ best = { override, score };
1575
+ }
1576
+ if (best === undefined)
1577
+ continue;
1578
+ for (const node of nodes) {
1579
+ const x = best.override.get(node.id);
1580
+ if (x === undefined || x === node.x)
1581
+ continue;
1582
+ context.decisions.push({
1583
+ focusId: node.id,
1584
+ kind: 'moved_to_improve_connector_quality',
1585
+ message: `Sibling-balance refinement moved (${node.x}, ${node.y}) to (${x}, ${node.y})`,
1586
+ });
1587
+ movePlacedFocusX(context, node, x);
1588
+ }
1589
+ current = best.score;
1590
+ accepted = true;
1591
+ }
1592
+ if (!accepted)
1593
+ break;
1594
+ }
1595
+ return current;
1596
+ }
1448
1597
  function candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections) {
1449
1598
  const incidentEdges = edges.filter((edge) => edge.parentId === focusId || edge.childId === focusId);
1450
1599
  const nonIncidentEdges = edges.filter((edge) => edge.parentId !== focusId && edge.childId !== focusId);
@@ -1471,6 +1620,7 @@ function candidateQualityScore(context, edges, current, focusId, x, currentAffec
1471
1620
  nodeIntersectionCount: current.nodeIntersectionCount -
1472
1621
  currentAffectedNodeIntersections +
1473
1622
  candidateAffectedNodeIntersections,
1623
+ linearDetourCount: linearDetourCount([...nonIncidentEdges, ...candidateIncidentEdges]),
1474
1624
  asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
1475
1625
  maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
1476
1626
  totalSiblingDeviation: symmetry.totalSiblingDeviation,
@@ -1492,29 +1642,67 @@ function candidateQualityScore(context, edges, current, focusId, x, currentAffec
1492
1642
  }
1493
1643
  const CONNECTOR_REFINEMENT_WORK_MAX = 7_000_000;
1494
1644
  function* refineConnectorQualityWithinBudget(context) {
1495
- const maximumAcceptedMoves = 24;
1496
- const maximumCandidateNodes = 32;
1645
+ const maximumAcceptedMoves = context.aggressiveAestheticRepair ? 32 : 24;
1646
+ const maximumCandidateNodes = context.aggressiveAestheticRepair ? 48 : 32;
1497
1647
  const edges = yield* connectorEdges(context);
1498
1648
  const crossingSummary = yield* connectorCrossings(edges, context);
1499
1649
  const intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
1500
1650
  const symmetry = siblingQuality(context);
1501
1651
  let current = connectorQualityScore(context, edges, crossingSummary.count, intersectionSummary.count, symmetry);
1502
- const siblingMembers = new Set(context.siblingCohorts.flat());
1652
+ if (context.aggressiveAestheticRepair)
1653
+ current = yield* refineSiblingCohorts(context, edges, current);
1503
1654
  const candidateIds = new Set(intersectionSummary.involvedFocusIds);
1655
+ const longFocusIds = new Set();
1504
1656
  for (const edge of edges.filter(longConnector)) {
1505
1657
  candidateIds.add(edge.parentId);
1506
1658
  candidateIds.add(edge.childId);
1659
+ longFocusIds.add(edge.parentId);
1660
+ longFocusIds.add(edge.childId);
1661
+ }
1662
+ const siblingFocusIds = new Set(context.siblingCohorts.flat());
1663
+ if (context.aggressiveAestheticRepair) {
1664
+ for (const cohort of context.siblingCohorts) {
1665
+ for (const focusId of cohort) {
1666
+ candidateIds.add(focusId);
1667
+ }
1668
+ }
1507
1669
  }
1508
1670
  for (const parentId of symmetry.parentFocusIds)
1509
1671
  candidateIds.add(parentId);
1672
+ const incomingCount = new Map();
1673
+ const outgoingCount = new Map();
1674
+ const linearFocusIds = new Set();
1675
+ for (const edge of context.aggressiveAestheticRepair ? edges : []) {
1676
+ incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
1677
+ outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
1678
+ }
1679
+ for (const edge of edges) {
1680
+ if ((outgoingCount.get(edge.parentId) ?? 0) !== 1 ||
1681
+ (incomingCount.get(edge.childId) ?? 0) !== 1)
1682
+ continue;
1683
+ const spans = connectorSpans(edge);
1684
+ if (spans.horizontal === 0 && spans.vertical === 1)
1685
+ continue;
1686
+ candidateIds.add(edge.parentId);
1687
+ candidateIds.add(edge.childId);
1688
+ linearFocusIds.add(edge.parentId);
1689
+ linearFocusIds.add(edge.childId);
1690
+ }
1510
1691
  const orderedCandidateIds = [...candidateIds]
1511
1692
  .filter((focusId) => {
1512
1693
  const degree = context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
1513
- return (canMoveForSoftConstraint(context, focusId) && !siblingMembers.has(focusId) && degree !== 1);
1694
+ return (canMoveForSoftConstraint(context, focusId) &&
1695
+ (context.aggressiveAestheticRepair || (!siblingFocusIds.has(focusId) && degree !== 1)));
1514
1696
  })
1515
1697
  .sort((left, right) => {
1516
1698
  const degree = (focusId) => context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
1517
- return degree(right) - degree(left) || compareCodeUnits(left, right);
1699
+ const priority = (focusId) => (intersectionSummary.involvedFocusIds.has(focusId) ? 8 : 0) +
1700
+ (longFocusIds.has(focusId) ? 4 : 0) +
1701
+ (linearFocusIds.has(focusId) ? 2 : 0) +
1702
+ (siblingFocusIds.has(focusId) ? 1 : 0);
1703
+ return (priority(right) - priority(left) ||
1704
+ degree(right) - degree(left) ||
1705
+ compareCodeUnits(left, right));
1518
1706
  })
1519
1707
  .slice(0, maximumCandidateNodes);
1520
1708
  for (let acceptedMoves = 0; acceptedMoves < maximumAcceptedMoves; acceptedMoves += 1) {
@@ -1551,9 +1739,10 @@ function* refineConnectorQualityWithinBudget(context) {
1551
1739
  continue;
1552
1740
  const candidateAffectedNodeIntersections = yield* affectedNodeIntersectionCount(context, edges, focusId, x);
1553
1741
  const score = candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections);
1554
- if (!betterConnectorQualityScore(score, current))
1742
+ if (!betterConnectorQualityScore(score, current, context.aggressiveAestheticRepair))
1555
1743
  continue;
1556
- if (best !== undefined && !betterConnectorQualityScore(score, best.score))
1744
+ if (best !== undefined &&
1745
+ !betterConnectorQualityScore(score, best.score, context.aggressiveAestheticRepair))
1557
1746
  continue;
1558
1747
  best = {
1559
1748
  focusId,
@@ -1620,6 +1809,76 @@ function* connectorLengthDiagnostics(context, edges) {
1620
1809
  });
1621
1810
  }
1622
1811
  }
1812
+ function* fakeComplexityDiagnostics(context, edges) {
1813
+ const incomingCount = new Map();
1814
+ const outgoingCount = new Map();
1815
+ for (const edge of edges) {
1816
+ incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
1817
+ outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
1818
+ }
1819
+ const linearEdges = edges.filter(({ parentId, childId }) => (outgoingCount.get(parentId) ?? 0) === 1 && (incomingCount.get(childId) ?? 0) === 1);
1820
+ const next = new Map(linearEdges.map((edge) => [edge.parentId, edge]));
1821
+ const linearChildren = new Set(linearEdges.map(({ childId }) => childId));
1822
+ for (const [edgeIndex, edge] of linearEdges.entries()) {
1823
+ yield* cancellationCheckpoint(context.signal, edgeIndex, 32);
1824
+ const spans = connectorSpans(edge);
1825
+ if (spans.horizontal === 0 && spans.vertical === 1)
1826
+ continue;
1827
+ const focus = context.focuses.get(edge.childId);
1828
+ context.diagnostics.push({
1829
+ code: 'FOCUS_LAYOUT_LINEAR_DETOUR',
1830
+ severity: 'warning',
1831
+ category: 'layout',
1832
+ message: `Mechanically linear connector ${edge.parentId} -> ${edge.childId} uses an unnecessary visual detour`,
1833
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1834
+ details: {
1835
+ parentId: edge.parentId,
1836
+ childId: edge.childId,
1837
+ horizontalSpan: spans.horizontal,
1838
+ verticalSpan: spans.vertical,
1839
+ expectedHorizontalSpan: 0,
1840
+ expectedVerticalSpan: 1,
1841
+ },
1842
+ });
1843
+ }
1844
+ const starts = linearEdges
1845
+ .filter(({ parentId }) => !linearChildren.has(parentId))
1846
+ .sort((left, right) => compareCodeUnits(left.parentId, right.parentId));
1847
+ for (const start of starts) {
1848
+ const chain = [];
1849
+ const visited = new Set();
1850
+ let current = start;
1851
+ while (current !== undefined && !visited.has(current.parentId)) {
1852
+ visited.add(current.parentId);
1853
+ chain.push(current);
1854
+ current = next.get(current.childId);
1855
+ }
1856
+ const horizontalDirections = chain
1857
+ .map(({ parent, child }) => Math.sign(child.x - parent.x))
1858
+ .filter((direction) => direction !== 0);
1859
+ if (horizontalDirections.length < 2)
1860
+ continue;
1861
+ const sameDirection = horizontalDirections.every((direction) => direction === horizontalDirections[0]);
1862
+ const alternating = horizontalDirections.every((direction, index) => index === 0 || direction === -horizontalDirections[index - 1]);
1863
+ const code = sameDirection
1864
+ ? 'FOCUS_LAYOUT_STAIRCASE_CHAIN'
1865
+ : alternating
1866
+ ? 'FOCUS_LAYOUT_ZIGZAG_CHAIN'
1867
+ : undefined;
1868
+ if (code === undefined)
1869
+ continue;
1870
+ const focusIds = [chain[0].parentId, ...chain.map(({ childId }) => childId)];
1871
+ const focus = context.focuses.get(focusIds.at(-1) ?? '');
1872
+ context.diagnostics.push({
1873
+ code,
1874
+ severity: 'warning',
1875
+ category: 'layout',
1876
+ message: `${sameDirection ? 'Staircase' : 'Zigzag'} geometry makes the linear chain ${focusIds.join(' -> ')} look artificially complex`,
1877
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1878
+ details: { focusIds, horizontalDirections },
1879
+ });
1880
+ }
1881
+ }
1623
1882
  function siblingCohortAnchor(context, cohort) {
1624
1883
  const focus = context.focuses.get(cohort[0] ?? '');
1625
1884
  const coordinates = cohort
@@ -1885,6 +2144,7 @@ function* layoutFocusTreeSteps(plan, options = {}) {
1885
2144
  siblingCohorts: siblingLayout.cohorts,
1886
2145
  nodeSpacing,
1887
2146
  minimumMutualExclusionSpacing: nodeSpacing,
2147
+ aggressiveAestheticRepair: options.aggressiveAestheticRepair ?? false,
1888
2148
  decisions: [],
1889
2149
  diagnostics,
1890
2150
  work: options.workBudget ?? new FocusLayoutWorkBudget(),
@@ -1917,6 +2177,7 @@ function* layoutFocusTreeSteps(plan, options = {}) {
1917
2177
  const nodeIntersectionCount = yield* connectorNodeIntersectionDiagnostics(context, edges);
1918
2178
  yield* connectorCrossingDiagnostics(context, crossingSummary.samples);
1919
2179
  yield* connectorLengthDiagnostics(context, edges);
2180
+ yield* fakeComplexityDiagnostics(context, edges);
1920
2181
  const symmetry = yield* siblingSymmetryDiagnostics(context);
1921
2182
  const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1922
2183
  const decisions = [...context.decisions].sort((left, right) => compareCodeUnits(left.focusId, right.focusId));
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '2.3.10';
3
+ export const PACKAGE_VERSION = '2.3.12';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/focus.md CHANGED
@@ -30,7 +30,7 @@ For a layout-only cleanup of an existing national tree, call `hoi4.focus_rewrite
30
30
 
31
31
  Compact mode imports the tree, resets authored presentation coordinates and lane bounds, and performs a deterministic reflow in the same rewrite call. It keeps prerequisites, mutual exclusions, availability, bypasses, rewards, icons, localisation, AI behavior, and route/lane membership unchanged.
32
32
 
33
- The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, and overall centering. A bounded refinement pass moves automatic gateways and blockers only when the measured connector result improves. The rewrite refuses changes with close focuses, rendered crossings, excessive branch imbalance, an off-center canvas, or connector and size metrics outside absolute and source-relative limits.
33
+ The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode evaluates preservation, row repair, linear-chain repair, sibling balancing, and combined compression strategies, then chooses the cleanest valid result. It refuses invalid geometry such as overlapping or cramped focuses, bad parent order, rendered crossings, connectors through unrelated focuses, or an off-center canvas. Long connectors, fake detours, staircase or zigzag chains, and avoidable sibling imbalance are optimization priorities instead of independent rejection budgets.
34
34
 
35
35
  Use the default `layoutMode: "authored"` with a complete plan when deliberately designing coordinates or changing gameplay. Set `layoutMode: "compact"` with a complete plan when creating a large automatically arranged tree. Inspect and render calls remain useful before or after either rewrite mode, but a separate plan is not required for compact cleanup.
36
36
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hoi4-agent-tools",
3
3
  "mcpName": "io.github.klimPaskov/hoi4-agent-tools",
4
- "version": "2.3.10",
4
+ "version": "2.3.12",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/configuration.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/continuous-focus-palette.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/custom-weighted-pool.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/event-feature-manifest.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/focus-plan.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/focus-planning-sidecar.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/gui-animation-source.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/gui-helper.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/gui-scenario.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/map-operation.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "oneOf": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/operation-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-analysis-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-analysis-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-compare-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-compare-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-evaluate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-evaluate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-inspect-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-inspect-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-render-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-render-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-scenario-set.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-scenario-set.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-sequence-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-sequence-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-simulate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-simulate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.10/schemas/probability-sweep-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.12/schemas/probability-sweep-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.klimPaskov/hoi4-agent-tools",
4
4
  "title": "HOI4 Agent Tools",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
- "version": "2.3.10",
6
+ "version": "2.3.12",
7
7
  "repository": {
8
8
  "url": "https://github.com/klimPaskov/hoi4-agent-tools",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "hoi4-agent-tools",
16
- "version": "2.3.10",
16
+ "version": "2.3.12",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }