hoi4-agent-tools 3.3.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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +13 -8
  3. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  4. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  5. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  6. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  7. package/dist/hoi4_agent_tools/event/service.js +21 -2
  8. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  9. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  10. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  11. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  12. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  13. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  14. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  15. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  16. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  17. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  18. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  19. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  20. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  21. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  22. package/dist/hoi4_agent_tools/map/render.js +70 -1
  23. package/dist/hoi4_agent_tools/map/service.js +30 -6
  24. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  25. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  26. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  27. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  29. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  30. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
  31. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  32. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  33. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  34. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  35. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  36. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  38. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  39. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  40. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  41. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  42. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  43. package/dist/hoi4_agent_tools/version.js +1 -1
  44. package/docs/events.md +3 -1
  45. package/docs/focus.md +10 -0
  46. package/docs/gui.md +49 -3
  47. package/docs/map.md +8 -0
  48. package/docs/probability.md +3 -1
  49. package/docs/technology.md +7 -2
  50. package/package.json +1 -1
  51. package/schemas/configuration.schema.json +1 -1
  52. package/schemas/continuous-focus-palette.schema.json +1 -1
  53. package/schemas/custom-weighted-pool.schema.json +1 -1
  54. package/schemas/decision-inspect-input.schema.json +1 -1
  55. package/schemas/event-feature-manifest.schema.json +1 -1
  56. package/schemas/focus-plan.schema.json +1 -1
  57. package/schemas/focus-planning-sidecar.schema.json +1 -1
  58. package/schemas/gui-animation-source.schema.json +1 -1
  59. package/schemas/gui-helper.schema.json +1 -1
  60. package/schemas/gui-scenario.schema.json +1 -1
  61. package/schemas/helper-expansion-request.schema.json +1 -1
  62. package/schemas/helper-expansion-summary.schema.json +1 -1
  63. package/schemas/impact-inspect-input.schema.json +1 -1
  64. package/schemas/map-operation.schema.json +1 -1
  65. package/schemas/mechanic-test-input.schema.json +1 -1
  66. package/schemas/operation-result.schema.json +1 -1
  67. package/schemas/package-check-input.schema.json +1 -1
  68. package/schemas/package-manifest.schema.json +1 -1
  69. package/schemas/probability-analysis-result.schema.json +1 -1
  70. package/schemas/probability-compare-input.schema.json +1 -1
  71. package/schemas/probability-evaluate-input.schema.json +1 -1
  72. package/schemas/probability-inspect-input.schema.json +1 -1
  73. package/schemas/probability-render-input.schema.json +1 -1
  74. package/schemas/probability-scenario-set.schema.json +1 -1
  75. package/schemas/probability-sequence-input.schema.json +1 -1
  76. package/schemas/probability-simulate-input.schema.json +1 -1
  77. package/schemas/probability-sweep-input.schema.json +1 -1
  78. package/schemas/scenario-suite.schema.json +1 -1
  79. package/schemas/scenario-test-input.schema.json +1 -1
  80. package/schemas/shared-scenario.schema.json +1 -1
  81. package/server.json +2 -2
@@ -226,18 +226,155 @@ function straightenLinearChains(plan, preferredX, preferredY) {
226
226
  preferredY.set(focus.id, parentY + 1);
227
227
  }
228
228
  }
229
- function compactCandidate(plan, layout, strategy) {
229
+ function validatedSelection(plan, selection) {
230
+ if (selection.focusIds?.length === 0)
231
+ throw new ServiceError('FOCUS_COMPACT_SELECTION_EMPTY', 'Compact focus selection requires at least one focus');
232
+ const known = new Set(plan.focuses.map(({ id }) => id));
233
+ for (const id of [...(selection.focusIds ?? []), ...(selection.pinnedFocusIds ?? [])])
234
+ if (!known.has(id))
235
+ throw new ServiceError('FOCUS_COMPACT_SELECTION_UNKNOWN', 'Compact focus selection names an unknown focus', { treeId: plan.id, focusId: id });
236
+ const used = new Set();
237
+ for (const group of selection.symmetryGroups ?? []) {
238
+ if (!known.has(group.centerFocusId))
239
+ throw new ServiceError('FOCUS_COMPACT_SYMMETRY_UNKNOWN', 'Symmetry center is unknown', {
240
+ treeId: plan.id,
241
+ focusId: group.centerFocusId,
242
+ });
243
+ for (const pair of group.pairs) {
244
+ for (const id of [pair.leftFocusId, pair.rightFocusId]) {
245
+ if (!known.has(id))
246
+ throw new ServiceError('FOCUS_COMPACT_SYMMETRY_UNKNOWN', 'Symmetry focus is unknown', {
247
+ treeId: plan.id,
248
+ focusId: id,
249
+ });
250
+ if (id === group.centerFocusId || used.has(id))
251
+ throw new ServiceError('FOCUS_COMPACT_SYMMETRY_DUPLICATE', 'A symmetry focus must belong to one pair and differ from its center', { treeId: plan.id, focusId: id });
252
+ used.add(id);
253
+ }
254
+ }
255
+ }
256
+ return {
257
+ ...(selection.focusIds === undefined ? {} : { focusIds: new Set(selection.focusIds) }),
258
+ pinnedFocusIds: new Set(selection.pinnedFocusIds ?? []),
259
+ symmetryGroups: selection.symmetryGroups ?? [],
260
+ };
261
+ }
262
+ function symmetryViolations(layout, groups) {
263
+ const positions = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
264
+ return groups.flatMap(({ centerFocusId, pairs }) => {
265
+ const center = positions.get(centerFocusId);
266
+ if (center === undefined)
267
+ return [];
268
+ return pairs.flatMap(({ leftFocusId, rightFocusId }) => {
269
+ const left = positions.get(leftFocusId);
270
+ const right = positions.get(rightFocusId);
271
+ if (left === undefined || right === undefined)
272
+ return [];
273
+ const xOffset = left.x + right.x - 2 * center.x;
274
+ const yOffset = left.y - right.y;
275
+ return Math.abs(xOffset) <= 1 && yOffset === 0
276
+ ? []
277
+ : [{ centerFocusId, leftFocusId, rightFocusId, xOffset, yOffset }];
278
+ });
279
+ });
280
+ }
281
+ function applySymmetryGroups(selection, layout, preferredX, preferredY) {
282
+ const original = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
283
+ const fixed = (id) => selection.pinnedFocusIds.has(id) || selection.focusIds?.has(id) === false;
284
+ for (const { centerFocusId, pairs } of selection.symmetryGroups) {
285
+ const centerX = fixed(centerFocusId)
286
+ ? original.get(centerFocusId)?.x
287
+ : preferredX.get(centerFocusId);
288
+ if (centerX === undefined)
289
+ continue;
290
+ for (const { leftFocusId, rightFocusId } of pairs) {
291
+ const leftFixed = fixed(leftFocusId);
292
+ const rightFixed = fixed(rightFocusId);
293
+ const leftX = leftFixed ? original.get(leftFocusId)?.x : preferredX.get(leftFocusId);
294
+ const rightX = rightFixed ? original.get(rightFocusId)?.x : preferredX.get(rightFocusId);
295
+ if (leftX === undefined || rightX === undefined)
296
+ continue;
297
+ if (!leftFixed && !rightFixed) {
298
+ const distance = Math.max(1, Math.round((rightX - leftX) / 2));
299
+ preferredX.set(leftFocusId, centerX - distance);
300
+ preferredX.set(rightFocusId, centerX + distance);
301
+ }
302
+ else if (!leftFixed)
303
+ preferredX.set(leftFocusId, 2 * centerX - rightX);
304
+ else if (!rightFixed)
305
+ preferredX.set(rightFocusId, 2 * centerX - leftX);
306
+ const leftY = leftFixed ? original.get(leftFocusId)?.y : preferredY.get(leftFocusId);
307
+ const rightY = rightFixed ? original.get(rightFocusId)?.y : preferredY.get(rightFocusId);
308
+ if (leftY === undefined || rightY === undefined)
309
+ continue;
310
+ const row = leftFixed ? leftY : rightFixed ? rightY : Math.round((leftY + rightY) / 2);
311
+ if (!leftFixed)
312
+ preferredY.set(leftFocusId, row);
313
+ if (!rightFixed)
314
+ preferredY.set(rightFocusId, row);
315
+ }
316
+ }
317
+ }
318
+ function scopedPrevious(current, focusIds) {
319
+ return { ...current, nodes: current.nodes.filter(({ id }) => !focusIds.has(id)) };
320
+ }
321
+ function movedOutsideSelection(current, proposed, focusIds, pinnedFocusIds) {
322
+ const fixed = new Set([
323
+ ...current.nodes.filter(({ id }) => focusIds?.has(id) === false).map(({ id }) => id),
324
+ ...pinnedFocusIds,
325
+ ]);
326
+ const before = new Map(current.nodes.map(({ id, x, y }) => [id, { x, y }]));
327
+ return proposed.nodes
328
+ .filter(({ id, x, y }) => {
329
+ const original = before.get(id);
330
+ return fixed.has(id) && (original?.x !== x || original.y !== y);
331
+ })
332
+ .map(({ id }) => id)
333
+ .sort(compareCodeUnits);
334
+ }
335
+ function relativeCompactRegressions(current, proposed) {
336
+ const before = requiredMetrics(current);
337
+ const after = requiredMetrics(proposed);
338
+ const hard = (layout) => layout.diagnostics.filter(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code)).length;
339
+ return [
340
+ ...(hard(proposed) > hard(current) ? ['hardLayoutDiagnostics'] : []),
341
+ ...(after.connectors.crossingCount > before.connectors.crossingCount
342
+ ? ['connectorCrossingCount']
343
+ : []),
344
+ ...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
345
+ ? ['connectorNodeIntersections']
346
+ : []),
347
+ ...(after.spacing.tooCloseSameRowPairCount > before.spacing.tooCloseSameRowPairCount
348
+ ? ['sameRowSpacing']
349
+ : []),
350
+ ];
351
+ }
352
+ function compactCandidate(plan, layout, strategy, selection = {
353
+ pinnedFocusIds: new Set(),
354
+ symmetryGroups: [],
355
+ }) {
230
356
  const { scale, compressRows, structuralRows: useStructuralRows, mirrorSiblings, straightenChains, } = strategy;
231
- const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
232
- const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
233
- const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
357
+ const scopeNodes = selection.focusIds === undefined
358
+ ? layout.nodes
359
+ : layout.nodes.filter(({ id }) => selection.focusIds.has(id));
360
+ const minimumX = Math.min(...scopeNodes.map(({ x }) => x));
361
+ const maximumX = Math.max(...scopeNodes.map(({ x }) => x));
362
+ const minimumY = Math.min(...scopeNodes.map(({ y }) => y));
234
363
  const centerX = (minimumX + maximumX) / 2;
235
- const preferredX = new Map(layout.nodes.map((node) => [node.id, Math.round((node.x - centerX) * scale)]));
236
- const sourceRows = [...new Set(layout.nodes.map(({ y }) => y))].sort((left, right) => left - right);
364
+ const preferredX = new Map(layout.nodes.map((node) => [
365
+ node.id,
366
+ Math.round((node.x - centerX) * scale + (selection.focusIds === undefined ? 0 : centerX)),
367
+ ]));
368
+ const sourceRows = [...new Set(scopeNodes.map(({ y }) => y))].sort((left, right) => left - right);
237
369
  const compactRows = new Map(sourceRows.map((row, index) => [row, index]));
238
370
  const preferredY = new Map(layout.nodes.map((node) => [
239
371
  node.id,
240
- compressRows ? (compactRows.get(node.y) ?? node.y - minimumY) : node.y - minimumY,
372
+ compressRows
373
+ ? (compactRows.get(node.y) ?? node.y - minimumY) +
374
+ (selection.focusIds === undefined ? 0 : minimumY)
375
+ : selection.focusIds === undefined
376
+ ? node.y - minimumY
377
+ : node.y,
241
378
  ]));
242
379
  if (useStructuralRows) {
243
380
  const normalizedRows = structuralRows(plan);
@@ -270,13 +407,28 @@ function compactCandidate(plan, layout, strategy) {
270
407
  mirrorSiblingCohorts(plan, preferredX, preferredY);
271
408
  if (straightenChains)
272
409
  straightenLinearChains(plan, preferredX, preferredY);
410
+ applySymmetryGroups(selection, layout, preferredX, preferredY);
273
411
  const compacted = structuredClone(plan);
274
- compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
275
- id,
276
- label,
277
- order,
278
- }));
412
+ if (selection.focusIds === undefined)
413
+ compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
414
+ id,
415
+ label,
416
+ order,
417
+ }));
418
+ const currentCoordinates = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
279
419
  for (const focus of compacted.focuses) {
420
+ if (selection.focusIds !== undefined && !selection.focusIds.has(focus.id))
421
+ continue;
422
+ if (selection.pinnedFocusIds.has(focus.id)) {
423
+ if (focus.position.mode === 'auto') {
424
+ const point = currentCoordinates.get(focus.id);
425
+ if (point !== undefined)
426
+ focus.position = { mode: 'fixed', ...point, pinned: true };
427
+ }
428
+ else
429
+ focus.position = { ...focus.position, pinned: true };
430
+ continue;
431
+ }
280
432
  focus.position = {
281
433
  mode: 'auto',
282
434
  pinned: false,
@@ -390,7 +542,8 @@ function needsPresentationNormalization(plan) {
390
542
  * repair and compression candidates are measured with the same layout engine.
391
543
  * Invalid geometry cannot win; aesthetic tradeoffs are resolved by the score.
392
544
  */
393
- export function compactFocusTreePlan(plan) {
545
+ export function compactFocusTreePlan(plan, requested = {}) {
546
+ const selection = validatedSelection(plan, requested);
394
547
  const currentLayout = layoutFocusTree(plan, { aggressiveAestheticRepair: true });
395
548
  const normalizedPlan = needsPresentationNormalization(plan)
396
549
  ? compactCandidate(plan, currentLayout, {
@@ -399,27 +552,51 @@ export function compactFocusTreePlan(plan) {
399
552
  structuralRows: false,
400
553
  mirrorSiblings: false,
401
554
  straightenChains: false,
402
- })
555
+ }, selection)
403
556
  : plan;
404
557
  const normalizedLayout = normalizedPlan === plan
405
558
  ? currentLayout
406
- : layoutFocusTree(normalizedPlan, { aggressiveAestheticRepair: true });
559
+ : layoutFocusTree(normalizedPlan, {
560
+ aggressiveAestheticRepair: true,
561
+ ...(selection.focusIds === undefined
562
+ ? {}
563
+ : { previous: scopedPrevious(currentLayout, selection.focusIds) }),
564
+ });
407
565
  let selected;
408
566
  let fallback;
567
+ const blockedFocusIds = new Set();
568
+ let blockedSymmetry = [];
409
569
  for (const strategy of compactStrategies(plan.focuses.length)) {
410
- const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
570
+ const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy, selection);
411
571
  let layout;
412
572
  try {
413
- layout = layoutFocusTree(candidate, { aggressiveAestheticRepair: true });
573
+ layout = layoutFocusTree(candidate, {
574
+ aggressiveAestheticRepair: true,
575
+ ...(selection.focusIds === undefined
576
+ ? {}
577
+ : { previous: scopedPrevious(currentLayout, selection.focusIds) }),
578
+ });
414
579
  }
415
580
  catch (error) {
416
581
  if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
417
582
  continue;
418
583
  throw error;
419
584
  }
585
+ const moved = movedOutsideSelection(currentLayout, layout, selection.focusIds, selection.pinnedFocusIds);
586
+ for (const id of moved)
587
+ blockedFocusIds.add(id);
588
+ if (moved.length > 0)
589
+ continue;
590
+ const symmetry = symmetryViolations(layout, selection.symmetryGroups);
591
+ if (symmetry.length > 0) {
592
+ blockedSymmetry = symmetry;
593
+ continue;
594
+ }
420
595
  if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
421
596
  fallback = { plan: candidate, layout };
422
- if (absoluteCompactRegressions(layout).length > 0)
597
+ if ((selection.focusIds === undefined
598
+ ? absoluteCompactRegressions(layout)
599
+ : relativeCompactRegressions(currentLayout, layout)).length > 0)
423
600
  continue;
424
601
  if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
425
602
  selected = { plan: candidate, layout };
@@ -427,9 +604,13 @@ export function compactFocusTreePlan(plan) {
427
604
  if (selected !== undefined)
428
605
  return selected.plan;
429
606
  if (fallback !== undefined) {
430
- assertCompactLayoutQuality(currentLayout, fallback.layout);
607
+ assertCompactLayoutQuality(currentLayout, fallback.layout, selection.focusIds !== undefined);
431
608
  return fallback.plan;
432
609
  }
610
+ if (blockedFocusIds.size > 0)
611
+ throw new ServiceError('FOCUS_COMPACT_ANCHOR_CONFLICT', 'No compact candidate preserves the selected branch boundary and pinned anchors', { treeId: plan.id, movedFocusIds: [...blockedFocusIds].sort(compareCodeUnits) });
612
+ if (blockedSymmetry.length > 0)
613
+ throw new ServiceError('FOCUS_COMPACT_SYMMETRY_CONFLICT', 'No compact candidate satisfies the requested symmetry pairs', { treeId: plan.id, pairs: blockedSymmetry });
433
614
  throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
434
615
  }
435
616
  /**
@@ -437,6 +618,7 @@ export function compactFocusTreePlan(plan) {
437
618
  * one work budget, and the selected layout is returned for validation/rendering.
438
619
  */
439
620
  export async function compactFocusTreePlanAsync(plan, options = {}) {
621
+ const selection = validatedSelection(plan, options);
440
622
  const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
441
623
  const layoutOptions = {
442
624
  workBudget,
@@ -451,20 +633,32 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
451
633
  structuralRows: false,
452
634
  mirrorSiblings: false,
453
635
  straightenChains: false,
454
- })
636
+ }, selection)
455
637
  : plan;
456
638
  const normalizedLayout = normalizedPlan === plan
457
639
  ? currentLayout
458
- : await layoutFocusTreeAsync(normalizedPlan, layoutOptions);
640
+ : await layoutFocusTreeAsync(normalizedPlan, {
641
+ ...layoutOptions,
642
+ ...(selection.focusIds === undefined
643
+ ? {}
644
+ : { previous: scopedPrevious(currentLayout, selection.focusIds) }),
645
+ });
459
646
  let selected;
460
647
  let fallback;
461
648
  let exhausted;
649
+ const blockedFocusIds = new Set();
650
+ let blockedSymmetry = [];
462
651
  candidateSearch: for (const strategy of compactStrategies(plan.focuses.length)) {
463
652
  options.signal?.throwIfAborted();
464
- const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
653
+ const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy, selection);
465
654
  let layout;
466
655
  try {
467
- layout = await layoutFocusTreeAsync(candidate, layoutOptions);
656
+ layout = await layoutFocusTreeAsync(candidate, {
657
+ ...layoutOptions,
658
+ ...(selection.focusIds === undefined
659
+ ? {}
660
+ : { previous: scopedPrevious(currentLayout, selection.focusIds) }),
661
+ });
468
662
  }
469
663
  catch (error) {
470
664
  if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
@@ -473,9 +667,21 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
473
667
  }
474
668
  throw error;
475
669
  }
670
+ const moved = movedOutsideSelection(currentLayout, layout, selection.focusIds, selection.pinnedFocusIds);
671
+ for (const id of moved)
672
+ blockedFocusIds.add(id);
673
+ if (moved.length > 0)
674
+ continue;
675
+ const symmetry = symmetryViolations(layout, selection.symmetryGroups);
676
+ if (symmetry.length > 0) {
677
+ blockedSymmetry = symmetry;
678
+ continue;
679
+ }
476
680
  if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
477
681
  fallback = { plan: candidate, layout };
478
- if (absoluteCompactRegressions(layout).length > 0)
682
+ if ((selection.focusIds === undefined
683
+ ? absoluteCompactRegressions(layout)
684
+ : relativeCompactRegressions(currentLayout, layout)).length > 0)
479
685
  continue;
480
686
  if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
481
687
  selected = { plan: candidate, layout };
@@ -486,14 +692,22 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
486
692
  if (exhausted !== undefined)
487
693
  throw exhausted;
488
694
  if (fallback !== undefined) {
489
- assertCompactLayoutQuality(currentLayout, fallback.layout);
695
+ assertCompactLayoutQuality(currentLayout, fallback.layout, selection.focusIds !== undefined);
490
696
  return { plan: fallback.plan, currentLayout, proposedLayout: fallback.layout };
491
697
  }
698
+ if (blockedFocusIds.size > 0)
699
+ throw new ServiceError('FOCUS_COMPACT_ANCHOR_CONFLICT', 'No compact candidate preserves the selected branch boundary and pinned anchors', { treeId: plan.id, movedFocusIds: [...blockedFocusIds].sort(compareCodeUnits) });
700
+ if (blockedSymmetry.length > 0)
701
+ throw new ServiceError('FOCUS_COMPACT_SYMMETRY_CONFLICT', 'No compact candidate satisfies the requested symmetry pairs', { treeId: plan.id, pairs: blockedSymmetry });
492
702
  throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
493
703
  }
494
704
  /** Refuses compact rewrites that still contain invalid or unreadable geometry. */
495
- export function assertCompactLayoutQuality(_current, proposed) {
496
- const regressions = [...absoluteCompactRegressions(proposed)].filter((value, index, all) => all.indexOf(value) === index);
705
+ export function assertCompactLayoutQuality(_current, proposed, relativeToBaseline = false) {
706
+ const regressions = [
707
+ ...(relativeToBaseline && _current !== undefined
708
+ ? relativeCompactRegressions(_current, proposed)
709
+ : absoluteCompactRegressions(proposed)),
710
+ ].filter((value, index, all) => all.indexOf(value) === index);
497
711
  if (regressions.length === 0)
498
712
  return;
499
713
  throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the layout-correctness gate', {
@@ -3,7 +3,7 @@ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
3
3
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
4
4
  import { sortDiagnostics } from '../core/diagnostics.js';
5
5
  import { ServiceError } from '../core/result.js';
6
- import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusConnectorPolylineIntersectsRectangle, focusNodeRectangle, focusNodesVisiblyOverlap, } from './geometry.js';
6
+ import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusConnectorPolylineIntersectsRectangle, focusNodeRectangle, focusNodesVisiblyOverlap, FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, } from './geometry.js';
7
7
  import { FOCUS_GRAPH_MAX_DEPTH, FOCUS_DIAGNOSTIC_MAX, FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES, FOCUS_LAYOUT_WORK_MAX, } from './limits.js';
8
8
  import {} from './model.js';
9
9
  export class FocusLayoutWorkBudget {
@@ -264,14 +264,61 @@ function* connectorCrossings(edges, context) {
264
264
  edge,
265
265
  points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
266
266
  }));
267
+ // Most focus connectors span one or two grid rows. Index those rows so
268
+ // unrelated branches do not consume the fixed work budget as an E² scan.
269
+ const maximumBucketSpan = 64;
270
+ const byRow = new Map();
271
+ const longEdges = [];
272
+ const intervals = edges.map(({ parent, child }) => ({
273
+ minimum: Math.min(parent.y, child.y),
274
+ maximum: Math.max(parent.y, child.y),
275
+ }));
276
+ for (const [index, interval] of intervals.entries()) {
277
+ const span = interval.maximum - interval.minimum;
278
+ if (span <= 0)
279
+ continue;
280
+ if (!Number.isSafeInteger(interval.minimum) ||
281
+ !Number.isSafeInteger(span) ||
282
+ span > maximumBucketSpan) {
283
+ longEdges.push(index);
284
+ continue;
285
+ }
286
+ for (let row = interval.minimum; row < interval.maximum; row += 1) {
287
+ context.work.spend('connector row index');
288
+ const bucket = byRow.get(row) ?? [];
289
+ bucket.push(index);
290
+ byRow.set(row, bucket);
291
+ }
292
+ }
267
293
  let crossingCount = 0;
268
294
  for (let leftIndex = 0; leftIndex < flattened.length; leftIndex += 1) {
269
295
  yield* cancellationCheckpoint(context.signal, leftIndex, 16);
270
296
  const left = flattened[leftIndex];
271
297
  if (left === undefined)
272
298
  continue;
273
- for (let rightIndex = leftIndex + 1; rightIndex < flattened.length; rightIndex += 1) {
274
- yield* cancellationCheckpoint(context.signal, rightIndex - leftIndex, 64);
299
+ const interval = intervals[leftIndex];
300
+ const span = interval.maximum - interval.minimum;
301
+ if (span <= 0)
302
+ continue;
303
+ const candidates = new Set();
304
+ if (span > maximumBucketSpan ||
305
+ !Number.isSafeInteger(interval.minimum) ||
306
+ !Number.isSafeInteger(span)) {
307
+ for (let index = leftIndex + 1; index < edges.length; index += 1)
308
+ candidates.add(index);
309
+ }
310
+ else {
311
+ for (let row = interval.minimum; row < interval.maximum; row += 1)
312
+ for (const index of byRow.get(row) ?? [])
313
+ if (index > leftIndex)
314
+ candidates.add(index);
315
+ for (const index of longEdges)
316
+ if (index > leftIndex)
317
+ candidates.add(index);
318
+ }
319
+ const orderedCandidates = [...candidates].sort((leftIndex, rightIndex) => leftIndex - rightIndex);
320
+ for (const [candidateIndex, rightIndex] of orderedCandidates.entries()) {
321
+ yield* cancellationCheckpoint(context.signal, candidateIndex, 64);
275
322
  const right = edges[rightIndex];
276
323
  const rightFlattened = flattened[rightIndex];
277
324
  if (right === undefined || rightFlattened === undefined)
@@ -1062,15 +1109,33 @@ function* placeFocus(context, focusId, inheritedStack = new Set()) {
1062
1109
  }
1063
1110
  function* visibleOverlapDiagnostics(context) {
1064
1111
  const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1112
+ const cells = new Map();
1113
+ for (const node of nodes) {
1114
+ const key = `${Math.floor(node.x)},${Math.floor(node.y)}`;
1115
+ const cell = cells.get(key) ?? [];
1116
+ cell.push(node);
1117
+ cells.set(key, cell);
1118
+ }
1065
1119
  for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
1066
1120
  yield* cancellationCheckpoint(context.signal, leftIndex, 16);
1067
1121
  const left = nodes[leftIndex];
1068
1122
  if (left === undefined)
1069
1123
  continue;
1070
- for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
1124
+ const candidates = [];
1125
+ const cellX = Math.floor(left.x);
1126
+ const cellY = Math.floor(left.y);
1127
+ for (let y = cellY - 1; y <= cellY + 1; y += 1) {
1128
+ for (let x = cellX - 1; x <= cellX + 1; x += 1) {
1129
+ for (const candidate of cells.get(`${x},${y}`) ?? []) {
1130
+ if (compareCodeUnits(candidate.id, left.id) > 0)
1131
+ candidates.push(candidate);
1132
+ }
1133
+ }
1134
+ }
1135
+ candidates.sort((first, second) => compareCodeUnits(first.id, second.id));
1136
+ for (const right of candidates) {
1071
1137
  context.work.spend('final visible-overlap comparison');
1072
- const right = nodes[rightIndex];
1073
- if (right === undefined || !focusNodesVisiblyOverlap(left, right))
1138
+ if (!focusNodesVisiblyOverlap(left, right))
1074
1139
  continue;
1075
1140
  const focus = context.focuses.get(right.id);
1076
1141
  context.diagnostics.push({
@@ -1189,12 +1254,44 @@ function* mutualExclusionSpacingDiagnostics(context) {
1189
1254
  }
1190
1255
  }
1191
1256
  function* connectorNodeIntersectionDiagnostics(context, edges) {
1192
- const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1257
+ const nodesByY = [...context.placed.values()].sort((left, right) => left.y - right.y || compareCodeUnits(left.id, right.id));
1193
1258
  let comparisonIndex = 0;
1194
1259
  let intersectionCount = 0;
1195
1260
  for (const edge of edges) {
1196
1261
  const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
1197
- for (const node of nodes) {
1262
+ let minimumX = Infinity;
1263
+ let maximumX = -Infinity;
1264
+ let minimumY = Infinity;
1265
+ let maximumY = -Infinity;
1266
+ for (const point of flattened) {
1267
+ minimumX = Math.min(minimumX, point.x);
1268
+ maximumX = Math.max(maximumX, point.x);
1269
+ minimumY = Math.min(minimumY, point.y);
1270
+ maximumY = Math.max(maximumY, point.y);
1271
+ }
1272
+ const minimumNodeY = (minimumY - FOCUS_NODE_HEIGHT_PIXELS) / FOCUS_VERTICAL_GRID_PIXELS;
1273
+ const maximumNodeY = maximumY / FOCUS_VERTICAL_GRID_PIXELS;
1274
+ const minimumNodeX = (minimumX - FOCUS_NODE_WIDTH_PIXELS) / FOCUS_HORIZONTAL_GRID_PIXELS;
1275
+ const maximumNodeX = maximumX / FOCUS_HORIZONTAL_GRID_PIXELS;
1276
+ let low = 0;
1277
+ let high = nodesByY.length;
1278
+ while (low < high) {
1279
+ const middle = Math.floor((low + high) / 2);
1280
+ if (nodesByY[middle].y < minimumNodeY)
1281
+ low = middle + 1;
1282
+ else
1283
+ high = middle;
1284
+ }
1285
+ const candidates = [];
1286
+ for (let index = low; index < nodesByY.length; index += 1) {
1287
+ const node = nodesByY[index];
1288
+ if (node.y > maximumNodeY)
1289
+ break;
1290
+ if (node.x >= minimumNodeX && node.x <= maximumNodeX)
1291
+ candidates.push(node);
1292
+ }
1293
+ candidates.sort((left, right) => compareCodeUnits(left.id, right.id));
1294
+ for (const node of candidates) {
1198
1295
  yield* cancellationCheckpoint(context.signal, comparisonIndex, 32);
1199
1296
  comparisonIndex += 1;
1200
1297
  if (node.id === edge.parentId || node.id === edge.childId)
@@ -2196,6 +2293,20 @@ function* layoutFocusTreeSteps(plan, options = {}) {
2196
2293
  decisions,
2197
2294
  diagnostics: sortDiagnostics(context.diagnostics.values()),
2198
2295
  metrics: layoutMetrics(context, edges, crossingSummary.count, nodeIntersectionCount, symmetry, spacing),
2296
+ connectorMeasurements: edges
2297
+ .map((edge) => {
2298
+ const spans = connectorSpans(edge);
2299
+ return {
2300
+ parentId: edge.parentId,
2301
+ childId: edge.childId,
2302
+ horizontalSpan: spans.horizontal,
2303
+ verticalSpan: spans.vertical,
2304
+ manhattanSpan: spans.manhattan,
2305
+ long: longConnectorSpans(spans),
2306
+ };
2307
+ })
2308
+ .sort((left, right) => compareCodeUnits(left.parentId, right.parentId) ||
2309
+ compareCodeUnits(left.childId, right.childId)),
2199
2310
  layoutHash: hashCanonical(nodes.map(({ id, x, y, laneId }) => ({ id, laneId, x, y }))),
2200
2311
  };
2201
2312
  }
@@ -334,6 +334,7 @@ function svgDocument(plan, layout, diagnostics, options) {
334
334
  nodes,
335
335
  decisions: layout.decisions,
336
336
  metrics: layout.metrics ?? null,
337
+ connectorMeasurements: layout.connectorMeasurements ?? null,
337
338
  },
338
339
  focuses: [...plan.focuses]
339
340
  .sort((left, right) => compareCodeUnits(left.id, right.id))
@@ -452,6 +453,47 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
452
453
  .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
453
454
  .toBuffer();
454
455
  }
456
+ const problemCrops = [];
457
+ if ((options.cropFocusIds?.length ?? 0) > 0) {
458
+ if (options.cropFocusIds.length > 16)
459
+ throw new ServiceError('FOCUS_CROP_LIMIT', 'Focus render accepts at most 16 problem crops');
460
+ if (options.rasterize === false)
461
+ throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops require raster output');
462
+ const nodes = new Map(layout.nodes.map((node) => [node.id, node]));
463
+ const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
464
+ const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
465
+ const horizontal = options.horizontalSpacing ?? FOCUS_HORIZONTAL_GRID_PIXELS;
466
+ const vertical = options.verticalSpacing ?? FOCUS_VERTICAL_GRID_PIXELS;
467
+ const padding = options.padding ?? 80;
468
+ const scale = options.outputScale ?? 1;
469
+ for (const focusId of [...new Set(options.cropFocusIds)]) {
470
+ const node = nodes.get(focusId);
471
+ if (node === undefined)
472
+ throw new ServiceError('FOCUS_CROP_FOCUS_MISSING', 'Problem crop names an unknown focus', {
473
+ focusId,
474
+ });
475
+ const origin = focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical);
476
+ const centerX = Math.round((origin.x + FOCUS_NODE_WIDTH_PIXELS / 2) * scale);
477
+ const centerY = Math.round((origin.y + FOCUS_NODE_HEIGHT_PIXELS / 2) * scale);
478
+ const width = Math.min(rendered.width, 640);
479
+ const height = Math.min(rendered.height, 440);
480
+ budget.reserve(width, height, 'focus problem crop PNG');
481
+ const x = Math.max(0, Math.min(rendered.width - width, centerX - Math.floor(width / 2)));
482
+ const y = Math.max(0, Math.min(rendered.height - height, centerY - Math.floor(height / 2)));
483
+ const crop = await sharp(png)
484
+ .extract({ left: x, top: y, width, height })
485
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
486
+ .toBuffer();
487
+ problemCrops.push({
488
+ focusId,
489
+ bounds: { x, y, width, height },
490
+ png: crop,
491
+ diagnostics: diagnostics
492
+ .filter((diagnostic) => focusIdForDiagnostic(diagnostic) === focusId)
493
+ .map(({ code }) => code),
494
+ });
495
+ }
496
+ }
455
497
  options.signal?.throwIfAborted();
456
498
  return {
457
499
  html,
@@ -467,6 +509,7 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
467
509
  sourceMap: compiled.sourceMap,
468
510
  width: rendered.width,
469
511
  height: rendered.height,
512
+ ...(problemCrops.length === 0 ? {} : { problemCrops }),
470
513
  };
471
514
  }
472
515
  export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bundle, options = {}) {
@@ -534,5 +577,24 @@ export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bund
534
577
  description: 'Source-hash-bound non-Clausewitz planning metadata',
535
578
  },
536
579
  ];
580
+ if (bundle.problemCrops !== undefined) {
581
+ for (const crop of bundle.problemCrops)
582
+ writes.push({
583
+ name: `${stem}.focus-problem-${safeArtifactStem(crop.focusId)}-${sha256Bytes(crop.focusId).slice(0, 8)}.png`,
584
+ mimeType: 'image/png',
585
+ content: crop.png,
586
+ provenance: {
587
+ ...provenance,
588
+ kind: 'focus-problem-crop',
589
+ metadata: { ...provenance.metadata, focusId: crop.focusId, bounds: crop.bounds },
590
+ },
591
+ });
592
+ writes.push({
593
+ name: `${stem}.focus-problem-crops.json`,
594
+ mimeType: 'application/json',
595
+ content: `${canonicalJson(bundle.problemCrops.map(({ focusId, bounds, png, diagnostics }) => ({ focusId, bounds, sha256: sha256Bytes(png), diagnostics })))}\n`,
596
+ provenance: { ...provenance, kind: 'focus-problem-crop-manifest' },
597
+ });
598
+ }
537
599
  return artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), options.signal);
538
600
  }