From e219a5dcc6bb820cd5ac9f9728612fc04bfceaae Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:36:17 +0200
Subject: [PATCH 001/105] Add layout engine v2 crossing refinement
---
flowforge/layout_engine_v2.py | 694 ++++++++++++++++++++++++++++++++++
1 file changed, 694 insertions(+)
create mode 100644 flowforge/layout_engine_v2.py
diff --git a/flowforge/layout_engine_v2.py b/flowforge/layout_engine_v2.py
new file mode 100644
index 0000000..267f1c9
--- /dev/null
+++ b/flowforge/layout_engine_v2.py
@@ -0,0 +1,694 @@
+"""Second-generation layout engine for ComfyUI FlowForge.
+
+The v2 engine keeps the mature placement and special-case handling from
+``flowforge.layout`` but changes how layout candidates are selected and adds a
+structural crossing-refinement stage for movable group internals.
+
+Key differences from the legacy engine:
+
+- candidate selection uses actual input/output port geometry instead of node
+ centres;
+- cyclic subgraphs are condensed into strongly connected components before
+ assigning layers;
+- long edges are expanded with virtual dummy vertices while ordering layers so
+ they influence every layer they pass through;
+- repeated directional median sweeps plus local transposition replace the
+ single forward/backward barycenter pass as the final crossing refinement;
+- every refined candidate is compared with its unrefined counterpart and is
+ only kept when the v2 quality score improves.
+
+Dummy vertices exist only in the in-memory ordering graph. They are never
+written into the ComfyUI workflow.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+import math
+from statistics import median
+from typing import Hashable
+
+from .layout import (
+ LayoutReport,
+ LayoutScore,
+ LayoutSettings,
+ _apply_layout_pass,
+ _assign_virtual_hub_groups_to_endpoints,
+ _build_layout_candidates,
+ _compress_debug_sidecar_layers,
+ _group_bottom_padding,
+ _group_top_padding,
+ _is_decorative_node,
+ _is_pinned_node,
+ _is_virtual_hub_node,
+ _node_input_port_y,
+ _node_output_port_y,
+ _node_visual_height,
+ _node_visual_width,
+ _position_control_nodes_near_targets,
+ _position_text_previews_near_sources,
+ _position_virtual_set_get_nodes,
+ _resolve_group_geometry_overlaps,
+ _resolve_layout_candidate_count,
+ _score_layout_candidate,
+ _separate_unpinned_nodes_from_pinned_geometry,
+ _update_bounding_boxes,
+)
+from .logger import setup_logger
+from .model import Group, Link, Node, Workflow
+
+logger = setup_logger(__name__)
+
+V2_SWEEP_ROUNDS = 4
+V2_TRANSPOSE_PASSES = 4
+V2_CROSSING_WEIGHT = 2_000.0
+V2_RIGHT_TO_LEFT_WEIGHT = 3_000.0
+V2_OVERLAP_WEIGHT = 100_000.0
+V2_WIDTH_WEIGHT = 2.5
+V2_HEIGHT_WEIGHT = 1.0
+V2_LINK_WEIGHT = 0.02
+V2_ASPECT_WEIGHT = 160.0
+V2_TARGET_ASPECT_RATIO = 1.35
+
+Vertex = Hashable
+
+
+@dataclass(frozen=True)
+class EngineV2Score:
+ """Port-aware quality metrics used to select v2 layout candidates."""
+
+ total: float
+ crossings: int
+ right_to_left_links: int
+ movable_overlaps: int
+ link_length: float
+ width: float
+ height: float
+
+
+def apply(workflow: Workflow, settings: LayoutSettings | None = None) -> Workflow:
+ """Apply the v2 layout engine using the normal adaptive candidate count."""
+ return apply_best_layout(workflow, settings)
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Evaluate legacy placement candidates plus the v2 structural refiner."""
+ settings = settings or LayoutSettings()
+ resolved_candidate_count = (
+ _resolve_layout_candidate_count(workflow)
+ if candidate_count is None
+ else max(1, int(candidate_count))
+ )
+ variants = _build_layout_candidates(workflow, settings, resolved_candidate_count)
+
+ best_workflow: Workflow | None = None
+ best_score: EngineV2Score | None = None
+ best_index = 0
+
+ for index, variant in enumerate(variants, start=1):
+ baseline = deepcopy(workflow)
+ _apply_layout_pass(baseline, variant, log=False)
+ baseline_score = _score_engine_v2(baseline)
+
+ refined = deepcopy(baseline)
+ changed = _refine_movable_group_internals(refined, variant)
+ if changed:
+ _finalize_refinement(refined, variant)
+ refined_score = _score_engine_v2(refined)
+ if refined_score.total < baseline_score.total:
+ candidate = refined
+ candidate_score = refined_score
+ else:
+ candidate = baseline
+ candidate_score = baseline_score
+ else:
+ candidate = baseline
+ candidate_score = baseline_score
+
+ logger.debug(
+ "v2 candidate %s/%s score=%.2f crossings=%s rtl=%s overlaps=%s link=%.2f size=%.0fx%.0f",
+ index,
+ len(variants),
+ candidate_score.total,
+ candidate_score.crossings,
+ candidate_score.right_to_left_links,
+ candidate_score.movable_overlaps,
+ candidate_score.link_length,
+ candidate_score.width,
+ candidate_score.height,
+ )
+
+ if best_score is None or candidate_score.total < best_score.total:
+ best_workflow = candidate
+ best_score = candidate_score
+ best_index = index
+
+ if best_workflow is None or best_score is None:
+ raise RuntimeError("Layout engine v2 did not produce a candidate")
+
+ legacy_score: LayoutScore = _score_layout_candidate(best_workflow)
+ best_workflow.layout_report = LayoutReport(
+ candidate_count=len(variants),
+ selected_candidate=best_index,
+ score=legacy_score,
+ )
+ logger.info(
+ "Layout engine v2 selected candidate %s/%s: score=%.2f crossings=%s rtl=%s overlaps=%s",
+ best_index,
+ len(variants),
+ best_score.total,
+ best_score.crossings,
+ best_score.right_to_left_links,
+ best_score.movable_overlaps,
+ )
+ return best_workflow
+
+
+def _refine_movable_group_internals(workflow: Workflow, settings: LayoutSettings) -> bool:
+ """Re-layout fully movable groups with SCC-aware, dummy-edge ordering."""
+ changed = False
+ for group in workflow.groups:
+ if not _group_can_be_refined(workflow, group):
+ continue
+ if _refine_group(workflow, group, settings):
+ changed = True
+ return changed
+
+
+def _group_can_be_refined(workflow: Workflow, group: Group) -> bool:
+ if group.pinned or len(group.nodes) < 2:
+ return False
+ return not any(
+ _is_pinned_node(workflow, node)
+ or _is_decorative_node(node)
+ or _is_virtual_hub_node(node)
+ for node in group.nodes
+ )
+
+
+def _refine_group(workflow: Workflow, group: Group, settings: LayoutSettings) -> bool:
+ node_ids = {node.id for node in group.nodes}
+ adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
+ rev_adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
+
+ internal_links: list[Link] = []
+ for link in workflow.links.values():
+ if link.source in node_ids and link.target in node_ids:
+ adj[link.source].append(link.target)
+ rev_adj[link.target].append(link.source)
+ internal_links.append(link)
+
+ if not internal_links:
+ return False
+
+ layers = _assign_scc_longest_path_layers(node_ids, adj)
+ _compress_debug_sidecar_layers(layers, adj, rev_adj, workflow)
+ if len(set(layers.values())) <= 1:
+ return False
+
+ layer_order = _minimize_crossings_with_dummies(workflow, layers, internal_links)
+ if not layer_order:
+ return False
+
+ base_x = group.bounding[0] + settings.group_padding
+ base_y = group.bounding[1] + _group_top_padding(settings)
+ current_x = base_x
+ max_bottom = base_y
+
+ for layer in sorted(layer_order):
+ node_order = [node_id for node_id in layer_order[layer] if isinstance(node_id, int)]
+ if not node_order:
+ continue
+
+ layer_width = max(_node_visual_width(workflow.nodes[node_id]) for node_id in node_order)
+ current_y = base_y
+ for node_id in node_order:
+ node = workflow.nodes[node_id]
+ node.x = current_x
+ node.y = current_y
+ current_y += _node_visual_height(node) + settings.node_v_gap
+ max_bottom = max(max_bottom, current_y - settings.node_v_gap)
+ current_x += layer_width + settings.node_h_gap
+
+ content_right = current_x - settings.node_h_gap
+ group.bounding = [
+ group.bounding[0],
+ group.bounding[1],
+ max(0.0, content_right - base_x) + 2.0 * settings.group_padding,
+ max(0.0, max_bottom - base_y)
+ + _group_top_padding(settings)
+ + _group_bottom_padding(settings),
+ ]
+ return True
+
+
+def _assign_scc_longest_path_layers(
+ node_ids: set[int],
+ adj: dict[int, list[int]],
+) -> dict[int, int]:
+ """Assign longest-path layers on a DAG of strongly connected components."""
+ components = _tarjan_components(node_ids, adj)
+ component_by_node: dict[int, int] = {}
+ for component_index, component in enumerate(components):
+ for node_id in component:
+ component_by_node[node_id] = component_index
+
+ component_adj: dict[int, set[int]] = {index: set() for index in range(len(components))}
+ indegree = {index: 0 for index in range(len(components))}
+ for source_id in node_ids:
+ source_component = component_by_node[source_id]
+ for target_id in adj.get(source_id, []):
+ target_component = component_by_node[target_id]
+ if source_component == target_component:
+ continue
+ if target_component not in component_adj[source_component]:
+ component_adj[source_component].add(target_component)
+ indegree[target_component] += 1
+
+ component_layer = {index: 0 for index, degree in indegree.items() if degree == 0}
+ queue = sorted(component_layer)
+ mutable_indegree = dict(indegree)
+ while queue:
+ component = queue.pop(0)
+ for target in sorted(component_adj[component]):
+ component_layer[target] = max(
+ component_layer.get(target, 0),
+ component_layer[component] + 1,
+ )
+ mutable_indegree[target] -= 1
+ if mutable_indegree[target] == 0:
+ queue.append(target)
+ queue.sort()
+
+ return {
+ node_id: component_layer.get(component_by_node[node_id], 0)
+ for node_id in node_ids
+ }
+
+
+def _tarjan_components(
+ node_ids: set[int],
+ adj: dict[int, list[int]],
+) -> list[list[int]]:
+ """Return deterministic strongly connected components using Tarjan's algorithm."""
+ index = 0
+ indices: dict[int, int] = {}
+ lowlink: dict[int, int] = {}
+ stack: list[int] = []
+ on_stack: set[int] = set()
+ components: list[list[int]] = []
+
+ def strongconnect(node_id: int) -> None:
+ nonlocal index
+ indices[node_id] = index
+ lowlink[node_id] = index
+ index += 1
+ stack.append(node_id)
+ on_stack.add(node_id)
+
+ for target_id in sorted(adj.get(node_id, [])):
+ if target_id not in indices:
+ strongconnect(target_id)
+ lowlink[node_id] = min(lowlink[node_id], lowlink[target_id])
+ elif target_id in on_stack:
+ lowlink[node_id] = min(lowlink[node_id], indices[target_id])
+
+ if lowlink[node_id] != indices[node_id]:
+ return
+
+ component: list[int] = []
+ while stack:
+ current = stack.pop()
+ on_stack.remove(current)
+ component.append(current)
+ if current == node_id:
+ break
+ components.append(sorted(component))
+
+ for node_id in sorted(node_ids):
+ if node_id not in indices:
+ strongconnect(node_id)
+
+ return components
+
+
+def _minimize_crossings_with_dummies(
+ workflow: Workflow,
+ layers: dict[int, int],
+ links: list[Link],
+) -> dict[int, list[Vertex]]:
+ """Order real and virtual vertices with repeated median sweeps and transpose."""
+ max_layer = max(layers.values(), default=0)
+ layer_to_vertices: dict[int, list[Vertex]] = {layer: [] for layer in range(max_layer + 1)}
+ stable_y: dict[Vertex, float] = {}
+
+ for node_id, layer in layers.items():
+ layer_to_vertices.setdefault(layer, []).append(node_id)
+ stable_y[node_id] = workflow.nodes[node_id].y
+
+ expanded_edges: list[tuple[Vertex, Vertex]] = []
+ for link in sorted(links, key=lambda item: item.id):
+ source_layer = layers[link.source]
+ target_layer = layers[link.target]
+ if target_layer <= source_layer:
+ continue
+
+ previous: Vertex = link.source
+ source_y = workflow.nodes[link.source].y
+ target_y = workflow.nodes[link.target].y
+ span = target_layer - source_layer
+ for layer in range(source_layer + 1, target_layer):
+ dummy: Vertex = ("dummy", link.id, layer)
+ layer_to_vertices.setdefault(layer, []).append(dummy)
+ fraction = (layer - source_layer) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy))
+ previous = dummy
+ expanded_edges.append((previous, link.target))
+
+ for layer, vertices in layer_to_vertices.items():
+ vertices.sort(key=lambda vertex: (_vertex_bypassed(workflow, vertex), stable_y.get(vertex, 0.0), _vertex_key(vertex)))
+
+ predecessors: dict[Vertex, list[Vertex]] = {}
+ successors: dict[Vertex, list[Vertex]] = {}
+ for source, target in expanded_edges:
+ successors.setdefault(source, []).append(target)
+ predecessors.setdefault(target, []).append(source)
+
+ for _ in range(V2_SWEEP_ROUNDS):
+ for layer in range(1, max_layer + 1):
+ _median_reorder_layer(
+ workflow,
+ layer_to_vertices,
+ layer,
+ predecessors,
+ reference_layer=layer - 1,
+ stable_y=stable_y,
+ )
+ for layer in range(max_layer - 1, -1, -1):
+ _median_reorder_layer(
+ workflow,
+ layer_to_vertices,
+ layer,
+ successors,
+ reference_layer=layer + 1,
+ stable_y=stable_y,
+ )
+ _transpose_layers(layer_to_vertices, expanded_edges, max_layer)
+
+ return layer_to_vertices
+
+
+def _median_reorder_layer(
+ workflow: Workflow,
+ layer_to_vertices: dict[int, list[Vertex]],
+ layer: int,
+ neighbours: dict[Vertex, list[Vertex]],
+ *,
+ reference_layer: int,
+ stable_y: dict[Vertex, float],
+) -> None:
+ vertices = layer_to_vertices.get(layer, [])
+ reference = layer_to_vertices.get(reference_layer, [])
+ if len(vertices) <= 1 or not reference:
+ return
+
+ positions = {vertex: index for index, vertex in enumerate(reference)}
+
+ def sort_key(vertex: Vertex) -> tuple[float, float, float, str]:
+ adjacent_positions = [
+ positions[other]
+ for other in neighbours.get(vertex, [])
+ if other in positions
+ ]
+ barycenter = median(adjacent_positions) if adjacent_positions else math.inf
+ return (
+ float(_vertex_bypassed(workflow, vertex)),
+ float(barycenter),
+ stable_y.get(vertex, 0.0),
+ _vertex_key(vertex),
+ )
+
+ layer_to_vertices[layer] = sorted(vertices, key=sort_key)
+
+
+def _transpose_layers(
+ layer_to_vertices: dict[int, list[Vertex]],
+ expanded_edges: list[tuple[Vertex, Vertex]],
+ max_layer: int,
+) -> None:
+ for _ in range(V2_TRANSPOSE_PASSES):
+ improved = False
+ for layer in range(max_layer + 1):
+ vertices = layer_to_vertices.get(layer, [])
+ if len(vertices) <= 1:
+ continue
+ index = 0
+ while index < len(vertices) - 1:
+ before = _local_boundary_crossings(layer_to_vertices, expanded_edges, layer, max_layer)
+ vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
+ after = _local_boundary_crossings(layer_to_vertices, expanded_edges, layer, max_layer)
+ if after < before:
+ improved = True
+ index += 1
+ else:
+ vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
+ index += 1
+ if not improved:
+ break
+
+
+def _local_boundary_crossings(
+ layer_to_vertices: dict[int, list[Vertex]],
+ edges: list[tuple[Vertex, Vertex]],
+ layer: int,
+ max_layer: int,
+) -> int:
+ crossings = 0
+ if layer > 0:
+ crossings += _boundary_crossings(layer_to_vertices, edges, layer - 1, layer)
+ if layer < max_layer:
+ crossings += _boundary_crossings(layer_to_vertices, edges, layer, layer + 1)
+ return crossings
+
+
+def _boundary_crossings(
+ layer_to_vertices: dict[int, list[Vertex]],
+ edges: list[tuple[Vertex, Vertex]],
+ left_layer: int,
+ right_layer: int,
+) -> int:
+ left = layer_to_vertices.get(left_layer, [])
+ right = layer_to_vertices.get(right_layer, [])
+ if not left or not right:
+ return 0
+
+ left_pos = {vertex: index for index, vertex in enumerate(left)}
+ right_pos = {vertex: index for index, vertex in enumerate(right)}
+ boundary_edges = [
+ (source, target)
+ for source, target in edges
+ if source in left_pos and target in right_pos
+ ]
+
+ crossings = 0
+ for index, first in enumerate(boundary_edges):
+ for second in boundary_edges[index + 1 :]:
+ if first[0] == second[0] or first[1] == second[1]:
+ continue
+ if (left_pos[first[0]] - left_pos[second[0]]) * (
+ right_pos[first[1]] - right_pos[second[1]]
+ ) < 0:
+ crossings += 1
+ return crossings
+
+
+def _vertex_bypassed(workflow: Workflow, vertex: Vertex) -> bool:
+ return isinstance(vertex, int) and workflow.nodes[vertex].mode == 4
+
+
+def _vertex_key(vertex: Vertex) -> str:
+ return str(vertex)
+
+
+def _finalize_refinement(workflow: Workflow, settings: LayoutSettings) -> None:
+ """Re-run geometry contracts that can be affected by internal group movement."""
+ _update_bounding_boxes(workflow, settings)
+ _resolve_group_geometry_overlaps(workflow, settings)
+ _separate_unpinned_nodes_from_pinned_geometry(workflow, settings)
+ _position_control_nodes_near_targets(workflow, list(workflow.nodes.values()), settings)
+ _position_text_previews_near_sources(workflow, settings)
+ _position_virtual_set_get_nodes(workflow, settings)
+ _assign_virtual_hub_groups_to_endpoints(workflow)
+ _update_bounding_boxes(workflow, settings)
+ _resolve_group_geometry_overlaps(workflow, settings)
+ _separate_unpinned_nodes_from_pinned_geometry(workflow, settings)
+
+
+def _score_engine_v2(workflow: Workflow) -> EngineV2Score:
+ segments = _port_segments(workflow)
+ crossings = _segment_crossing_count(segments)
+ right_to_left = sum(1 for _source, _target, start, end in segments if end[0] < start[0])
+ link_length = sum(
+ abs(end[0] - start[0]) + abs(end[1] - start[1])
+ for _source, _target, start, end in segments
+ )
+ overlaps = _movable_overlap_count(workflow)
+ left, top, right, bottom = _workflow_bounds(workflow)
+ width = max(0.0, right - left)
+ height = max(0.0, bottom - top)
+ aspect_ratio = width / max(1.0, height)
+ aspect_cost = max(0.0, aspect_ratio - V2_TARGET_ASPECT_RATIO) * V2_ASPECT_WEIGHT
+
+ total = (
+ crossings * V2_CROSSING_WEIGHT
+ + right_to_left * V2_RIGHT_TO_LEFT_WEIGHT
+ + overlaps * V2_OVERLAP_WEIGHT
+ + link_length * V2_LINK_WEIGHT
+ + width * V2_WIDTH_WEIGHT
+ + height * V2_HEIGHT_WEIGHT
+ + aspect_cost
+ )
+ return EngineV2Score(
+ total=total,
+ crossings=crossings,
+ right_to_left_links=right_to_left,
+ movable_overlaps=overlaps,
+ link_length=link_length,
+ width=width,
+ height=height,
+ )
+
+
+def _port_segments(
+ workflow: Workflow,
+) -> list[tuple[int, int, tuple[float, float], tuple[float, float]]]:
+ segments: list[tuple[int, int, tuple[float, float], tuple[float, float]]] = []
+ for link in workflow.links.values():
+ source = workflow.nodes.get(link.source)
+ target = workflow.nodes.get(link.target)
+ if source is None or target is None:
+ continue
+ start = (
+ source.x + _node_visual_width(source),
+ _node_output_port_y(source, link.source_port),
+ )
+ end = (
+ target.x,
+ _node_input_port_y(target, link.target_port),
+ )
+ segments.append((source.id, target.id, start, end))
+ return segments
+
+
+def _segment_crossing_count(
+ segments: list[tuple[int, int, tuple[float, float], tuple[float, float]]],
+) -> int:
+ crossings = 0
+ for index, first in enumerate(segments):
+ for second in segments[index + 1 :]:
+ if first[0] in {second[0], second[1]} or first[1] in {second[0], second[1]}:
+ continue
+ if _segments_intersect(first[2], first[3], second[2], second[3]):
+ crossings += 1
+ return crossings
+
+
+def _segments_intersect(
+ a1: tuple[float, float],
+ a2: tuple[float, float],
+ b1: tuple[float, float],
+ b2: tuple[float, float],
+) -> bool:
+ if a1 == b1 or a1 == b2 or a2 == b1 or a2 == b2:
+ return False
+
+ def orientation(
+ p: tuple[float, float],
+ q: tuple[float, float],
+ r: tuple[float, float],
+ ) -> float:
+ return (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1])
+
+ o1 = orientation(a1, a2, b1)
+ o2 = orientation(a1, a2, b2)
+ o3 = orientation(b1, b2, a1)
+ o4 = orientation(b1, b2, a2)
+ return o1 * o2 < 0 and o3 * o4 < 0
+
+
+def _movable_overlap_count(workflow: Workflow) -> int:
+ movable = [
+ node
+ for node in workflow.nodes.values()
+ if not _is_pinned_node(workflow, node)
+ and not _is_decorative_node(node)
+ and not _is_virtual_hub_node(node)
+ ]
+ overlaps = 0
+ for index, first in enumerate(movable):
+ first_rect = (
+ first.x,
+ first.y,
+ first.x + _node_visual_width(first),
+ first.y + _node_visual_height(first),
+ )
+ for second in movable[index + 1 :]:
+ second_rect = (
+ second.x,
+ second.y,
+ second.x + _node_visual_width(second),
+ second.y + _node_visual_height(second),
+ )
+ if _rects_overlap(first_rect, second_rect):
+ overlaps += 1
+ return overlaps
+
+
+def _rects_overlap(
+ first: tuple[float, float, float, float],
+ second: tuple[float, float, float, float],
+) -> bool:
+ return not (
+ first[2] <= second[0]
+ or second[2] <= first[0]
+ or first[3] <= second[1]
+ or second[3] <= first[1]
+ )
+
+
+def _workflow_bounds(workflow: Workflow) -> tuple[float, float, float, float]:
+ boxes: list[tuple[float, float, float, float]] = []
+ for node in workflow.nodes.values():
+ boxes.append(
+ (
+ node.x,
+ node.y,
+ node.x + _node_visual_width(node),
+ node.y + _node_visual_height(node),
+ )
+ )
+ for group in workflow.groups:
+ if len(group.bounding) < 4 or group.bounding[2] <= 0 or group.bounding[3] <= 0:
+ continue
+ boxes.append(
+ (
+ group.bounding[0],
+ group.bounding[1],
+ group.bounding[0] + group.bounding[2],
+ group.bounding[1] + group.bounding[3],
+ )
+ )
+ if not boxes:
+ return 0.0, 0.0, 0.0, 0.0
+ return (
+ min(box[0] for box in boxes),
+ min(box[1] for box in boxes),
+ max(box[2] for box in boxes),
+ max(box[3] for box in boxes),
+ )
From 76ae7b52816c752fc3764acdc7dce4d256a8fdf5 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:36:37 +0200
Subject: [PATCH 002/105] Route package layout calls through engine v2
---
flowforge/__init__.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/flowforge/__init__.py b/flowforge/__init__.py
index 0e8f2fc..b715b29 100644
--- a/flowforge/__init__.py
+++ b/flowforge/__init__.py
@@ -4,12 +4,20 @@
from .model import Node, Link, Group, Workflow
from .parser import parse_comfyui_workflow
-from .layout import apply as apply_layout
+from . import layout as _layout_module
+from .layout_engine_v2 import apply as apply_layout
from .optimizer import optimize as optimize_workflow
+# Keep the established ``flowforge.layout`` import surface working while the
+# v2 engine lives in its own module. Modules such as api.py, cli.py, and
+# layout_quality.py import ``apply`` from flowforge.layout after package
+# initialization, so they transparently use v2 without duplicating the mature
+# geometry helpers that remain in layout.py.
+_layout_module.apply = apply_layout
+
__all__ = [
'Node', 'Link', 'Group', 'Workflow',
'parse_comfyui_workflow',
'apply_layout',
'optimize_workflow',
-]
\ No newline at end of file
+]
From 7a8e6a07db0878851d7f2294df36b29e149a116a Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:37:07 +0200
Subject: [PATCH 003/105] Add layout engine v2 regression tests
---
tests/test_layout_engine_v2.py | 127 +++++++++++++++++++++++++++++++++
1 file changed, 127 insertions(+)
create mode 100644 tests/test_layout_engine_v2.py
diff --git a/tests/test_layout_engine_v2.py b/tests/test_layout_engine_v2.py
new file mode 100644
index 0000000..e0a4e0a
--- /dev/null
+++ b/tests/test_layout_engine_v2.py
@@ -0,0 +1,127 @@
+"""Regression tests for the second-generation layout engine."""
+
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import (
+ _assign_scc_longest_path_layers,
+ _boundary_crossings,
+ _group_can_be_refined,
+ _minimize_crossings_with_dummies,
+ _score_engine_v2,
+ apply_best_layout,
+)
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def test_scc_layering_condenses_cycle_before_longest_path_assignment():
+ node_ids = {1, 2, 3, 4}
+ adj = {
+ 1: [2],
+ 2: [1, 3],
+ 3: [4],
+ 4: [],
+ }
+
+ layers = _assign_scc_longest_path_layers(node_ids, adj)
+
+ assert layers[1] == layers[2]
+ assert layers[3] == layers[1] + 1
+ assert layers[4] == layers[3] + 1
+
+
+def test_dummy_vertices_make_long_edges_participate_in_crossing_order():
+ workflow = Workflow()
+ workflow.nodes = {
+ 1: Node(id=1, type="SourceA", x=0, y=0, size=[200, 80]),
+ 2: Node(id=2, type="SourceB", x=0, y=200, size=[200, 80]),
+ 3: Node(id=3, type="TargetA", x=800, y=0, size=[200, 80]),
+ 4: Node(id=4, type="TargetB", x=800, y=200, size=[200, 80]),
+ }
+ links = [
+ Link(id=10, source=1, source_port=0, target=4, target_port=0, type="DATA"),
+ Link(id=11, source=2, source_port=0, target=3, target_port=0, type="DATA"),
+ ]
+ layers = {1: 0, 2: 0, 3: 2, 4: 2}
+
+ ordered = _minimize_crossings_with_dummies(workflow, layers, links)
+ expanded_edges = []
+ for link in links:
+ dummy = ("dummy", link.id, 1)
+ expanded_edges.extend([(link.source, dummy), (dummy, link.target)])
+
+ crossings = _boundary_crossings(ordered, expanded_edges, 0, 1)
+ crossings += _boundary_crossings(ordered, expanded_edges, 1, 2)
+
+ assert crossings == 0
+ assert any(not isinstance(vertex, int) for vertex in ordered[1])
+
+
+def test_port_aware_score_counts_right_to_left_links_from_socket_geometry():
+ workflow = Workflow()
+ source = Node(id=1, type="Source", x=400, y=0, size=[200, 100], output_count=1)
+ target = Node(id=2, type="Target", x=100, y=0, size=[200, 100], input_count=1)
+ workflow.nodes = {1: source, 2: target}
+ _attach_link(
+ workflow,
+ Link(id=1, source=1, source_port=0, target=2, target_port=0, type="DATA"),
+ )
+
+ score = _score_engine_v2(workflow)
+
+ assert score.right_to_left_links == 1
+ assert score.link_length > 0
+
+
+def test_pinned_group_is_not_structurally_refined():
+ workflow = Workflow()
+ first = Node(id=1, type="A", x=0, y=0, size=[200, 80])
+ second = Node(id=2, type="B", x=300, y=0, size=[200, 80])
+ workflow.nodes = {1: first, 2: second}
+ group = Group(id=1, name="Pinned", nodes=[first, second], bounding=[0, 0, 600, 300], pinned=True)
+ workflow.groups = [group]
+
+ assert not _group_can_be_refined(workflow, group)
+
+
+def test_apply_best_layout_preserves_graph_and_reports_selected_candidate():
+ workflow = Workflow()
+ nodes = {
+ 1: Node(id=1, type="Source", x=0, y=0, size=[200, 80], output_count=2),
+ 2: Node(id=2, type="Upper", x=300, y=0, size=[200, 80], input_count=1, output_count=1),
+ 3: Node(id=3, type="Lower", x=300, y=200, size=[200, 80], input_count=1, output_count=1),
+ 4: Node(id=4, type="Target", x=600, y=100, size=[200, 80], input_count=2),
+ }
+ workflow.nodes = nodes
+ for link in [
+ Link(id=10, source=1, source_port=0, target=2, target_port=0, type="DATA"),
+ Link(id=11, source=1, source_port=1, target=3, target_port=0, type="DATA"),
+ Link(id=12, source=2, source_port=0, target=4, target_port=0, type="DATA"),
+ Link(id=13, source=3, source_port=0, target=4, target_port=1, type="DATA"),
+ ]:
+ _attach_link(workflow, link)
+ workflow.groups = [
+ Group(id=1, name="Pipeline", nodes=list(nodes.values()), bounding=[0, 0, 900, 500])
+ ]
+
+ original_links = {
+ link_id: (link.source, link.source_port, link.target, link.target_port, link.type)
+ for link_id, link in workflow.links.items()
+ }
+ result = apply_best_layout(
+ workflow,
+ LayoutSettings(node_x_distance=60, node_y_distance=60),
+ candidate_count=1,
+ )
+
+ assert {
+ link_id: (link.source, link.source_port, link.target, link.target_port, link.type)
+ for link_id, link in result.links.items()
+ } == original_links
+ assert result.layout_report is not None
+ assert result.layout_report.candidate_count >= 1
+ assert result.layout_report.selected_candidate >= 1
From 419fe04639d6706a16ec0343699fd5c27f776025 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:37:58 +0200
Subject: [PATCH 004/105] Document layout engine v2 architecture
---
docs/LAYOUT_ENGINE_V2.md | 134 +++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 docs/LAYOUT_ENGINE_V2.md
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
new file mode 100644
index 0000000..572d04e
--- /dev/null
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -0,0 +1,134 @@
+# Layout Engine v2
+
+This document describes the experimental second-generation layout engine on the
+`layout-engine-v2` branch. It is intentionally separate from the release-facing
+README until corpus validation is complete.
+
+## Goal
+
+The legacy layout pipeline has mature handling for ComfyUI-specific geometry:
+groups, pinned surfaces, ungrouped bridge nodes, controls, text previews,
+virtual Set/Get hubs, node compaction, and collision clearance. The v2 work does
+not replace those contracts. It replaces the weakest part of the layout search:
+structural crossing minimization and candidate scoring.
+
+The design goal is that a v2 structural refinement is never mandatory. For each
+spacing/wrapping candidate, FlowForge first produces the normal legacy layout,
+then tries the v2 refinement on a copy. The refined copy is kept only when the
+v2 score improves. This keeps the legacy candidate as a local safety fallback.
+
+## Current v2 Pipeline
+
+For every legacy layout candidate:
+
+1. Run the existing six-phase layout pass.
+2. Find fully movable groups whose internal graph contains links.
+3. Condense strongly connected components (SCCs) with Tarjan's algorithm.
+4. Assign longest-path layers on the condensed DAG.
+5. Preserve the existing debug/control sidecar layer compression rules.
+6. Expand links that skip layers with in-memory dummy vertices.
+7. Run repeated directional median sweeps over adjacent layers.
+8. Run local adjacent-node transposition passes while crossings improve.
+9. Remove dummy vertices from the physical layout and assign node coordinates.
+10. Re-run the existing bounding-box, group-overlap, pinned-clearance,
+ control, text-preview, and virtual-hub geometry contracts.
+11. Score both the legacy and refined versions and retain the lower-cost one.
+12. Select the best candidate using the v2 score.
+
+Dummy vertices are layout-only objects. They never become ComfyUI nodes and are
+never serialized.
+
+## Port-Aware Candidate Score
+
+The legacy candidate scorer measures physical links between node centres. The
+v2 scorer measures each physical link from the source output socket to the
+target input socket using the same port geometry helpers used by FlowForge's
+placement code.
+
+The v2 score currently combines:
+
+- straight-line port-to-port crossings;
+- right-to-left physical links;
+- overlaps between movable, non-decorative, non-virtual nodes;
+- total Manhattan cable length;
+- workflow width and height;
+- a wide-aspect penalty.
+
+Crossings, right-to-left links, and movable overlaps receive substantially
+higher weights than small compactness differences.
+
+## SCC Handling
+
+The legacy longest-path layerer falls cyclic leftovers back to layer zero. v2
+instead computes strongly connected components first. Every SCC is treated as a
+single vertex while assigning global layers. Nodes inside an SCC share the same
+layer because a cycle cannot be represented as strictly left-to-right without
+breaking at least one edge.
+
+This prevents an unrelated cycle from collapsing the rest of a connected
+subgraph back into the source layer.
+
+## Long-Edge Dummy Vertices
+
+A link from layer 0 to layer 4 participates in crossing minimization at every
+intermediate boundary:
+
+```text
+source -> dummy@1 -> dummy@2 -> dummy@3 -> target
+```
+
+The dummy chain exists only while calculating vertical order. This makes a long
+wire influence layers 1, 2, and 3 instead of appearing only as a distant
+source/target relationship.
+
+## Safety Constraints
+
+The structural refiner currently skips a group when:
+
+- the group is pinned;
+- any member node is pinned;
+- the group contains fewer than two nodes;
+- the group has no internal physical links;
+- the group contains decorative or virtual Set/Get hub nodes.
+
+Existing bridge, local-control, text-preview, virtual-hub, pinned-surface, and
+group-overlap passes remain authoritative after refinement.
+
+## Current Scope Boundary
+
+This first implementation deliberately refines **group internals only**.
+Ungrouped linked flows and top-level group ordering still use the mature legacy
+placement. Extending the same SCC/dummy/sweep mechanism to those surfaces is a
+follow-up only after the group-internal implementation passes the repository CI
+and the `example-workflows` corpus is re-measured.
+
+## Validation
+
+Repository CI must remain green:
+
+```powershell
+uv run pytest
+uv run ruff check .
+uv run mypy flowforge
+npm run typecheck
+npm run build
+```
+
+New regression tests cover:
+
+- SCC condensation and downstream longest-path assignment;
+- dummy-vertex participation for long crossing edges;
+- port-aware right-to-left detection;
+- pinned-group refinement blocking;
+- graph/link preservation through v2 candidate selection.
+
+Before merging v2, reproduce the read-only corpus reports for both layout-only
+and Optimize + Layout and compare them with the documented baseline in
+`docs/OPTIMIZER_NOTES.md`.
+
+## Documentation Status
+
+The release-facing README is intentionally unchanged while this branch remains
+experimental. If corpus validation confirms v2 as the new default, README.md,
+CHANGELOG.md, and any affected frontend documentation must be updated before the
+PR is marked ready for review.
From 93bd5834ea260af822ada8529e7e0178f05cf85c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:38:52 +0200
Subject: [PATCH 005/105] Preserve in-place layout API with engine v2
---
flowforge/layout_engine_v2_runtime.py | 56 +++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 flowforge/layout_engine_v2_runtime.py
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
new file mode 100644
index 0000000..855f2a4
--- /dev/null
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -0,0 +1,56 @@
+"""Compatibility runtime for the v2 layout engine.
+
+The v2 candidate search intentionally works on deep copies so losing candidates
+cannot mutate caller state. The established FlowForge ``apply`` contract,
+however, mutates the supplied Workflow and preserves Node/Group object identity.
+This module commits only the selected layout geometry back onto those original
+objects.
+"""
+
+from __future__ import annotations
+
+from .layout import LayoutSettings
+from .layout_engine_v2 import apply_best_layout
+from .model import Workflow
+
+
+def apply(workflow: Workflow, settings: LayoutSettings | None = None) -> Workflow:
+ """Run v2 candidate search and commit the selected geometry in place."""
+ selected = apply_best_layout(workflow, settings)
+ if selected is workflow:
+ return workflow
+ _commit_layout_state(workflow, selected)
+ return workflow
+
+
+def _commit_layout_state(target: Workflow, selected: Workflow) -> None:
+ """Copy layout-only state while preserving caller-owned model objects."""
+ for node_id, selected_node in selected.nodes.items():
+ target_node = target.nodes.get(node_id)
+ if target_node is None:
+ continue
+ target_node.x = selected_node.x
+ target_node.y = selected_node.y
+ target_node.size = list(selected_node.size)
+
+ target_groups_by_id = {group.id: group for group in target.groups}
+ reordered_groups = []
+ for selected_group in selected.groups:
+ target_group = target_groups_by_id.get(selected_group.id)
+ if target_group is None:
+ continue
+ target_group.bounding = list(selected_group.bounding)
+ target_group.nodes = [
+ target.nodes[node.id]
+ for node in selected_group.nodes
+ if node.id in target.nodes
+ ]
+ reordered_groups.append(target_group)
+ target.groups = reordered_groups
+
+ target.ungrouped_nodes = [
+ target.nodes[node.id]
+ for node in selected.ungrouped_nodes
+ if node.id in target.nodes
+ ]
+ target.layout_report = selected.layout_report
From 8eb2fc97df68144da4579dad36f20c296b848842 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:38:59 +0200
Subject: [PATCH 006/105] Use in-place runtime adapter for layout engine v2
---
flowforge/__init__.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/flowforge/__init__.py b/flowforge/__init__.py
index b715b29..18c216b 100644
--- a/flowforge/__init__.py
+++ b/flowforge/__init__.py
@@ -5,14 +5,13 @@
from .model import Node, Link, Group, Workflow
from .parser import parse_comfyui_workflow
from . import layout as _layout_module
-from .layout_engine_v2 import apply as apply_layout
+from .layout_engine_v2_runtime import apply as apply_layout
from .optimizer import optimize as optimize_workflow
# Keep the established ``flowforge.layout`` import surface working while the
-# v2 engine lives in its own module. Modules such as api.py, cli.py, and
-# layout_quality.py import ``apply`` from flowforge.layout after package
-# initialization, so they transparently use v2 without duplicating the mature
-# geometry helpers that remain in layout.py.
+# v2 engine lives in separate modules. The runtime adapter preserves the legacy
+# in-place mutation and object-identity contract after v2 finishes its isolated
+# candidate search.
_layout_module.apply = apply_layout
__all__ = [
From e6e07f6f34f5a5e9542d35785ebe1cc1f1e5f3a2 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:41:54 +0200
Subject: [PATCH 007/105] Fix layout engine v2 lint
---
flowforge/layout_engine_v2.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/flowforge/layout_engine_v2.py b/flowforge/layout_engine_v2.py
index 267f1c9..55f9182 100644
--- a/flowforge/layout_engine_v2.py
+++ b/flowforge/layout_engine_v2.py
@@ -56,7 +56,7 @@
_update_bounding_boxes,
)
from .logger import setup_logger
-from .model import Group, Link, Node, Workflow
+from .model import Group, Link, Workflow
logger = setup_logger(__name__)
@@ -691,4 +691,4 @@ def _workflow_bounds(workflow: Workflow) -> tuple[float, float, float, float]:
min(box[1] for box in boxes),
max(box[2] for box in boxes),
max(box[3] for box in boxes),
- )
+ )
\ No newline at end of file
From 84d1b776275ec1b9d00dbaed4131a6b7f67475d2 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:43:20 +0200
Subject: [PATCH 008/105] Fix layout engine v2 component typing
---
flowforge/layout_engine_v2.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/flowforge/layout_engine_v2.py b/flowforge/layout_engine_v2.py
index 55f9182..92f61f4 100644
--- a/flowforge/layout_engine_v2.py
+++ b/flowforge/layout_engine_v2.py
@@ -274,11 +274,11 @@ def _assign_scc_longest_path_layers(
queue = sorted(component_layer)
mutable_indegree = dict(indegree)
while queue:
- component = queue.pop(0)
- for target in sorted(component_adj[component]):
+ component_id = queue.pop(0)
+ for target in sorted(component_adj[component_id]):
component_layer[target] = max(
component_layer.get(target, 0),
- component_layer[component] + 1,
+ component_layer[component_id] + 1,
)
mutable_indegree[target] -= 1
if mutable_indegree[target] == 0:
From e5f6379f6b0b00f30ce7ebcbd9631b8d29cc59ea Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:00:32 +0200
Subject: [PATCH 009/105] Add boundary-aware layout refinement
---
flowforge/layout_engine_v2_phase15.py | 521 ++++++++++++++++++++++++++
1 file changed, 521 insertions(+)
create mode 100644 flowforge/layout_engine_v2_phase15.py
diff --git a/flowforge/layout_engine_v2_phase15.py b/flowforge/layout_engine_v2_phase15.py
new file mode 100644
index 0000000..2f398a9
--- /dev/null
+++ b/flowforge/layout_engine_v2_phase15.py
@@ -0,0 +1,521 @@
+"""Boundary-aware refinement and crossing-first selection for layout engine v2.
+
+This phase builds on :mod:`flowforge.layout_engine_v2` without changing its
+SCC/dummy-vertex core. It adds fixed boundary anchors for links that enter or
+leave a movable group and uses a crossing-first comparison policy when choosing
+between layout candidates.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Hashable
+
+from .layout import (
+ LayoutReport,
+ LayoutScore,
+ LayoutSettings,
+ _apply_layout_pass,
+ _build_layout_candidates,
+ _compress_debug_sidecar_layers,
+ _group_bottom_padding,
+ _group_top_padding,
+ _node_input_port_y,
+ _node_output_port_y,
+ _node_visual_height,
+ _node_visual_width,
+ _resolve_layout_candidate_count,
+ _score_layout_candidate,
+)
+from .layout_engine_v2 import (
+ EngineV2Score,
+ V2_SWEEP_ROUNDS,
+ V2_TRANSPOSE_PASSES,
+ _assign_scc_longest_path_layers,
+ _boundary_crossings,
+ _finalize_refinement,
+ _group_can_be_refined,
+ _median_reorder_layer,
+ _port_segments,
+ _score_engine_v2,
+ _segments_intersect,
+ _vertex_bypassed,
+ _vertex_key,
+)
+from .logger import setup_logger
+from .model import Group, Link, Workflow
+
+logger = setup_logger(__name__)
+
+Vertex = Hashable
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Select a layout with boundary-aware refinement and crossing-first gates."""
+ settings = settings or LayoutSettings()
+ resolved_candidate_count = (
+ _resolve_layout_candidate_count(workflow)
+ if candidate_count is None
+ else max(1, int(candidate_count))
+ )
+ variants = _build_layout_candidates(workflow, settings, resolved_candidate_count)
+
+ best_workflow: Workflow | None = None
+ best_score: EngineV2Score | None = None
+ best_index = 0
+
+ for index, variant in enumerate(variants, start=1):
+ baseline = deepcopy(workflow)
+ _apply_layout_pass(baseline, variant, log=False)
+ baseline_score = _score_engine_v2(baseline)
+
+ refined = deepcopy(baseline)
+ changed = _refine_movable_group_internals_boundary_aware(refined, variant)
+ if changed:
+ _finalize_refinement(refined, variant)
+ refined_score = _score_engine_v2(refined)
+ if _score_order_key(refined_score) < _score_order_key(baseline_score):
+ candidate = refined
+ candidate_score = refined_score
+ else:
+ candidate = baseline
+ candidate_score = baseline_score
+ else:
+ candidate = baseline
+ candidate_score = baseline_score
+
+ logger.debug(
+ "v2.1 candidate %s/%s crossings=%s rtl=%s overlaps=%s link=%.2f size=%.0fx%.0f",
+ index,
+ len(variants),
+ candidate_score.crossings,
+ candidate_score.right_to_left_links,
+ candidate_score.movable_overlaps,
+ candidate_score.link_length,
+ candidate_score.width,
+ candidate_score.height,
+ )
+
+ if best_score is None or _score_order_key(candidate_score) < _score_order_key(best_score):
+ best_workflow = candidate
+ best_score = candidate_score
+ best_index = index
+
+ if best_workflow is None or best_score is None:
+ raise RuntimeError("Layout engine v2.1 did not produce a candidate")
+
+ legacy_score: LayoutScore = _score_layout_candidate(best_workflow)
+ best_workflow.layout_report = LayoutReport(
+ candidate_count=len(variants),
+ selected_candidate=best_index,
+ score=legacy_score,
+ )
+ logger.info(
+ "Layout engine v2.1 selected candidate %s/%s: crossings=%s rtl=%s overlaps=%s",
+ best_index,
+ len(variants),
+ best_score.crossings,
+ best_score.right_to_left_links,
+ best_score.movable_overlaps,
+ )
+ return best_workflow
+
+
+def _score_order_key(score: EngineV2Score) -> tuple[int, int, int, float, float, float, float]:
+ """Return the lexicographic candidate order used by the v2.1 safety gate.
+
+ Geometry safety is evaluated first, then physical crossings, then
+ right-to-left links. Cable length and compactness only break ties after the
+ structural metrics are equal.
+ """
+ area = score.width * score.height
+ return (
+ score.movable_overlaps,
+ score.crossings,
+ score.right_to_left_links,
+ score.link_length,
+ area,
+ score.width + score.height,
+ score.total,
+ )
+
+
+def _refine_movable_group_internals_boundary_aware(
+ workflow: Workflow,
+ settings: LayoutSettings,
+) -> bool:
+ """Refine movable groups while treating external endpoints as fixed anchors."""
+ changed = False
+ for group in workflow.groups:
+ if not _group_can_be_refined(workflow, group):
+ continue
+ if _refine_group_boundary_aware(workflow, group, settings):
+ changed = True
+ return changed
+
+
+def _refine_group_boundary_aware(
+ workflow: Workflow,
+ group: Group,
+ settings: LayoutSettings,
+) -> bool:
+ """Refine one group without allowing its incident crossing count to regress."""
+ node_ids = {node.id for node in group.nodes}
+ adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
+ rev_adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
+ internal_links: list[Link] = []
+ boundary_links: list[Link] = []
+
+ for link in workflow.links.values():
+ source_inside = link.source in node_ids
+ target_inside = link.target in node_ids
+ if source_inside and target_inside:
+ adj[link.source].append(link.target)
+ rev_adj[link.target].append(link.source)
+ internal_links.append(link)
+ elif source_inside != target_inside:
+ boundary_links.append(link)
+
+ if not internal_links:
+ return False
+
+ layers = _assign_scc_longest_path_layers(node_ids, adj)
+ _compress_debug_sidecar_layers(layers, adj, rev_adj, workflow)
+ if len(set(layers.values())) <= 1:
+ return False
+
+ layer_order = _minimize_crossings_with_boundaries(
+ workflow,
+ layers,
+ internal_links,
+ boundary_links,
+ )
+ if not layer_order:
+ return False
+
+ before_crossings = _group_incident_crossing_count(workflow, node_ids)
+ original_positions = {
+ node_id: (workflow.nodes[node_id].x, workflow.nodes[node_id].y)
+ for node_id in node_ids
+ }
+ original_bounding = list(group.bounding)
+
+ _place_group_order(workflow, group, settings, layer_order)
+ after_crossings = _group_incident_crossing_count(workflow, node_ids)
+ if after_crossings > before_crossings:
+ for node_id, (x, y) in original_positions.items():
+ workflow.nodes[node_id].x = x
+ workflow.nodes[node_id].y = y
+ group.bounding = original_bounding
+ logger.debug(
+ "Rejected boundary-aware refinement for group %s: incident crossings %s -> %s",
+ group.id,
+ before_crossings,
+ after_crossings,
+ )
+ return False
+
+ return any(
+ (workflow.nodes[node_id].x, workflow.nodes[node_id].y) != original_positions[node_id]
+ for node_id in node_ids
+ )
+
+
+def _minimize_crossings_with_boundaries(
+ workflow: Workflow,
+ layers: dict[int, int],
+ internal_links: list[Link],
+ boundary_links: list[Link],
+) -> dict[int, list[Vertex]]:
+ """Order group nodes with fixed external endpoint anchors on both boundaries."""
+ max_layer = max(layers.values(), default=0)
+ left_boundary_layer = -1
+ right_boundary_layer = max_layer + 1
+ layer_to_vertices: dict[int, list[Vertex]] = {
+ layer: [] for layer in range(left_boundary_layer, right_boundary_layer + 1)
+ }
+ stable_y: dict[Vertex, float] = {}
+
+ for node_id, layer in layers.items():
+ layer_to_vertices[layer].append(node_id)
+ stable_y[node_id] = workflow.nodes[node_id].y
+
+ expanded_edges: list[tuple[Vertex, Vertex]] = []
+ for link in sorted(internal_links, key=lambda item: item.id):
+ source_layer = layers[link.source]
+ target_layer = layers[link.target]
+ if target_layer <= source_layer:
+ continue
+ _append_internal_edge_chain(
+ workflow,
+ layer_to_vertices,
+ stable_y,
+ expanded_edges,
+ link,
+ source_layer,
+ target_layer,
+ )
+
+ for link in sorted(boundary_links, key=lambda item: item.id):
+ source_inside = link.source in layers
+ target_inside = link.target in layers
+ if not source_inside and target_inside:
+ _append_incoming_boundary_chain(
+ workflow,
+ layer_to_vertices,
+ stable_y,
+ expanded_edges,
+ link,
+ layers[link.target],
+ )
+ elif source_inside and not target_inside:
+ _append_outgoing_boundary_chain(
+ workflow,
+ layer_to_vertices,
+ stable_y,
+ expanded_edges,
+ link,
+ layers[link.source],
+ max_layer,
+ )
+
+ for layer, vertices in layer_to_vertices.items():
+ if layer in {left_boundary_layer, right_boundary_layer}:
+ vertices.sort(key=lambda vertex: (stable_y.get(vertex, 0.0), _vertex_key(vertex)))
+ else:
+ vertices.sort(
+ key=lambda vertex: (
+ _vertex_bypassed(workflow, vertex),
+ stable_y.get(vertex, 0.0),
+ _vertex_key(vertex),
+ )
+ )
+
+ predecessors: dict[Vertex, list[Vertex]] = {}
+ successors: dict[Vertex, list[Vertex]] = {}
+ for source, target in expanded_edges:
+ successors.setdefault(source, []).append(target)
+ predecessors.setdefault(target, []).append(source)
+
+ for _ in range(V2_SWEEP_ROUNDS):
+ for layer in range(0, max_layer + 1):
+ _median_reorder_layer(
+ workflow,
+ layer_to_vertices,
+ layer,
+ predecessors,
+ reference_layer=layer - 1,
+ stable_y=stable_y,
+ )
+ for layer in range(max_layer, -1, -1):
+ _median_reorder_layer(
+ workflow,
+ layer_to_vertices,
+ layer,
+ successors,
+ reference_layer=layer + 1,
+ stable_y=stable_y,
+ )
+ _transpose_layers_with_boundaries(
+ layer_to_vertices,
+ expanded_edges,
+ max_layer,
+ )
+
+ return {layer: layer_to_vertices[layer] for layer in range(0, max_layer + 1)}
+
+
+def _append_internal_edge_chain(
+ workflow: Workflow,
+ layer_to_vertices: dict[int, list[Vertex]],
+ stable_y: dict[Vertex, float],
+ expanded_edges: list[tuple[Vertex, Vertex]],
+ link: Link,
+ source_layer: int,
+ target_layer: int,
+) -> None:
+ previous: Vertex = link.source
+ source_y = _node_output_port_y(workflow.nodes[link.source], link.source_port)
+ target_y = _node_input_port_y(workflow.nodes[link.target], link.target_port)
+ span = target_layer - source_layer
+ for layer in range(source_layer + 1, target_layer):
+ dummy: Vertex = ("dummy", link.id, layer)
+ layer_to_vertices[layer].append(dummy)
+ fraction = (layer - source_layer) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy))
+ previous = dummy
+ expanded_edges.append((previous, link.target))
+
+
+def _append_incoming_boundary_chain(
+ workflow: Workflow,
+ layer_to_vertices: dict[int, list[Vertex]],
+ stable_y: dict[Vertex, float],
+ expanded_edges: list[tuple[Vertex, Vertex]],
+ link: Link,
+ target_layer: int,
+) -> None:
+ source = workflow.nodes.get(link.source)
+ target = workflow.nodes.get(link.target)
+ if source is None or target is None:
+ return
+
+ anchor: Vertex = ("boundary-in", link.id)
+ layer_to_vertices[-1].append(anchor)
+ source_y = _node_output_port_y(source, link.source_port)
+ target_y = _node_input_port_y(target, link.target_port)
+ stable_y[anchor] = source_y
+ previous: Vertex = anchor
+ span = target_layer + 1
+
+ for layer in range(0, target_layer):
+ dummy: Vertex = ("boundary-in-dummy", link.id, layer)
+ layer_to_vertices[layer].append(dummy)
+ fraction = (layer + 1) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy))
+ previous = dummy
+ expanded_edges.append((previous, link.target))
+
+
+def _append_outgoing_boundary_chain(
+ workflow: Workflow,
+ layer_to_vertices: dict[int, list[Vertex]],
+ stable_y: dict[Vertex, float],
+ expanded_edges: list[tuple[Vertex, Vertex]],
+ link: Link,
+ source_layer: int,
+ max_layer: int,
+) -> None:
+ source = workflow.nodes.get(link.source)
+ target = workflow.nodes.get(link.target)
+ if source is None or target is None:
+ return
+
+ anchor: Vertex = ("boundary-out", link.id)
+ right_boundary_layer = max_layer + 1
+ layer_to_vertices[right_boundary_layer].append(anchor)
+ source_y = _node_output_port_y(source, link.source_port)
+ target_y = _node_input_port_y(target, link.target_port)
+ stable_y[anchor] = target_y
+ previous: Vertex = link.source
+ span = right_boundary_layer - source_layer
+
+ for layer in range(source_layer + 1, right_boundary_layer):
+ dummy: Vertex = ("boundary-out-dummy", link.id, layer)
+ layer_to_vertices[layer].append(dummy)
+ fraction = (layer - source_layer) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy))
+ previous = dummy
+ expanded_edges.append((previous, anchor))
+
+
+def _transpose_layers_with_boundaries(
+ layer_to_vertices: dict[int, list[Vertex]],
+ expanded_edges: list[tuple[Vertex, Vertex]],
+ max_layer: int,
+) -> None:
+ """Transpose interior vertices while counting crossings at fixed boundaries."""
+ for _ in range(V2_TRANSPOSE_PASSES):
+ improved = False
+ for layer in range(0, max_layer + 1):
+ vertices = layer_to_vertices[layer]
+ if len(vertices) <= 1:
+ continue
+ index = 0
+ while index < len(vertices) - 1:
+ before = _local_crossings_with_boundaries(
+ layer_to_vertices,
+ expanded_edges,
+ layer,
+ max_layer,
+ )
+ vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
+ after = _local_crossings_with_boundaries(
+ layer_to_vertices,
+ expanded_edges,
+ layer,
+ max_layer,
+ )
+ if after < before:
+ improved = True
+ else:
+ vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
+ index += 1
+ if not improved:
+ break
+
+
+def _local_crossings_with_boundaries(
+ layer_to_vertices: dict[int, list[Vertex]],
+ edges: list[tuple[Vertex, Vertex]],
+ layer: int,
+ max_layer: int,
+) -> int:
+ crossings = _boundary_crossings(layer_to_vertices, edges, layer - 1, layer)
+ crossings += _boundary_crossings(layer_to_vertices, edges, layer, layer + 1)
+ return crossings
+
+
+def _place_group_order(
+ workflow: Workflow,
+ group: Group,
+ settings: LayoutSettings,
+ layer_order: dict[int, list[Vertex]],
+) -> None:
+ """Apply an ordered group-internal layering to physical node coordinates."""
+ base_x = group.bounding[0] + settings.group_padding
+ base_y = group.bounding[1] + _group_top_padding(settings)
+ current_x = base_x
+ max_bottom = base_y
+
+ for layer in sorted(layer_order):
+ node_order = [node_id for node_id in layer_order[layer] if isinstance(node_id, int)]
+ if not node_order:
+ continue
+
+ layer_width = max(_node_visual_width(workflow.nodes[node_id]) for node_id in node_order)
+ current_y = base_y
+ for node_id in node_order:
+ node = workflow.nodes[node_id]
+ node.x = current_x
+ node.y = current_y
+ current_y += _node_visual_height(node) + settings.node_v_gap
+ max_bottom = max(max_bottom, current_y - settings.node_v_gap)
+ current_x += layer_width + settings.node_h_gap
+
+ content_right = current_x - settings.node_h_gap
+ group.bounding = [
+ group.bounding[0],
+ group.bounding[1],
+ max(0.0, content_right - base_x) + 2.0 * settings.group_padding,
+ max(0.0, max_bottom - base_y)
+ + _group_top_padding(settings)
+ + _group_bottom_padding(settings),
+ ]
+
+
+def _group_incident_crossing_count(workflow: Workflow, node_ids: set[int]) -> int:
+ """Count physical crossings where at least one segment touches the group."""
+ segments = _port_segments(workflow)
+ crossings = 0
+ for index, first in enumerate(segments):
+ for second in segments[index + 1 :]:
+ if not (
+ first[0] in node_ids
+ or first[1] in node_ids
+ or second[0] in node_ids
+ or second[1] in node_ids
+ ):
+ continue
+ if first[0] in {second[0], second[1]} or first[1] in {second[0], second[1]}:
+ continue
+ if _segments_intersect(first[2], first[3], second[2], second[3]):
+ crossings += 1
+ return crossings
From bddb1590e223c0a7b85df9a27a13ea3f303998bf Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:00:47 +0200
Subject: [PATCH 010/105] Test boundary-aware layout refinement
---
tests/test_layout_engine_v2_phase15.py | 123 +++++++++++++++++++++++++
1 file changed, 123 insertions(+)
create mode 100644 tests/test_layout_engine_v2_phase15.py
diff --git a/tests/test_layout_engine_v2_phase15.py b/tests/test_layout_engine_v2_phase15.py
new file mode 100644
index 0000000..56cae10
--- /dev/null
+++ b/tests/test_layout_engine_v2_phase15.py
@@ -0,0 +1,123 @@
+"""Regression tests for boundary-aware layout engine v2.1 behavior."""
+
+import flowforge.layout_engine_v2_phase15 as phase15
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import EngineV2Score
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def test_score_order_is_crossing_first_after_overlap_safety():
+ fewer_crossings = EngineV2Score(
+ total=999_999.0,
+ crossings=4,
+ right_to_left_links=20,
+ movable_overlaps=0,
+ link_length=99_999.0,
+ width=9_000.0,
+ height=9_000.0,
+ )
+ fewer_rtl_but_more_crossings = EngineV2Score(
+ total=1.0,
+ crossings=5,
+ right_to_left_links=0,
+ movable_overlaps=0,
+ link_length=1.0,
+ width=1.0,
+ height=1.0,
+ )
+
+ assert phase15._score_order_key(fewer_crossings) < phase15._score_order_key(
+ fewer_rtl_but_more_crossings
+ )
+
+
+def test_boundary_anchors_drive_target_order_from_external_geometry():
+ workflow = Workflow()
+ workflow.nodes = {
+ 1: Node(id=1, type="InternalSource", x=100, y=200, size=[200, 80], output_count=2),
+ 2: Node(id=2, type="TargetA", x=500, y=0, size=[200, 80], input_count=2),
+ 3: Node(id=3, type="TargetB", x=500, y=400, size=[200, 80], input_count=2),
+ 4: Node(id=4, type="ExternalTop", x=-500, y=0, size=[200, 80], output_count=1),
+ 5: Node(id=5, type="ExternalBottom", x=-500, y=400, size=[200, 80], output_count=1),
+ }
+ internal_links = [
+ Link(id=10, source=1, source_port=0, target=2, target_port=0, type="DATA"),
+ Link(id=11, source=1, source_port=1, target=3, target_port=0, type="DATA"),
+ ]
+ boundary_links = [
+ Link(id=12, source=4, source_port=0, target=3, target_port=1, type="DATA"),
+ Link(id=13, source=5, source_port=0, target=2, target_port=1, type="DATA"),
+ ]
+ layers = {1: 0, 2: 1, 3: 1}
+
+ ordered = phase15._minimize_crossings_with_boundaries(
+ workflow,
+ layers,
+ internal_links,
+ boundary_links,
+ )
+
+ real_target_order = [vertex for vertex in ordered[1] if isinstance(vertex, int)]
+ assert real_target_order.index(3) < real_target_order.index(2)
+
+
+def test_group_refinement_reverts_when_incident_crossings_increase(monkeypatch):
+ workflow = Workflow()
+ source = Node(id=1, type="InternalSource", x=100, y=250, size=[200, 80], output_count=2)
+ top = Node(id=2, type="Top", x=500, y=100, size=[200, 80], input_count=2)
+ bottom = Node(id=3, type="Bottom", x=500, y=400, size=[200, 80], input_count=2)
+ external_top = Node(id=4, type="ExternalTop", x=-500, y=100, size=[200, 80], output_count=1)
+ external_bottom = Node(
+ id=5,
+ type="ExternalBottom",
+ x=-500,
+ y=400,
+ size=[200, 80],
+ output_count=1,
+ )
+ workflow.nodes = {
+ 1: source,
+ 2: top,
+ 3: bottom,
+ 4: external_top,
+ 5: external_bottom,
+ }
+ for link in [
+ Link(id=10, source=1, source_port=0, target=2, target_port=0, type="DATA"),
+ Link(id=11, source=1, source_port=1, target=3, target_port=0, type="DATA"),
+ Link(id=12, source=4, source_port=0, target=2, target_port=1, type="DATA"),
+ Link(id=13, source=5, source_port=0, target=3, target_port=1, type="DATA"),
+ ]:
+ _attach_link(workflow, link)
+
+ group = Group(
+ id=1,
+ name="Pipeline",
+ nodes=[source, top, bottom],
+ bounding=[0, 0, 900, 600],
+ )
+ workflow.groups = [group]
+ original_positions = {node.id: (node.x, node.y) for node in group.nodes}
+ original_bounding = list(group.bounding)
+
+ monkeypatch.setattr(
+ phase15,
+ "_minimize_crossings_with_boundaries",
+ lambda *_args, **_kwargs: {0: [1], 1: [3, 2]},
+ )
+
+ changed = phase15._refine_group_boundary_aware(
+ workflow,
+ group,
+ LayoutSettings(node_x_distance=60, node_y_distance=60),
+ )
+
+ assert not changed
+ assert {node.id: (node.x, node.y) for node in group.nodes} == original_positions
+ assert group.bounding == original_bounding
From 02effc1c1a2ec0aaab1b0451d1d83db33d76bf88 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:00:55 +0200
Subject: [PATCH 011/105] Use boundary-aware layout engine runtime
---
flowforge/layout_engine_v2_runtime.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
index 855f2a4..cbe426f 100644
--- a/flowforge/layout_engine_v2_runtime.py
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -10,7 +10,7 @@
from __future__ import annotations
from .layout import LayoutSettings
-from .layout_engine_v2 import apply_best_layout
+from .layout_engine_v2_phase15 import apply_best_layout
from .model import Workflow
From 8083c410af8b67e983432402d09ee3b9d57e3533 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:01:45 +0200
Subject: [PATCH 012/105] Document boundary-aware layout refinement
---
docs/LAYOUT_ENGINE_V2.md | 132 +++++++++++++++++++++++++++++++--------
1 file changed, 105 insertions(+), 27 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 572d04e..d215134 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -15,7 +15,8 @@ structural crossing minimization and candidate scoring.
The design goal is that a v2 structural refinement is never mandatory. For each
spacing/wrapping candidate, FlowForge first produces the normal legacy layout,
then tries the v2 refinement on a copy. The refined copy is kept only when the
-v2 score improves. This keeps the legacy candidate as a local safety fallback.
+v2 selection policy improves. This keeps the legacy candidate as a local safety
+fallback.
## Current v2 Pipeline
@@ -27,35 +28,73 @@ For every legacy layout candidate:
4. Assign longest-path layers on the condensed DAG.
5. Preserve the existing debug/control sidecar layer compression rules.
6. Expand links that skip layers with in-memory dummy vertices.
-7. Run repeated directional median sweeps over adjacent layers.
-8. Run local adjacent-node transposition passes while crossings improve.
-9. Remove dummy vertices from the physical layout and assign node coordinates.
-10. Re-run the existing bounding-box, group-overlap, pinned-clearance,
+7. Add fixed boundary anchors for physical links entering or leaving a group.
+8. Expand boundary links across intermediate layers with in-memory dummy chains.
+9. Run repeated directional median sweeps over adjacent layers.
+10. Run local adjacent-node transposition passes while crossings improve,
+ including the fixed boundary layers in the local crossing count.
+11. Reject a group refinement if its physical incident crossing count increases.
+12. Remove dummy and boundary vertices from the physical layout and assign node
+ coordinates.
+13. Re-run the existing bounding-box, group-overlap, pinned-clearance,
control, text-preview, and virtual-hub geometry contracts.
-11. Score both the legacy and refined versions and retain the lower-cost one.
-12. Select the best candidate using the v2 score.
+14. Compare the legacy and refined versions with crossing-first candidate gates.
+15. Select the best candidate with the same crossing-first policy.
-Dummy vertices are layout-only objects. They never become ComfyUI nodes and are
-never serialized.
+Dummy and boundary vertices are layout-only objects. They never become ComfyUI
+nodes and are never serialized.
-## Port-Aware Candidate Score
+## Boundary-Aware Group Refinement
+
+The first v2 implementation optimized only links whose source and target were
+inside the same group. Corpus comparison showed that this reduced pure
+within-group crossings but could move an otherwise well-placed boundary node
+away from its external neighbours.
+
+Phase 1.5 models incoming and outgoing physical links as fixed anchors outside
+the left and right group boundaries. Their vertical order comes from the actual
+external socket geometry. Dummy chains carry that order through every internal
+layer to the connected node.
+
+After the proposed node order is placed, FlowForge counts all physical
+crossings where at least one segment touches the group. If the count increased,
+the group geometry is restored exactly to its pre-refinement state.
+
+This local guard is intentionally stricter than a weighted score: a shorter or
+more compact group cannot buy an increase in incident crossings.
+
+## Crossing-First Candidate Selection
+
+The port-aware v2 metrics are still collected for diagnostics, but candidate
+selection is now lexicographic instead of relying only on a weighted sum:
+
+1. movable overlaps;
+2. straight-line port-to-port crossings;
+3. right-to-left physical links;
+4. total Manhattan cable length;
+5. workflow area;
+6. width plus height;
+7. the original weighted v2 score as the final tie-breaker.
+
+This means a candidate with fewer right-to-left links, shorter cables, or a
+smaller canvas cannot replace another candidate if it introduces an additional
+physical crossing at the same overlap count.
+
+## Port-Aware Geometry
The legacy candidate scorer measures physical links between node centres. The
-v2 scorer measures each physical link from the source output socket to the
+v2 metrics measure each physical link from the source output socket to the
target input socket using the same port geometry helpers used by FlowForge's
placement code.
-The v2 score currently combines:
+The metrics include:
- straight-line port-to-port crossings;
- right-to-left physical links;
- overlaps between movable, non-decorative, non-virtual nodes;
- total Manhattan cable length;
- workflow width and height;
-- a wide-aspect penalty.
-
-Crossings, right-to-left links, and movable overlaps receive substantially
-higher weights than small compactness differences.
+- a wide-aspect penalty used by the diagnostic weighted score.
## SCC Handling
@@ -94,13 +133,49 @@ The structural refiner currently skips a group when:
Existing bridge, local-control, text-preview, virtual-hub, pinned-surface, and
group-overlap passes remain authoritative after refinement.
+In addition, Phase 1.5 rejects an individual group refinement when its incident
+physical crossing count increases, and final candidate selection rejects a
+crossing regression when overlap safety is unchanged.
+
+## Corpus Baseline and First v2 Comparison
+
+The current local corpus contains 81 UI workflows, 3,793 nodes, and 4,357
+physical links. The same corpus was measured on `master` and on the first v2
+implementation before Phase 1.5.
+
+| Mode | Master crossings | v2 crossings | Master RTL | v2 RTL |
+| --- | ---: | ---: | ---: | ---: |
+| Layout only | 6,390 | 6,068 | 461 | 379 |
+| Optimize + Layout | 4,320 | 4,395 | 372 | 318 |
+
+The first v2 implementation therefore improved layout-only crossings by 322 and
+RTL links by 82, while Optimize + Layout regressed by 75 crossings despite
+improving RTL by 54.
+
+Category comparison identified the main boundary problem:
+
+- `within_group x within_group`: 873 -> 607 in layout-only;
+- `group->group x group->group`: 1,485 -> 1,301 in layout-only;
+- `group->group x within_group`: 941 -> 1,153 in layout-only;
+- `group->group x within_group`: 799 -> 940 with Optimize + Layout.
+
+The SDXL workflow `Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` was the
+largest single regression: v2 added about 100 crossings relative to master in
+both modes. It is the primary real-world regression case for Phase 1.5.
+
+Phase 1.5 must now be re-measured on the same 81-workflow corpus before the
+engine proceeds to top-level group ordering.
+
## Current Scope Boundary
-This first implementation deliberately refines **group internals only**.
-Ungrouped linked flows and top-level group ordering still use the mature legacy
-placement. Extending the same SCC/dummy/sweep mechanism to those surfaces is a
-follow-up only after the group-internal implementation passes the repository CI
-and the `example-workflows` corpus is re-measured.
+The engine still deliberately changes structural ordering only inside movable
+groups. Ungrouped linked flows and top-level group ordering continue to use the
+legacy placement.
+
+If Phase 1.5 removes the boundary regression without sacrificing the current
+within-group gains, the next step is a weighted group-level Sugiyama pass:
+SCC condensation, group dependency layers, weighted inter-group edges, dummy
+vertices for long group edges, repeated sweeps, and local transpose refinement.
## Validation
@@ -114,17 +189,20 @@ npm run typecheck
npm run build
```
-New regression tests cover:
+Regression tests now cover:
- SCC condensation and downstream longest-path assignment;
- dummy-vertex participation for long crossing edges;
- port-aware right-to-left detection;
- pinned-group refinement blocking;
-- graph/link preservation through v2 candidate selection.
-
-Before merging v2, reproduce the read-only corpus reports for both layout-only
-and Optimize + Layout and compare them with the documented baseline in
-`docs/OPTIMIZER_NOTES.md`.
+- graph/link preservation through v2 candidate selection;
+- crossing-first candidate ordering;
+- boundary-anchor influence from external socket geometry;
+- rejection and exact rollback of a group refinement that increases incident
+ crossings.
+
+Before merging v2, reproduce the read-only corpus reports for both Layout Only
+and Optimize + Layout and compare them with `master` on the same workflow set.
## Documentation Status
From 670b66122770abe5318624cf8bc5c609852ca639 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:15:07 +0200
Subject: [PATCH 013/105] Ignore local Superpowers tooling state
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index f5b8ad5..34fb436 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,8 +67,9 @@ htmlcov/
*.cover
coverage.xml
-# Kilo
+# Local AI/tooling state
.kilo/
+.superpowers/
# Frontend
frontend/node_modules/
From 83ff4afacb791c101ba6943cb3fd9e48002e2794 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:18:59 +0200
Subject: [PATCH 014/105] Add weighted group-level crossing refinement
---
flowforge/layout_engine_v2_phase2.py | 478 +++++++++++++++++++++++++++
1 file changed, 478 insertions(+)
create mode 100644 flowforge/layout_engine_v2_phase2.py
diff --git a/flowforge/layout_engine_v2_phase2.py b/flowforge/layout_engine_v2_phase2.py
new file mode 100644
index 0000000..b865db1
--- /dev/null
+++ b/flowforge/layout_engine_v2_phase2.py
@@ -0,0 +1,478 @@
+"""Group-level crossing refinement for FlowForge layout engine v2.
+
+Phase 2 keeps the proven node-level v2 engine as its baseline, then treats
+movable groups as vertices in a second Sugiyama-style ordering pass. The pass
+uses the existing inter-group topology, SCC-aware layer assignment, weighted
+physical group edges, dummy vertices for long edges, repeated weighted-median
+sweeps, and local transposition.
+
+Only vertical order inside an existing group-flow layer is changed. Group
+columns, internal node geometry, pinned groups, and graph topology are left
+untouched. A refined result is accepted only when its global port-aware quality
+key improves over the baseline.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+import math
+from typing import TypeAlias
+
+from .layout import LayoutSettings, _group_flow_adjacency, _is_pinned_node
+from .layout_engine_v2 import (
+ EngineV2Score,
+ _assign_scc_longest_path_layers,
+ _finalize_refinement,
+ _score_engine_v2,
+ apply_best_layout as _apply_core_best_layout,
+)
+from .logger import setup_logger
+from .model import Group, Workflow
+
+logger = setup_logger(__name__)
+
+GROUP_SWEEP_ROUNDS = 6
+GROUP_TRANSPOSE_PASSES = 6
+
+GroupDummy: TypeAlias = tuple[str, int, int, int]
+GroupVertex: TypeAlias = int | GroupDummy
+WeightedGroupEdge: TypeAlias = tuple[GroupVertex, GroupVertex, int]
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Run the core v2 engine, then try weighted group-level refinement."""
+ settings = settings or LayoutSettings()
+ baseline = _apply_core_best_layout(workflow, settings, candidate_count)
+ baseline_score = _score_engine_v2(baseline)
+
+ refined = deepcopy(baseline)
+ if not _refine_group_level_order(refined, settings):
+ return baseline
+
+ _finalize_refinement(refined, settings)
+ refined_score = _score_engine_v2(refined)
+ if _quality_key(refined_score) < _quality_key(baseline_score):
+ logger.info(
+ "Group-level v2 refinement accepted: crossings %s -> %s, rtl %s -> %s",
+ baseline_score.crossings,
+ refined_score.crossings,
+ baseline_score.right_to_left_links,
+ refined_score.right_to_left_links,
+ )
+ return refined
+
+ logger.info(
+ "Group-level v2 refinement rejected: crossings %s -> %s, rtl %s -> %s",
+ baseline_score.crossings,
+ refined_score.crossings,
+ baseline_score.right_to_left_links,
+ refined_score.right_to_left_links,
+ )
+ return baseline
+
+
+def _quality_key(score: EngineV2Score) -> tuple[int, int, int, float, float, float, float]:
+ """Compare layout candidates lexicographically by visible graph quality."""
+ return (
+ score.movable_overlaps,
+ score.crossings,
+ score.right_to_left_links,
+ score.link_length,
+ score.width + score.height,
+ score.width,
+ score.height,
+ )
+
+
+def _refine_group_level_order(workflow: Workflow, settings: LayoutSettings) -> bool:
+ """Reorder movable groups vertically inside their existing flow layers."""
+ groups = [group for group in workflow.groups if group.nodes]
+ if len(groups) < 2:
+ return False
+
+ group_ids = {group.id for group in groups}
+ adjacency_sets, _reverse = _group_flow_adjacency(workflow)
+ adjacency = {
+ group_id: sorted(target for target in adjacency_sets.get(group_id, set()) if target in group_ids)
+ for group_id in group_ids
+ }
+ if not any(adjacency.values()):
+ return False
+
+ layers = _assign_scc_longest_path_layers(group_ids, adjacency)
+ edge_weights = _group_edge_weights(workflow, group_ids, adjacency)
+ if not edge_weights:
+ return False
+
+ movable_group_ids = {
+ group.id
+ for group in groups
+ if not _group_is_fixed(workflow, group)
+ and len(group.bounding) >= 4
+ and group.bounding[2] > 0
+ and group.bounding[3] > 0
+ }
+ if len(movable_group_ids) < 2:
+ return False
+
+ layer_order = _weighted_group_order(
+ workflow,
+ layers,
+ edge_weights,
+ movable_group_ids,
+ )
+ return _apply_group_layer_order(
+ workflow,
+ layers,
+ layer_order,
+ movable_group_ids,
+ settings,
+ )
+
+
+def _group_is_fixed(workflow: Workflow, group: Group) -> bool:
+ return group.pinned or any(_is_pinned_node(workflow, node) for node in group.nodes)
+
+
+def _group_edge_weights(
+ workflow: Workflow,
+ group_ids: set[int],
+ adjacency: dict[int, list[int]],
+) -> dict[tuple[int, int], int]:
+ """Weight direct group dependencies by their physical wire multiplicity."""
+ group_by_node_id = {
+ node.id: group
+ for group in workflow.groups
+ for node in group.nodes
+ if group.id in group_ids
+ }
+ weights: dict[tuple[int, int], int] = {}
+
+ for link in workflow.links.values():
+ source_group = group_by_node_id.get(link.source)
+ target_group = group_by_node_id.get(link.target)
+ if source_group is None or target_group is None or source_group is target_group:
+ continue
+ pair = (source_group.id, target_group.id)
+ if target_group.id not in adjacency.get(source_group.id, []):
+ continue
+ weights[pair] = weights.get(pair, 0) + 1
+
+ # Bridge-only dependencies do not have one physical link whose endpoints
+ # both belong to groups. Keep them in the ordering graph with unit weight.
+ for source_id, targets in adjacency.items():
+ for target_id in targets:
+ weights.setdefault((source_id, target_id), 1)
+
+ return weights
+
+
+def _weighted_group_order(
+ workflow: Workflow,
+ layers: dict[int, int],
+ edge_weights: dict[tuple[int, int], int],
+ movable_group_ids: set[int],
+) -> dict[int, list[GroupVertex]]:
+ """Order group vertices with weighted median sweeps and transposition."""
+ max_layer = max(layers.values(), default=0)
+ group_by_id = {group.id: group for group in workflow.groups}
+ layer_to_vertices: dict[int, list[GroupVertex]] = {
+ layer: [] for layer in range(max_layer + 1)
+ }
+ stable_y: dict[GroupVertex, float] = {}
+
+ for group_id, layer in layers.items():
+ group = group_by_id.get(group_id)
+ if group is None:
+ continue
+ layer_to_vertices.setdefault(layer, []).append(group_id)
+ stable_y[group_id] = _group_center_y(group)
+
+ expanded_edges: list[WeightedGroupEdge] = []
+ for (source_id, target_id), weight in sorted(edge_weights.items()):
+ source_layer = layers.get(source_id, 0)
+ target_layer = layers.get(target_id, 0)
+ if target_layer <= source_layer:
+ continue
+
+ previous: GroupVertex = source_id
+ source_y = stable_y.get(source_id, 0.0)
+ target_y = stable_y.get(target_id, 0.0)
+ span = target_layer - source_layer
+ for layer in range(source_layer + 1, target_layer):
+ dummy: GroupDummy = ("group-dummy", source_id, target_id, layer)
+ layer_to_vertices.setdefault(layer, []).append(dummy)
+ fraction = (layer - source_layer) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy, weight))
+ previous = dummy
+ expanded_edges.append((previous, target_id, weight))
+
+ for vertices in layer_to_vertices.values():
+ vertices.sort(key=lambda vertex: (stable_y.get(vertex, 0.0), _vertex_key(vertex)))
+
+ predecessors: dict[GroupVertex, list[tuple[GroupVertex, int]]] = {}
+ successors: dict[GroupVertex, list[tuple[GroupVertex, int]]] = {}
+ for source, target, weight in expanded_edges:
+ successors.setdefault(source, []).append((target, weight))
+ predecessors.setdefault(target, []).append((source, weight))
+
+ for _ in range(GROUP_SWEEP_ROUNDS):
+ for layer in range(1, max_layer + 1):
+ _weighted_median_reorder_layer(
+ layer_to_vertices,
+ layer,
+ predecessors,
+ reference_layer=layer - 1,
+ stable_y=stable_y,
+ movable_group_ids=movable_group_ids,
+ )
+ for layer in range(max_layer - 1, -1, -1):
+ _weighted_median_reorder_layer(
+ layer_to_vertices,
+ layer,
+ successors,
+ reference_layer=layer + 1,
+ stable_y=stable_y,
+ movable_group_ids=movable_group_ids,
+ )
+ _weighted_transpose_layers(
+ layer_to_vertices,
+ expanded_edges,
+ max_layer,
+ movable_group_ids,
+ )
+
+ return layer_to_vertices
+
+
+def _weighted_median_reorder_layer(
+ layer_to_vertices: dict[int, list[GroupVertex]],
+ layer: int,
+ neighbours: dict[GroupVertex, list[tuple[GroupVertex, int]]],
+ *,
+ reference_layer: int,
+ stable_y: dict[GroupVertex, float],
+ movable_group_ids: set[int],
+) -> None:
+ vertices = layer_to_vertices.get(layer, [])
+ reference = layer_to_vertices.get(reference_layer, [])
+ if len(vertices) <= 1 or not reference:
+ return
+
+ # A layer containing fixed real groups is left in its physical order. This
+ # prevents an abstract order that cannot be committed from distorting the
+ # neighbouring layers during later sweeps.
+ if any(isinstance(vertex, int) and vertex not in movable_group_ids for vertex in vertices):
+ return
+
+ positions = {vertex: index for index, vertex in enumerate(reference)}
+
+ def sort_key(vertex: GroupVertex) -> tuple[float, float, str]:
+ weighted_positions = [
+ (positions[other], weight)
+ for other, weight in neighbours.get(vertex, [])
+ if other in positions
+ ]
+ median_position = (
+ _weighted_median(weighted_positions)
+ if weighted_positions
+ else math.inf
+ )
+ return (median_position, stable_y.get(vertex, 0.0), _vertex_key(vertex))
+
+ layer_to_vertices[layer] = sorted(vertices, key=sort_key)
+
+
+def _weighted_median(values: list[tuple[int, int]]) -> float:
+ """Return a deterministic weighted median of integer layer positions."""
+ ordered = sorted(values)
+ total_weight = sum(max(0, weight) for _position, weight in ordered)
+ if total_weight <= 0:
+ return math.inf
+
+ threshold = total_weight / 2.0
+ cumulative = 0
+ for position, weight in ordered:
+ cumulative += max(0, weight)
+ if cumulative >= threshold:
+ return float(position)
+ return float(ordered[-1][0])
+
+
+def _weighted_transpose_layers(
+ layer_to_vertices: dict[int, list[GroupVertex]],
+ edges: list[WeightedGroupEdge],
+ max_layer: int,
+ movable_group_ids: set[int],
+) -> None:
+ for _ in range(GROUP_TRANSPOSE_PASSES):
+ improved = False
+ for layer in range(max_layer + 1):
+ vertices = layer_to_vertices.get(layer, [])
+ if len(vertices) <= 1:
+ continue
+ index = 0
+ while index < len(vertices) - 1:
+ left = vertices[index]
+ right = vertices[index + 1]
+ if not _vertices_can_swap(left, right, movable_group_ids):
+ index += 1
+ continue
+
+ before = _weighted_local_boundary_crossings(
+ layer_to_vertices, edges, layer, max_layer
+ )
+ vertices[index], vertices[index + 1] = right, left
+ after = _weighted_local_boundary_crossings(
+ layer_to_vertices, edges, layer, max_layer
+ )
+ if after < before:
+ improved = True
+ else:
+ vertices[index], vertices[index + 1] = left, right
+ index += 1
+ if not improved:
+ break
+
+
+def _vertices_can_swap(
+ left: GroupVertex,
+ right: GroupVertex,
+ movable_group_ids: set[int],
+) -> bool:
+ for vertex in (left, right):
+ if isinstance(vertex, int) and vertex not in movable_group_ids:
+ return False
+ return True
+
+
+def _weighted_local_boundary_crossings(
+ layer_to_vertices: dict[int, list[GroupVertex]],
+ edges: list[WeightedGroupEdge],
+ layer: int,
+ max_layer: int,
+) -> int:
+ crossings = 0
+ if layer > 0:
+ crossings += _weighted_boundary_crossings(
+ layer_to_vertices, edges, layer - 1, layer
+ )
+ if layer < max_layer:
+ crossings += _weighted_boundary_crossings(
+ layer_to_vertices, edges, layer, layer + 1
+ )
+ return crossings
+
+
+def _weighted_boundary_crossings(
+ layer_to_vertices: dict[int, list[GroupVertex]],
+ edges: list[WeightedGroupEdge],
+ left_layer: int,
+ right_layer: int,
+) -> int:
+ """Count wire-multiplicity-weighted crossings at one layer boundary."""
+ left = layer_to_vertices.get(left_layer, [])
+ right = layer_to_vertices.get(right_layer, [])
+ if not left or not right:
+ return 0
+
+ left_pos = {vertex: index for index, vertex in enumerate(left)}
+ right_pos = {vertex: index for index, vertex in enumerate(right)}
+ boundary_edges = [
+ (source, target, weight)
+ for source, target, weight in edges
+ if source in left_pos and target in right_pos
+ ]
+
+ crossings = 0
+ for index, first in enumerate(boundary_edges):
+ for second in boundary_edges[index + 1 :]:
+ if first[0] == second[0] or first[1] == second[1]:
+ continue
+ if (left_pos[first[0]] - left_pos[second[0]]) * (
+ right_pos[first[1]] - right_pos[second[1]]
+ ) < 0:
+ crossings += first[2] * second[2]
+ return crossings
+
+
+def _apply_group_layer_order(
+ workflow: Workflow,
+ layers: dict[int, int],
+ layer_order: dict[int, list[GroupVertex]],
+ movable_group_ids: set[int],
+ settings: LayoutSettings,
+) -> bool:
+ """Commit vertical group order while preserving each group's x coordinate."""
+ group_by_id = {group.id: group for group in workflow.groups}
+ changed = False
+
+ for layer in sorted(layer_order):
+ physical_groups = [
+ group
+ for group in workflow.groups
+ if group.id in layers and layers[group.id] == layer and group.nodes
+ ]
+ if len(physical_groups) <= 1:
+ continue
+ if any(group.id not in movable_group_ids for group in physical_groups):
+ continue
+
+ current_groups = sorted(
+ physical_groups,
+ key=lambda group: (group.bounding[1], group.bounding[0], group.id),
+ )
+ current_ids = [group.id for group in current_groups]
+ desired_ids = [
+ vertex
+ for vertex in layer_order[layer]
+ if isinstance(vertex, int) and vertex in current_ids
+ ]
+ if len(desired_ids) != len(current_ids) or desired_ids == current_ids:
+ continue
+
+ gap = _infer_group_vertical_gap(current_groups, settings.group_v_gap)
+ current_y = min(group.bounding[1] for group in current_groups)
+ for group_id in desired_ids:
+ group = group_by_id[group_id]
+ delta_y = current_y - group.bounding[1]
+ if abs(delta_y) > 1e-9:
+ for node in group.nodes:
+ node.y += delta_y
+ group.bounding[1] = current_y
+ current_y += group.bounding[3] + gap
+ changed = True
+
+ return changed
+
+
+def _infer_group_vertical_gap(groups: list[Group], fallback: float) -> float:
+ if len(groups) <= 1:
+ return fallback
+ gaps = [
+ next_group.bounding[1] - (group.bounding[1] + group.bounding[3])
+ for group, next_group in zip(groups, groups[1:])
+ ]
+ non_negative = sorted(gap for gap in gaps if gap >= 0)
+ if not non_negative:
+ return fallback
+ return non_negative[len(non_negative) // 2]
+
+
+def _group_center_y(group: Group) -> float:
+ if len(group.bounding) >= 4 and group.bounding[3] > 0:
+ return group.bounding[1] + group.bounding[3] / 2.0
+ if not group.nodes:
+ return 0.0
+ top = min(node.y for node in group.nodes)
+ bottom = max(node.y + max(1.0, node.size[1] if len(node.size) > 1 else 1.0) for node in group.nodes)
+ return top + (bottom - top) / 2.0
+
+
+def _vertex_key(vertex: GroupVertex) -> str:
+ return str(vertex)
From 37d60017bd1f0b13c6a6830d4e1733492de33d00 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:19:20 +0200
Subject: [PATCH 015/105] Test weighted group-level crossing refinement
---
tests/test_layout_engine_v2_phase2.py | 125 ++++++++++++++++++++++++++
1 file changed, 125 insertions(+)
create mode 100644 tests/test_layout_engine_v2_phase2.py
diff --git a/tests/test_layout_engine_v2_phase2.py b/tests/test_layout_engine_v2_phase2.py
new file mode 100644
index 0000000..192678c
--- /dev/null
+++ b/tests/test_layout_engine_v2_phase2.py
@@ -0,0 +1,125 @@
+"""Regression tests for v2 group-level crossing refinement."""
+
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import EngineV2Score
+from flowforge.layout_engine_v2_phase2 import (
+ _apply_group_layer_order,
+ _group_edge_weights,
+ _quality_key,
+ _weighted_boundary_crossings,
+ _weighted_group_order,
+ _weighted_median,
+)
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def _single_node_group(group_id: int, node_id: int, y: float, *, pinned: bool = False) -> Group:
+ node = Node(id=node_id, type=f"Node{node_id}", x=0, y=y, size=[200, 80])
+ return Group(
+ id=group_id,
+ name=f"Group{group_id}",
+ nodes=[node],
+ bounding=[0, y, 300, 180],
+ pinned=pinned,
+ )
+
+
+def test_group_edge_weights_preserve_direct_wire_multiplicity():
+ workflow = Workflow()
+ source_group = _single_node_group(1, 1, 0)
+ target_group = _single_node_group(2, 2, 0)
+ workflow.groups = [source_group, target_group]
+ workflow.nodes = {1: source_group.nodes[0], 2: target_group.nodes[0]}
+
+ for link_id in range(1, 4):
+ _attach_link(
+ workflow,
+ Link(
+ id=link_id,
+ source=1,
+ source_port=link_id - 1,
+ target=2,
+ target_port=link_id - 1,
+ type="DATA",
+ ),
+ )
+
+ weights = _group_edge_weights(workflow, {1, 2}, {1: [2], 2: []})
+
+ assert weights[(1, 2)] == 3
+
+
+def test_weighted_median_prefers_high_multiplicity_neighbour():
+ assert _weighted_median([(0, 1), (1, 5)]) == 1.0
+
+
+def test_weighted_group_order_removes_simple_inter_group_crossing():
+ workflow = Workflow()
+ groups = [
+ _single_node_group(1, 1, 0),
+ _single_node_group(2, 2, 300),
+ _single_node_group(3, 3, 0),
+ _single_node_group(4, 4, 300),
+ ]
+ workflow.groups = groups
+ workflow.nodes = {group.nodes[0].id: group.nodes[0] for group in groups}
+ layers = {1: 0, 2: 0, 3: 1, 4: 1}
+ weights = {(1, 4): 3, (2, 3): 1}
+
+ ordered = _weighted_group_order(workflow, layers, weights, {1, 2, 3, 4})
+ edges = [(1, 4, 3), (2, 3, 1)]
+
+ assert _weighted_boundary_crossings(ordered, edges, 0, 1) == 0
+
+
+def test_fixed_group_prevents_physical_reorder_of_its_layer():
+ workflow = Workflow()
+ fixed = _single_node_group(1, 1, 0, pinned=True)
+ movable = _single_node_group(2, 2, 300)
+ workflow.groups = [fixed, movable]
+ workflow.nodes = {1: fixed.nodes[0], 2: movable.nodes[0]}
+ original_positions = {
+ 1: (fixed.bounding[1], fixed.nodes[0].y),
+ 2: (movable.bounding[1], movable.nodes[0].y),
+ }
+
+ changed = _apply_group_layer_order(
+ workflow,
+ {1: 0, 2: 0},
+ {0: [2, 1]},
+ {2},
+ LayoutSettings(),
+ )
+
+ assert not changed
+ assert (fixed.bounding[1], fixed.nodes[0].y) == original_positions[1]
+ assert (movable.bounding[1], movable.nodes[0].y) == original_positions[2]
+
+
+def test_group_phase_quality_key_is_crossing_first_after_overlap_safety():
+ fewer_crossings = EngineV2Score(
+ total=999999.0,
+ crossings=4,
+ right_to_left_links=20,
+ movable_overlaps=0,
+ link_length=100000.0,
+ width=10000.0,
+ height=10000.0,
+ )
+ shorter_but_crossed = EngineV2Score(
+ total=1.0,
+ crossings=5,
+ right_to_left_links=0,
+ movable_overlaps=0,
+ link_length=1.0,
+ width=100.0,
+ height=100.0,
+ )
+
+ assert _quality_key(fewer_crossings) < _quality_key(shorter_but_crossed)
From 804e336f295a1c073560b0fbb87488ddb5991b39 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:19:31 +0200
Subject: [PATCH 016/105] Use group-level v2 refinement runtime
---
flowforge/layout_engine_v2_runtime.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
index cbe426f..2ed2b90 100644
--- a/flowforge/layout_engine_v2_runtime.py
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -10,7 +10,7 @@
from __future__ import annotations
from .layout import LayoutSettings
-from .layout_engine_v2_phase15 import apply_best_layout
+from .layout_engine_v2_phase2 import apply_best_layout
from .model import Workflow
From 1d7107371ab624b6589ea374933fd204e3e7ea6e Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:20:10 +0200
Subject: [PATCH 017/105] Remove rejected boundary-aware refinement experiment
---
flowforge/layout_engine_v2_phase15.py | 521 --------------------------
1 file changed, 521 deletions(-)
delete mode 100644 flowforge/layout_engine_v2_phase15.py
diff --git a/flowforge/layout_engine_v2_phase15.py b/flowforge/layout_engine_v2_phase15.py
deleted file mode 100644
index 2f398a9..0000000
--- a/flowforge/layout_engine_v2_phase15.py
+++ /dev/null
@@ -1,521 +0,0 @@
-"""Boundary-aware refinement and crossing-first selection for layout engine v2.
-
-This phase builds on :mod:`flowforge.layout_engine_v2` without changing its
-SCC/dummy-vertex core. It adds fixed boundary anchors for links that enter or
-leave a movable group and uses a crossing-first comparison policy when choosing
-between layout candidates.
-"""
-
-from __future__ import annotations
-
-from copy import deepcopy
-from typing import Hashable
-
-from .layout import (
- LayoutReport,
- LayoutScore,
- LayoutSettings,
- _apply_layout_pass,
- _build_layout_candidates,
- _compress_debug_sidecar_layers,
- _group_bottom_padding,
- _group_top_padding,
- _node_input_port_y,
- _node_output_port_y,
- _node_visual_height,
- _node_visual_width,
- _resolve_layout_candidate_count,
- _score_layout_candidate,
-)
-from .layout_engine_v2 import (
- EngineV2Score,
- V2_SWEEP_ROUNDS,
- V2_TRANSPOSE_PASSES,
- _assign_scc_longest_path_layers,
- _boundary_crossings,
- _finalize_refinement,
- _group_can_be_refined,
- _median_reorder_layer,
- _port_segments,
- _score_engine_v2,
- _segments_intersect,
- _vertex_bypassed,
- _vertex_key,
-)
-from .logger import setup_logger
-from .model import Group, Link, Workflow
-
-logger = setup_logger(__name__)
-
-Vertex = Hashable
-
-
-def apply_best_layout(
- workflow: Workflow,
- settings: LayoutSettings | None = None,
- candidate_count: int | None = None,
-) -> Workflow:
- """Select a layout with boundary-aware refinement and crossing-first gates."""
- settings = settings or LayoutSettings()
- resolved_candidate_count = (
- _resolve_layout_candidate_count(workflow)
- if candidate_count is None
- else max(1, int(candidate_count))
- )
- variants = _build_layout_candidates(workflow, settings, resolved_candidate_count)
-
- best_workflow: Workflow | None = None
- best_score: EngineV2Score | None = None
- best_index = 0
-
- for index, variant in enumerate(variants, start=1):
- baseline = deepcopy(workflow)
- _apply_layout_pass(baseline, variant, log=False)
- baseline_score = _score_engine_v2(baseline)
-
- refined = deepcopy(baseline)
- changed = _refine_movable_group_internals_boundary_aware(refined, variant)
- if changed:
- _finalize_refinement(refined, variant)
- refined_score = _score_engine_v2(refined)
- if _score_order_key(refined_score) < _score_order_key(baseline_score):
- candidate = refined
- candidate_score = refined_score
- else:
- candidate = baseline
- candidate_score = baseline_score
- else:
- candidate = baseline
- candidate_score = baseline_score
-
- logger.debug(
- "v2.1 candidate %s/%s crossings=%s rtl=%s overlaps=%s link=%.2f size=%.0fx%.0f",
- index,
- len(variants),
- candidate_score.crossings,
- candidate_score.right_to_left_links,
- candidate_score.movable_overlaps,
- candidate_score.link_length,
- candidate_score.width,
- candidate_score.height,
- )
-
- if best_score is None or _score_order_key(candidate_score) < _score_order_key(best_score):
- best_workflow = candidate
- best_score = candidate_score
- best_index = index
-
- if best_workflow is None or best_score is None:
- raise RuntimeError("Layout engine v2.1 did not produce a candidate")
-
- legacy_score: LayoutScore = _score_layout_candidate(best_workflow)
- best_workflow.layout_report = LayoutReport(
- candidate_count=len(variants),
- selected_candidate=best_index,
- score=legacy_score,
- )
- logger.info(
- "Layout engine v2.1 selected candidate %s/%s: crossings=%s rtl=%s overlaps=%s",
- best_index,
- len(variants),
- best_score.crossings,
- best_score.right_to_left_links,
- best_score.movable_overlaps,
- )
- return best_workflow
-
-
-def _score_order_key(score: EngineV2Score) -> tuple[int, int, int, float, float, float, float]:
- """Return the lexicographic candidate order used by the v2.1 safety gate.
-
- Geometry safety is evaluated first, then physical crossings, then
- right-to-left links. Cable length and compactness only break ties after the
- structural metrics are equal.
- """
- area = score.width * score.height
- return (
- score.movable_overlaps,
- score.crossings,
- score.right_to_left_links,
- score.link_length,
- area,
- score.width + score.height,
- score.total,
- )
-
-
-def _refine_movable_group_internals_boundary_aware(
- workflow: Workflow,
- settings: LayoutSettings,
-) -> bool:
- """Refine movable groups while treating external endpoints as fixed anchors."""
- changed = False
- for group in workflow.groups:
- if not _group_can_be_refined(workflow, group):
- continue
- if _refine_group_boundary_aware(workflow, group, settings):
- changed = True
- return changed
-
-
-def _refine_group_boundary_aware(
- workflow: Workflow,
- group: Group,
- settings: LayoutSettings,
-) -> bool:
- """Refine one group without allowing its incident crossing count to regress."""
- node_ids = {node.id for node in group.nodes}
- adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
- rev_adj: dict[int, list[int]] = {node_id: [] for node_id in node_ids}
- internal_links: list[Link] = []
- boundary_links: list[Link] = []
-
- for link in workflow.links.values():
- source_inside = link.source in node_ids
- target_inside = link.target in node_ids
- if source_inside and target_inside:
- adj[link.source].append(link.target)
- rev_adj[link.target].append(link.source)
- internal_links.append(link)
- elif source_inside != target_inside:
- boundary_links.append(link)
-
- if not internal_links:
- return False
-
- layers = _assign_scc_longest_path_layers(node_ids, adj)
- _compress_debug_sidecar_layers(layers, adj, rev_adj, workflow)
- if len(set(layers.values())) <= 1:
- return False
-
- layer_order = _minimize_crossings_with_boundaries(
- workflow,
- layers,
- internal_links,
- boundary_links,
- )
- if not layer_order:
- return False
-
- before_crossings = _group_incident_crossing_count(workflow, node_ids)
- original_positions = {
- node_id: (workflow.nodes[node_id].x, workflow.nodes[node_id].y)
- for node_id in node_ids
- }
- original_bounding = list(group.bounding)
-
- _place_group_order(workflow, group, settings, layer_order)
- after_crossings = _group_incident_crossing_count(workflow, node_ids)
- if after_crossings > before_crossings:
- for node_id, (x, y) in original_positions.items():
- workflow.nodes[node_id].x = x
- workflow.nodes[node_id].y = y
- group.bounding = original_bounding
- logger.debug(
- "Rejected boundary-aware refinement for group %s: incident crossings %s -> %s",
- group.id,
- before_crossings,
- after_crossings,
- )
- return False
-
- return any(
- (workflow.nodes[node_id].x, workflow.nodes[node_id].y) != original_positions[node_id]
- for node_id in node_ids
- )
-
-
-def _minimize_crossings_with_boundaries(
- workflow: Workflow,
- layers: dict[int, int],
- internal_links: list[Link],
- boundary_links: list[Link],
-) -> dict[int, list[Vertex]]:
- """Order group nodes with fixed external endpoint anchors on both boundaries."""
- max_layer = max(layers.values(), default=0)
- left_boundary_layer = -1
- right_boundary_layer = max_layer + 1
- layer_to_vertices: dict[int, list[Vertex]] = {
- layer: [] for layer in range(left_boundary_layer, right_boundary_layer + 1)
- }
- stable_y: dict[Vertex, float] = {}
-
- for node_id, layer in layers.items():
- layer_to_vertices[layer].append(node_id)
- stable_y[node_id] = workflow.nodes[node_id].y
-
- expanded_edges: list[tuple[Vertex, Vertex]] = []
- for link in sorted(internal_links, key=lambda item: item.id):
- source_layer = layers[link.source]
- target_layer = layers[link.target]
- if target_layer <= source_layer:
- continue
- _append_internal_edge_chain(
- workflow,
- layer_to_vertices,
- stable_y,
- expanded_edges,
- link,
- source_layer,
- target_layer,
- )
-
- for link in sorted(boundary_links, key=lambda item: item.id):
- source_inside = link.source in layers
- target_inside = link.target in layers
- if not source_inside and target_inside:
- _append_incoming_boundary_chain(
- workflow,
- layer_to_vertices,
- stable_y,
- expanded_edges,
- link,
- layers[link.target],
- )
- elif source_inside and not target_inside:
- _append_outgoing_boundary_chain(
- workflow,
- layer_to_vertices,
- stable_y,
- expanded_edges,
- link,
- layers[link.source],
- max_layer,
- )
-
- for layer, vertices in layer_to_vertices.items():
- if layer in {left_boundary_layer, right_boundary_layer}:
- vertices.sort(key=lambda vertex: (stable_y.get(vertex, 0.0), _vertex_key(vertex)))
- else:
- vertices.sort(
- key=lambda vertex: (
- _vertex_bypassed(workflow, vertex),
- stable_y.get(vertex, 0.0),
- _vertex_key(vertex),
- )
- )
-
- predecessors: dict[Vertex, list[Vertex]] = {}
- successors: dict[Vertex, list[Vertex]] = {}
- for source, target in expanded_edges:
- successors.setdefault(source, []).append(target)
- predecessors.setdefault(target, []).append(source)
-
- for _ in range(V2_SWEEP_ROUNDS):
- for layer in range(0, max_layer + 1):
- _median_reorder_layer(
- workflow,
- layer_to_vertices,
- layer,
- predecessors,
- reference_layer=layer - 1,
- stable_y=stable_y,
- )
- for layer in range(max_layer, -1, -1):
- _median_reorder_layer(
- workflow,
- layer_to_vertices,
- layer,
- successors,
- reference_layer=layer + 1,
- stable_y=stable_y,
- )
- _transpose_layers_with_boundaries(
- layer_to_vertices,
- expanded_edges,
- max_layer,
- )
-
- return {layer: layer_to_vertices[layer] for layer in range(0, max_layer + 1)}
-
-
-def _append_internal_edge_chain(
- workflow: Workflow,
- layer_to_vertices: dict[int, list[Vertex]],
- stable_y: dict[Vertex, float],
- expanded_edges: list[tuple[Vertex, Vertex]],
- link: Link,
- source_layer: int,
- target_layer: int,
-) -> None:
- previous: Vertex = link.source
- source_y = _node_output_port_y(workflow.nodes[link.source], link.source_port)
- target_y = _node_input_port_y(workflow.nodes[link.target], link.target_port)
- span = target_layer - source_layer
- for layer in range(source_layer + 1, target_layer):
- dummy: Vertex = ("dummy", link.id, layer)
- layer_to_vertices[layer].append(dummy)
- fraction = (layer - source_layer) / span
- stable_y[dummy] = source_y + (target_y - source_y) * fraction
- expanded_edges.append((previous, dummy))
- previous = dummy
- expanded_edges.append((previous, link.target))
-
-
-def _append_incoming_boundary_chain(
- workflow: Workflow,
- layer_to_vertices: dict[int, list[Vertex]],
- stable_y: dict[Vertex, float],
- expanded_edges: list[tuple[Vertex, Vertex]],
- link: Link,
- target_layer: int,
-) -> None:
- source = workflow.nodes.get(link.source)
- target = workflow.nodes.get(link.target)
- if source is None or target is None:
- return
-
- anchor: Vertex = ("boundary-in", link.id)
- layer_to_vertices[-1].append(anchor)
- source_y = _node_output_port_y(source, link.source_port)
- target_y = _node_input_port_y(target, link.target_port)
- stable_y[anchor] = source_y
- previous: Vertex = anchor
- span = target_layer + 1
-
- for layer in range(0, target_layer):
- dummy: Vertex = ("boundary-in-dummy", link.id, layer)
- layer_to_vertices[layer].append(dummy)
- fraction = (layer + 1) / span
- stable_y[dummy] = source_y + (target_y - source_y) * fraction
- expanded_edges.append((previous, dummy))
- previous = dummy
- expanded_edges.append((previous, link.target))
-
-
-def _append_outgoing_boundary_chain(
- workflow: Workflow,
- layer_to_vertices: dict[int, list[Vertex]],
- stable_y: dict[Vertex, float],
- expanded_edges: list[tuple[Vertex, Vertex]],
- link: Link,
- source_layer: int,
- max_layer: int,
-) -> None:
- source = workflow.nodes.get(link.source)
- target = workflow.nodes.get(link.target)
- if source is None or target is None:
- return
-
- anchor: Vertex = ("boundary-out", link.id)
- right_boundary_layer = max_layer + 1
- layer_to_vertices[right_boundary_layer].append(anchor)
- source_y = _node_output_port_y(source, link.source_port)
- target_y = _node_input_port_y(target, link.target_port)
- stable_y[anchor] = target_y
- previous: Vertex = link.source
- span = right_boundary_layer - source_layer
-
- for layer in range(source_layer + 1, right_boundary_layer):
- dummy: Vertex = ("boundary-out-dummy", link.id, layer)
- layer_to_vertices[layer].append(dummy)
- fraction = (layer - source_layer) / span
- stable_y[dummy] = source_y + (target_y - source_y) * fraction
- expanded_edges.append((previous, dummy))
- previous = dummy
- expanded_edges.append((previous, anchor))
-
-
-def _transpose_layers_with_boundaries(
- layer_to_vertices: dict[int, list[Vertex]],
- expanded_edges: list[tuple[Vertex, Vertex]],
- max_layer: int,
-) -> None:
- """Transpose interior vertices while counting crossings at fixed boundaries."""
- for _ in range(V2_TRANSPOSE_PASSES):
- improved = False
- for layer in range(0, max_layer + 1):
- vertices = layer_to_vertices[layer]
- if len(vertices) <= 1:
- continue
- index = 0
- while index < len(vertices) - 1:
- before = _local_crossings_with_boundaries(
- layer_to_vertices,
- expanded_edges,
- layer,
- max_layer,
- )
- vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
- after = _local_crossings_with_boundaries(
- layer_to_vertices,
- expanded_edges,
- layer,
- max_layer,
- )
- if after < before:
- improved = True
- else:
- vertices[index], vertices[index + 1] = vertices[index + 1], vertices[index]
- index += 1
- if not improved:
- break
-
-
-def _local_crossings_with_boundaries(
- layer_to_vertices: dict[int, list[Vertex]],
- edges: list[tuple[Vertex, Vertex]],
- layer: int,
- max_layer: int,
-) -> int:
- crossings = _boundary_crossings(layer_to_vertices, edges, layer - 1, layer)
- crossings += _boundary_crossings(layer_to_vertices, edges, layer, layer + 1)
- return crossings
-
-
-def _place_group_order(
- workflow: Workflow,
- group: Group,
- settings: LayoutSettings,
- layer_order: dict[int, list[Vertex]],
-) -> None:
- """Apply an ordered group-internal layering to physical node coordinates."""
- base_x = group.bounding[0] + settings.group_padding
- base_y = group.bounding[1] + _group_top_padding(settings)
- current_x = base_x
- max_bottom = base_y
-
- for layer in sorted(layer_order):
- node_order = [node_id for node_id in layer_order[layer] if isinstance(node_id, int)]
- if not node_order:
- continue
-
- layer_width = max(_node_visual_width(workflow.nodes[node_id]) for node_id in node_order)
- current_y = base_y
- for node_id in node_order:
- node = workflow.nodes[node_id]
- node.x = current_x
- node.y = current_y
- current_y += _node_visual_height(node) + settings.node_v_gap
- max_bottom = max(max_bottom, current_y - settings.node_v_gap)
- current_x += layer_width + settings.node_h_gap
-
- content_right = current_x - settings.node_h_gap
- group.bounding = [
- group.bounding[0],
- group.bounding[1],
- max(0.0, content_right - base_x) + 2.0 * settings.group_padding,
- max(0.0, max_bottom - base_y)
- + _group_top_padding(settings)
- + _group_bottom_padding(settings),
- ]
-
-
-def _group_incident_crossing_count(workflow: Workflow, node_ids: set[int]) -> int:
- """Count physical crossings where at least one segment touches the group."""
- segments = _port_segments(workflow)
- crossings = 0
- for index, first in enumerate(segments):
- for second in segments[index + 1 :]:
- if not (
- first[0] in node_ids
- or first[1] in node_ids
- or second[0] in node_ids
- or second[1] in node_ids
- ):
- continue
- if first[0] in {second[0], second[1]} or first[1] in {second[0], second[1]}:
- continue
- if _segments_intersect(first[2], first[3], second[2], second[3]):
- crossings += 1
- return crossings
From 0964903f08dbc1e0c5976ce8d0d3542fef92c369 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:20:13 +0200
Subject: [PATCH 018/105] Remove rejected boundary-aware refinement tests
---
tests/test_layout_engine_v2_phase15.py | 123 -------------------------
1 file changed, 123 deletions(-)
delete mode 100644 tests/test_layout_engine_v2_phase15.py
diff --git a/tests/test_layout_engine_v2_phase15.py b/tests/test_layout_engine_v2_phase15.py
deleted file mode 100644
index 56cae10..0000000
--- a/tests/test_layout_engine_v2_phase15.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Regression tests for boundary-aware layout engine v2.1 behavior."""
-
-import flowforge.layout_engine_v2_phase15 as phase15
-from flowforge.layout import LayoutSettings
-from flowforge.layout_engine_v2 import EngineV2Score
-from flowforge.model import Group, Link, Node, Workflow
-
-
-def _attach_link(workflow: Workflow, link: Link) -> None:
- workflow.links[link.id] = link
- workflow.nodes[link.source].output_links.append(link.id)
- workflow.nodes[link.target].input_links.append(link.id)
-
-
-def test_score_order_is_crossing_first_after_overlap_safety():
- fewer_crossings = EngineV2Score(
- total=999_999.0,
- crossings=4,
- right_to_left_links=20,
- movable_overlaps=0,
- link_length=99_999.0,
- width=9_000.0,
- height=9_000.0,
- )
- fewer_rtl_but_more_crossings = EngineV2Score(
- total=1.0,
- crossings=5,
- right_to_left_links=0,
- movable_overlaps=0,
- link_length=1.0,
- width=1.0,
- height=1.0,
- )
-
- assert phase15._score_order_key(fewer_crossings) < phase15._score_order_key(
- fewer_rtl_but_more_crossings
- )
-
-
-def test_boundary_anchors_drive_target_order_from_external_geometry():
- workflow = Workflow()
- workflow.nodes = {
- 1: Node(id=1, type="InternalSource", x=100, y=200, size=[200, 80], output_count=2),
- 2: Node(id=2, type="TargetA", x=500, y=0, size=[200, 80], input_count=2),
- 3: Node(id=3, type="TargetB", x=500, y=400, size=[200, 80], input_count=2),
- 4: Node(id=4, type="ExternalTop", x=-500, y=0, size=[200, 80], output_count=1),
- 5: Node(id=5, type="ExternalBottom", x=-500, y=400, size=[200, 80], output_count=1),
- }
- internal_links = [
- Link(id=10, source=1, source_port=0, target=2, target_port=0, type="DATA"),
- Link(id=11, source=1, source_port=1, target=3, target_port=0, type="DATA"),
- ]
- boundary_links = [
- Link(id=12, source=4, source_port=0, target=3, target_port=1, type="DATA"),
- Link(id=13, source=5, source_port=0, target=2, target_port=1, type="DATA"),
- ]
- layers = {1: 0, 2: 1, 3: 1}
-
- ordered = phase15._minimize_crossings_with_boundaries(
- workflow,
- layers,
- internal_links,
- boundary_links,
- )
-
- real_target_order = [vertex for vertex in ordered[1] if isinstance(vertex, int)]
- assert real_target_order.index(3) < real_target_order.index(2)
-
-
-def test_group_refinement_reverts_when_incident_crossings_increase(monkeypatch):
- workflow = Workflow()
- source = Node(id=1, type="InternalSource", x=100, y=250, size=[200, 80], output_count=2)
- top = Node(id=2, type="Top", x=500, y=100, size=[200, 80], input_count=2)
- bottom = Node(id=3, type="Bottom", x=500, y=400, size=[200, 80], input_count=2)
- external_top = Node(id=4, type="ExternalTop", x=-500, y=100, size=[200, 80], output_count=1)
- external_bottom = Node(
- id=5,
- type="ExternalBottom",
- x=-500,
- y=400,
- size=[200, 80],
- output_count=1,
- )
- workflow.nodes = {
- 1: source,
- 2: top,
- 3: bottom,
- 4: external_top,
- 5: external_bottom,
- }
- for link in [
- Link(id=10, source=1, source_port=0, target=2, target_port=0, type="DATA"),
- Link(id=11, source=1, source_port=1, target=3, target_port=0, type="DATA"),
- Link(id=12, source=4, source_port=0, target=2, target_port=1, type="DATA"),
- Link(id=13, source=5, source_port=0, target=3, target_port=1, type="DATA"),
- ]:
- _attach_link(workflow, link)
-
- group = Group(
- id=1,
- name="Pipeline",
- nodes=[source, top, bottom],
- bounding=[0, 0, 900, 600],
- )
- workflow.groups = [group]
- original_positions = {node.id: (node.x, node.y) for node in group.nodes}
- original_bounding = list(group.bounding)
-
- monkeypatch.setattr(
- phase15,
- "_minimize_crossings_with_boundaries",
- lambda *_args, **_kwargs: {0: [1], 1: [3, 2]},
- )
-
- changed = phase15._refine_group_boundary_aware(
- workflow,
- group,
- LayoutSettings(node_x_distance=60, node_y_distance=60),
- )
-
- assert not changed
- assert {node.id: (node.x, node.y) for node in group.nodes} == original_positions
- assert group.bounding == original_bounding
From 3827baa51ce8f89b0dc0ac99bc850792c31d1473 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:20:41 +0200
Subject: [PATCH 019/105] Document weighted group-level layout phase
---
docs/LAYOUT_ENGINE_V2.md | 286 +++++++++++++++++++++------------------
1 file changed, 154 insertions(+), 132 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index d215134..086a1d5 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -1,128 +1,115 @@
# Layout Engine v2
This document describes the experimental second-generation layout engine on the
-`layout-engine-v2` branch. It is intentionally separate from the release-facing
-README until corpus validation is complete.
+`layout-engine-v2` branch. It remains separate from release-facing documentation
+until corpus validation is complete.
## Goal
-The legacy layout pipeline has mature handling for ComfyUI-specific geometry:
+The legacy layout pipeline already contains mature ComfyUI-specific handling for
groups, pinned surfaces, ungrouped bridge nodes, controls, text previews,
-virtual Set/Get hubs, node compaction, and collision clearance. The v2 work does
-not replace those contracts. It replaces the weakest part of the layout search:
-structural crossing minimization and candidate scoring.
-
-The design goal is that a v2 structural refinement is never mandatory. For each
-spacing/wrapping candidate, FlowForge first produces the normal legacy layout,
-then tries the v2 refinement on a copy. The refined copy is kept only when the
-v2 selection policy improves. This keeps the legacy candidate as a local safety
-fallback.
-
-## Current v2 Pipeline
-
-For every legacy layout candidate:
-
-1. Run the existing six-phase layout pass.
-2. Find fully movable groups whose internal graph contains links.
-3. Condense strongly connected components (SCCs) with Tarjan's algorithm.
-4. Assign longest-path layers on the condensed DAG.
-5. Preserve the existing debug/control sidecar layer compression rules.
-6. Expand links that skip layers with in-memory dummy vertices.
-7. Add fixed boundary anchors for physical links entering or leaving a group.
-8. Expand boundary links across intermediate layers with in-memory dummy chains.
-9. Run repeated directional median sweeps over adjacent layers.
-10. Run local adjacent-node transposition passes while crossings improve,
- including the fixed boundary layers in the local crossing count.
-11. Reject a group refinement if its physical incident crossing count increases.
-12. Remove dummy and boundary vertices from the physical layout and assign node
- coordinates.
-13. Re-run the existing bounding-box, group-overlap, pinned-clearance,
- control, text-preview, and virtual-hub geometry contracts.
-14. Compare the legacy and refined versions with crossing-first candidate gates.
-15. Select the best candidate with the same crossing-first policy.
-
-Dummy and boundary vertices are layout-only objects. They never become ComfyUI
-nodes and are never serialized.
-
-## Boundary-Aware Group Refinement
-
-The first v2 implementation optimized only links whose source and target were
-inside the same group. Corpus comparison showed that this reduced pure
-within-group crossings but could move an otherwise well-placed boundary node
-away from its external neighbours.
-
-Phase 1.5 models incoming and outgoing physical links as fixed anchors outside
-the left and right group boundaries. Their vertical order comes from the actual
-external socket geometry. Dummy chains carry that order through every internal
-layer to the connected node.
-
-After the proposed node order is placed, FlowForge counts all physical
-crossings where at least one segment touches the group. If the count increased,
-the group geometry is restored exactly to its pre-refinement state.
-
-This local guard is intentionally stricter than a weighted score: a shorter or
-more compact group cannot buy an increase in incident crossings.
-
-## Crossing-First Candidate Selection
-
-The port-aware v2 metrics are still collected for diagnostics, but candidate
-selection is now lexicographic instead of relying only on a weighted sum:
+virtual Set/Get hubs, node compaction, collision clearance, and serialization.
+Layout engine v2 keeps those contracts and replaces the weakest part of the
+pipeline: structural crossing minimization and candidate selection.
+
+The safety rule is simple: every new structural pass works on a copy and may be
+rejected in favour of the already valid baseline layout.
+
+## Active Pipeline
+
+The active runtime now has two refinement levels.
+
+### 1. Node-level v2 refinement
+
+For every normal layout candidate FlowForge:
+
+1. runs the established six-phase layout pass;
+2. finds fully movable groups with internal links;
+3. condenses strongly connected components with Tarjan's algorithm;
+4. assigns longest-path layers on the SCC DAG;
+5. preserves debug/control sidecar compression rules;
+6. expands long internal edges with virtual dummy vertices;
+7. runs repeated directional median sweeps;
+8. runs local adjacent transposition while crossings improve;
+9. assigns physical node coordinates;
+10. re-runs the established group, pin, control, preview, and virtual-hub
+ geometry contracts;
+11. compares refined and baseline candidates with port-aware metrics.
+
+Dummy vertices exist only in the ordering graph and are never serialized.
+
+### 2. Weighted group-level refinement
+
+After the best node-level v2 result is selected, FlowForge treats whole movable
+groups as a second graph:
+
+1. build the existing inter-group dependency graph, including dependencies that
+ pass through ungrouped bridge nodes;
+2. condense SCCs and assign group flow layers;
+3. weight direct group dependencies by physical wire multiplicity;
+4. retain bridge-only group dependencies with unit weight;
+5. expand long group dependencies through intermediate layers with virtual
+ group dummy vertices;
+6. run repeated weighted-median sweeps;
+7. run weighted local transposition, where a crossing between edges of weights
+ `a` and `b` contributes `a * b`;
+8. change only the vertical order of movable groups inside an existing flow
+ layer, preserving each group's x coordinate and internal geometry;
+9. skip a physical layer when it contains a pinned/fixed group;
+10. re-run the normal geometry finalizers;
+11. accept the group-level result only when the global port-aware quality key
+ improves.
+
+The active Phase 2 quality comparison is lexicographic:
1. movable overlaps;
2. straight-line port-to-port crossings;
-3. right-to-left physical links;
+3. right-to-left links;
4. total Manhattan cable length;
-5. workflow area;
-6. width plus height;
-7. the original weighted v2 score as the final tie-breaker.
+5. width plus height;
+6. width;
+7. height.
-This means a candidate with fewer right-to-left links, shorter cables, or a
-smaller canvas cannot replace another candidate if it introduces an additional
+This prevents a compact layout or shorter cables from buying an additional
physical crossing at the same overlap count.
-## Port-Aware Geometry
+## Port-Aware Metrics
-The legacy candidate scorer measures physical links between node centres. The
-v2 metrics measure each physical link from the source output socket to the
-target input socket using the same port geometry helpers used by FlowForge's
-placement code.
-
-The metrics include:
+The v2 scorer measures physical links from the actual source output socket to
+the actual target input socket rather than from node centre to node centre.
+Metrics include:
- straight-line port-to-port crossings;
- right-to-left physical links;
- overlaps between movable, non-decorative, non-virtual nodes;
- total Manhattan cable length;
- workflow width and height;
-- a wide-aspect penalty used by the diagnostic weighted score.
+- a wide-aspect diagnostic penalty.
## SCC Handling
The legacy longest-path layerer falls cyclic leftovers back to layer zero. v2
-instead computes strongly connected components first. Every SCC is treated as a
-single vertex while assigning global layers. Nodes inside an SCC share the same
-layer because a cycle cannot be represented as strictly left-to-right without
-breaking at least one edge.
+first computes strongly connected components. Every SCC shares one layer while
+the condensed component graph receives normal longest-path layers.
-This prevents an unrelated cycle from collapsing the rest of a connected
-subgraph back into the source layer.
+The same SCC-aware layer assignment is reused at group level so a group cycle
+does not collapse unrelated downstream group structure.
## Long-Edge Dummy Vertices
-A link from layer 0 to layer 4 participates in crossing minimization at every
-intermediate boundary:
+A dependency from layer 0 to layer 4 participates in every intermediate crossing
+boundary:
```text
source -> dummy@1 -> dummy@2 -> dummy@3 -> target
```
-The dummy chain exists only while calculating vertical order. This makes a long
-wire influence layers 1, 2, and 3 instead of appearing only as a distant
-source/target relationship.
+The same mechanism is used for node-level and group-level ordering. Dummy
+vertices never become ComfyUI workflow objects.
## Safety Constraints
-The structural refiner currently skips a group when:
+Node-level structural refinement skips a group when:
- the group is pinned;
- any member node is pinned;
@@ -130,52 +117,89 @@ The structural refiner currently skips a group when:
- the group has no internal physical links;
- the group contains decorative or virtual Set/Get hub nodes.
-Existing bridge, local-control, text-preview, virtual-hub, pinned-surface, and
-group-overlap passes remain authoritative after refinement.
+Group-level refinement:
+
+- preserves group x coordinates and internal node geometry;
+- skips physical layers that contain a fixed/pinned group;
+- does not change graph topology;
+- is accepted only when its global port-aware quality key improves.
-In addition, Phase 1.5 rejects an individual group refinement when its incident
-physical crossing count increases, and final candidate selection rejects a
-crossing regression when overlap safety is unchanged.
+The existing bridge, local-control, text-preview, virtual-hub, pinned-surface,
+and group-overlap passes remain authoritative after every refinement.
-## Corpus Baseline and First v2 Comparison
+## Corpus Results
The current local corpus contains 81 UI workflows, 3,793 nodes, and 4,357
-physical links. The same corpus was measured on `master` and on the first v2
-implementation before Phase 1.5.
+physical links.
+
+### Master vs first v2 implementation
| Mode | Master crossings | v2 crossings | Master RTL | v2 RTL |
| --- | ---: | ---: | ---: | ---: |
| Layout only | 6,390 | 6,068 | 461 | 379 |
| Optimize + Layout | 4,320 | 4,395 | 372 | 318 |
-The first v2 implementation therefore improved layout-only crossings by 322 and
-RTL links by 82, while Optimize + Layout regressed by 75 crossings despite
-improving RTL by 54.
+The first v2 implementation therefore improved Layout Only by 322 crossings and
+82 RTL links, but Optimize + Layout regressed by 75 crossings while still
+reducing RTL by 54.
+
+The strongest node-level gain was:
+
+- `within_group x within_group`: 873 -> 607 in Layout Only.
+
+The main remaining categories were inter-group related, especially:
+
+- `group->group x group->group`;
+- `group->group x within_group`;
+- `group->group x ungrouped->group`.
+
+### Rejected Phase 1.5 boundary experiment
-Category comparison identified the main boundary problem:
+A boundary-anchor experiment was tested and removed from the active runtime after
+corpus measurement.
-- `within_group x within_group`: 873 -> 607 in layout-only;
-- `group->group x group->group`: 1,485 -> 1,301 in layout-only;
-- `group->group x within_group`: 941 -> 1,153 in layout-only;
-- `group->group x within_group`: 799 -> 940 with Optimize + Layout.
+Its measured result was:
+
+| Mode | Phase 1.5 crossings | Phase 1.5 RTL |
+| --- | ---: | ---: |
+| Layout only | 5,921 | 428 |
+| Optimize + Layout | 4,443 | 359 |
+
+Although it improved Layout Only crossings relative to the first v2 pass, it
+made Optimize + Layout worse than both master and the first v2 result. It also
+did not solve the main real-world regression:
+`Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` remained at 1,167 crossings in
+Layout Only and 1,055 crossings with Optimize + Layout.
+
+The experiment and its tests were therefore removed instead of accumulating
+dead layout logic in the branch. Git history preserves the implementation and
+benchmark evidence.
+
+### Current Phase 2 gate
+
+The weighted group-level pass must now be measured on the same 81-workflow corpus
+using both commands:
+
+```powershell
+uv run python tools/report_layout_quality.py example-workflows --top 20
+uv run python tools/report_layout_quality.py example-workflows --optimize --top 20
+```
-The SDXL workflow `Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` was the
-largest single regression: v2 added about 100 crossings relative to master in
-both modes. It is the primary real-world regression case for Phase 1.5.
+The primary comparisons are against:
-Phase 1.5 must now be re-measured on the same 81-workflow corpus before the
-engine proceeds to top-level group ordering.
+- master: 6,390 / 461 and 4,320 / 372;
+- first v2: 6,068 / 379 and 4,395 / 318;
+- the `Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` regression.
## Current Scope Boundary
-The engine still deliberately changes structural ordering only inside movable
-groups. Ungrouped linked flows and top-level group ordering continue to use the
-legacy placement.
+The active engine now refines movable group internals and top-level group order.
+Ungrouped linked dataflow still uses the legacy placement, including its bridge
+and external-source special cases.
-If Phase 1.5 removes the boundary regression without sacrificing the current
-within-group gains, the next step is a weighted group-level Sugiyama pass:
-SCC condensation, group dependency layers, weighted inter-group edges, dummy
-vertices for long group edges, repeated sweeps, and local transpose refinement.
+If the weighted group-level pass improves the corpus without introducing new
+large regressions, the next structural target is ungrouped linked flow ordering
+using the same SCC/dummy/sweep core.
## Validation
@@ -189,24 +213,22 @@ npm run typecheck
npm run build
```
-Regression tests now cover:
+Regression coverage includes:
- SCC condensation and downstream longest-path assignment;
-- dummy-vertex participation for long crossing edges;
-- port-aware right-to-left detection;
-- pinned-group refinement blocking;
-- graph/link preservation through v2 candidate selection;
-- crossing-first candidate ordering;
-- boundary-anchor influence from external socket geometry;
-- rejection and exact rollback of a group refinement that increases incident
- crossings.
-
-Before merging v2, reproduce the read-only corpus reports for both Layout Only
-and Optimize + Layout and compare them with `master` on the same workflow set.
+- dummy-vertex participation for long node edges;
+- port-aware RTL detection;
+- pinned-group node-level refinement blocking;
+- graph/link preservation through v2 selection;
+- direct group-edge wire multiplicity;
+- weighted group medians;
+- weighted group crossing elimination;
+- fixed-group protection at group level;
+- crossing-first group-level candidate selection.
## Documentation Status
-The release-facing README is intentionally unchanged while this branch remains
-experimental. If corpus validation confirms v2 as the new default, README.md,
-CHANGELOG.md, and any affected frontend documentation must be updated before the
-PR is marked ready for review.
+README.md and CHANGELOG.md remain intentionally unchanged while this branch is
+experimental. If corpus validation confirms v2 as the new default, release-facing
+documentation and any affected frontend documentation must be updated before the
+pull request is marked ready for review.
From 18320ec6ca21cc7f9b7e62fd516003a7c738d268 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Thu, 17 Sep 2026 23:16:20 +0200
Subject: [PATCH 020/105] Document deferred undo redo support
---
docs/LAYOUT_ENGINE_V2.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 086a1d5..8b036be 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -201,6 +201,14 @@ If the weighted group-level pass improves the corpus without introducing new
large regressions, the next structural target is ungrouped linked flow ordering
using the same SCC/dummy/sweep core.
+## Deferred TODOs
+
+- Add user-facing Undo/Redo for destructive workflow transformations such as
+ Layout and Optimize. The implementation should restore the complete workflow
+ state from before the action rather than trying to reverse individual geometry
+ mutations. This is intentionally deferred until the v2 layout engine and its
+ frontend integration are stable.
+
## Validation
Repository CI must remain green:
From a2cdac239a6542e91d118783cbc47f807c00219d Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:05:33 +0200
Subject: [PATCH 021/105] Compact v2 layer placement
---
flowforge/layout_engine_v2.py | 99 ++++++++++++++++++++++++++++++-----
1 file changed, 86 insertions(+), 13 deletions(-)
diff --git a/flowforge/layout_engine_v2.py b/flowforge/layout_engine_v2.py
index 92f61f4..07bb49e 100644
--- a/flowforge/layout_engine_v2.py
+++ b/flowforge/layout_engine_v2.py
@@ -39,6 +39,7 @@
_compress_debug_sidecar_layers,
_group_bottom_padding,
_group_top_padding,
+ _internal_layer_positions,
_is_decorative_node,
_is_pinned_node,
_is_virtual_hub_node,
@@ -70,6 +71,16 @@
V2_LINK_WEIGHT = 0.02
V2_ASPECT_WEIGHT = 160.0
V2_TARGET_ASPECT_RATIO = 1.35
+V2_MAX_WIDTH_GROWTH_RATIO = 1.35
+V2_COMPACT_WIDTH_RATIO = 0.75
+V2_SIGNIFICANT_CROSSING_GAIN_RATIO = 0.05
+V2_SIGNIFICANT_CROSSING_GAIN_MIN = 8
+V2_SIGNIFICANT_RTL_GAIN_RATIO = 0.10
+V2_SIGNIFICANT_RTL_GAIN_MIN = 3
+V2_COMPACT_CROSSING_TOLERANCE_RATIO = 0.02
+V2_COMPACT_CROSSING_TOLERANCE_MIN = 3
+V2_COMPACT_RTL_TOLERANCE_RATIO = 0.05
+V2_COMPACT_RTL_TOLERANCE_MIN = 1
Vertex = Hashable
@@ -120,7 +131,7 @@ def apply_best_layout(
if changed:
_finalize_refinement(refined, variant)
refined_score = _score_engine_v2(refined)
- if refined_score.total < baseline_score.total:
+ if _candidate_is_better(refined_score, baseline_score):
candidate = refined
candidate_score = refined_score
else:
@@ -143,7 +154,7 @@ def apply_best_layout(
candidate_score.height,
)
- if best_score is None or candidate_score.total < best_score.total:
+ if best_score is None or _candidate_is_better(candidate_score, best_score):
best_workflow = candidate
best_score = candidate_score
best_index = index
@@ -169,6 +180,50 @@ def apply_best_layout(
return best_workflow
+
+def _candidate_is_better(candidate: EngineV2Score, incumbent: EngineV2Score) -> bool:
+ """Prefer quality gains without allowing small gains to explode workflow width."""
+ if candidate.movable_overlaps != incumbent.movable_overlaps:
+ return candidate.movable_overlaps < incumbent.movable_overlaps
+
+ crossing_gain = incumbent.crossings - candidate.crossings
+ rtl_gain = incumbent.right_to_left_links - candidate.right_to_left_links
+ significant_crossing_gain = crossing_gain >= max(
+ V2_SIGNIFICANT_CROSSING_GAIN_MIN,
+ math.ceil(incumbent.crossings * V2_SIGNIFICANT_CROSSING_GAIN_RATIO),
+ )
+ significant_rtl_gain = rtl_gain >= max(
+ V2_SIGNIFICANT_RTL_GAIN_MIN,
+ math.ceil(incumbent.right_to_left_links * V2_SIGNIFICANT_RTL_GAIN_RATIO),
+ )
+
+ if (
+ incumbent.width > 0
+ and candidate.width > incumbent.width * V2_MAX_WIDTH_GROWTH_RATIO
+ and not significant_crossing_gain
+ and not significant_rtl_gain
+ ):
+ return False
+
+ compact_crossing_tolerance = max(
+ V2_COMPACT_CROSSING_TOLERANCE_MIN,
+ math.ceil(incumbent.crossings * V2_COMPACT_CROSSING_TOLERANCE_RATIO),
+ )
+ compact_rtl_tolerance = max(
+ V2_COMPACT_RTL_TOLERANCE_MIN,
+ math.ceil(incumbent.right_to_left_links * V2_COMPACT_RTL_TOLERANCE_RATIO),
+ )
+ if (
+ incumbent.width > 0
+ and candidate.width <= incumbent.width * V2_COMPACT_WIDTH_RATIO
+ and candidate.crossings <= incumbent.crossings + compact_crossing_tolerance
+ and candidate.right_to_left_links
+ <= incumbent.right_to_left_links + compact_rtl_tolerance
+ ):
+ return True
+
+ return candidate.total < incumbent.total
+
def _refine_movable_group_internals(workflow: Workflow, settings: LayoutSettings) -> bool:
"""Re-layout fully movable groups with SCC-aware, dummy-edge ordering."""
changed = False
@@ -217,30 +272,48 @@ def _refine_group(workflow: Workflow, group: Group, settings: LayoutSettings) ->
base_x = group.bounding[0] + settings.group_padding
base_y = group.bounding[1] + _group_top_padding(settings)
- current_x = base_x
- max_bottom = base_y
+ max_layer = max(layer_order)
+ physical_layers = {
+ layer: [
+ node_id
+ for node_id in layer_order.get(layer, [])
+ if isinstance(node_id, int)
+ ]
+ for layer in range(max_layer + 1)
+ }
+ layer_positions = _internal_layer_positions(
+ workflow,
+ physical_layers,
+ max_layer,
+ base_x,
+ base_y,
+ settings,
+ )
- for layer in sorted(layer_order):
- node_order = [node_id for node_id in layer_order[layer] if isinstance(node_id, int)]
+ placed_nodes = []
+ for layer in range(max_layer + 1):
+ node_order = physical_layers[layer]
if not node_order:
continue
- layer_width = max(_node_visual_width(workflow.nodes[node_id]) for node_id in node_order)
- current_y = base_y
+ layer_x, current_y = layer_positions[layer]
for node_id in node_order:
node = workflow.nodes[node_id]
- node.x = current_x
+ node.x = layer_x
node.y = current_y
+ placed_nodes.append(node)
current_y += _node_visual_height(node) + settings.node_v_gap
- max_bottom = max(max_bottom, current_y - settings.node_v_gap)
- current_x += layer_width + settings.node_h_gap
- content_right = current_x - settings.node_h_gap
+ if not placed_nodes:
+ return False
+
+ content_right = max(node.x + _node_visual_width(node) for node in placed_nodes)
+ content_bottom = max(node.y + _node_visual_height(node) for node in placed_nodes)
group.bounding = [
group.bounding[0],
group.bounding[1],
max(0.0, content_right - base_x) + 2.0 * settings.group_padding,
- max(0.0, max_bottom - base_y)
+ max(0.0, content_bottom - base_y)
+ _group_top_padding(settings)
+ _group_bottom_padding(settings),
]
From 56830684c4a0d6881cef841b99a5f82101e44144 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:05:47 +0200
Subject: [PATCH 022/105] Test compact v2 candidate selection
---
tests/test_layout_engine_v2.py | 90 ++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
diff --git a/tests/test_layout_engine_v2.py b/tests/test_layout_engine_v2.py
index e0a4e0a..dc9713d 100644
--- a/tests/test_layout_engine_v2.py
+++ b/tests/test_layout_engine_v2.py
@@ -2,10 +2,13 @@
from flowforge.layout import LayoutSettings
from flowforge.layout_engine_v2 import (
+ EngineV2Score,
_assign_scc_longest_path_layers,
_boundary_crossings,
+ _candidate_is_better,
_group_can_be_refined,
_minimize_crossings_with_dummies,
+ _refine_group,
_score_engine_v2,
apply_best_layout,
)
@@ -125,3 +128,90 @@ def test_apply_best_layout_preserves_graph_and_reports_selected_candidate():
assert result.layout_report is not None
assert result.layout_report.candidate_count >= 1
assert result.layout_report.selected_candidate >= 1
+
+
+def _score(
+ *,
+ total: float,
+ crossings: int,
+ rtl: int,
+ width: float,
+ height: float = 1000.0,
+ overlaps: int = 0,
+) -> EngineV2Score:
+ return EngineV2Score(
+ total=total,
+ crossings=crossings,
+ right_to_left_links=rtl,
+ movable_overlaps=overlaps,
+ link_length=10_000.0,
+ width=width,
+ height=height,
+ )
+
+
+def test_candidate_guard_rejects_large_width_growth_for_minor_quality_gain():
+ incumbent = _score(total=1000.0, crossings=100, rtl=20, width=1000.0)
+ wider = _score(total=900.0, crossings=99, rtl=20, width=1500.0)
+
+ assert not _candidate_is_better(wider, incumbent)
+
+
+def test_candidate_guard_allows_large_width_growth_for_significant_crossing_gain():
+ incumbent = _score(total=1000.0, crossings=100, rtl=20, width=1000.0)
+ wider = _score(total=900.0, crossings=90, rtl=20, width=1500.0)
+
+ assert _candidate_is_better(wider, incumbent)
+
+
+def test_candidate_guard_prefers_major_compaction_with_small_quality_cost():
+ incumbent = _score(total=900.0, crossings=100, rtl=20, width=2000.0)
+ compact = _score(total=950.0, crossings=102, rtl=21, width=1400.0)
+
+ assert _candidate_is_better(compact, incumbent)
+
+
+def test_group_refinement_reuses_compact_internal_layer_wrapping():
+ workflow = Workflow()
+ nodes = {
+ node_id: Node(
+ id=node_id,
+ type=f"Node{node_id}",
+ x=(node_id - 1) * 300,
+ y=0,
+ size=[200, 80],
+ input_count=1 if node_id > 1 else 0,
+ output_count=1 if node_id < 6 else 0,
+ )
+ for node_id in range(1, 7)
+ }
+ workflow.nodes = nodes
+ for link_id, source_id in enumerate(range(1, 6), start=1):
+ _attach_link(
+ workflow,
+ Link(
+ id=link_id,
+ source=source_id,
+ source_port=0,
+ target=source_id + 1,
+ target_port=0,
+ type="DATA",
+ ),
+ )
+ group = Group(
+ id=1,
+ name="Long chain",
+ nodes=list(nodes.values()),
+ bounding=[0, 0, 2000, 400],
+ )
+ workflow.groups = [group]
+
+ changed = _refine_group(
+ workflow,
+ group,
+ LayoutSettings(node_x_distance=80, node_y_distance=80),
+ )
+
+ assert changed
+ assert group.bounding[2] < 1200
+ assert max(node.y for node in nodes.values()) > min(node.y for node in nodes.values())
From 52c019d88514eb58b55404b48720867c6feacf6a Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:06:07 +0200
Subject: [PATCH 023/105] Document compact layout phase
---
docs/LAYOUT_ENGINE_V2.md | 52 +++++++++++++++++++++++++++++-----------
1 file changed, 38 insertions(+), 14 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 8b036be..23f2d92 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -175,21 +175,44 @@ The experiment and its tests were therefore removed instead of accumulating
dead layout logic in the branch. Git history preserves the implementation and
benchmark evidence.
-### Current Phase 2 gate
+### Phase 2 corpus result
-The weighted group-level pass must now be measured on the same 81-workflow corpus
-using both commands:
+The weighted group-level pass was measured on the same 81-workflow corpus:
-```powershell
-uv run python tools/report_layout_quality.py example-workflows --top 20
-uv run python tools/report_layout_quality.py example-workflows --optimize --top 20
-```
+| Mode | Master | First v2 | Phase 2 |
+| --- | ---: | ---: | ---: |
+| Layout only crossings / RTL | 6,390 / 461 | 6,068 / 379 | 6,052 / 379 |
+| Optimize + Layout crossings / RTL | 4,320 / 372 | 4,395 / 318 | 4,153 / 318 |
+
+Phase 2 therefore preserves the first-v2 RTL gains, slightly improves Layout
+Only crossings, and improves Optimize + Layout by 242 crossings relative to the
+first v2 implementation and by 167 relative to master.
+
+The strongest real-world regression also changed materially after Optimize:
+`Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` improved from 960 crossings
+on master and 1,056 on first v2 to 853 on Phase 2. Layout Only remains a known
+weak case at 1,168 crossings, so group ordering alone does not solve the
+unoptimized geometry.
+
+### Phase 2.5 compact placement
+
+Corpus inspection also exposed a strong left-to-right width bias. The node-level
+v2 refiner correctly computed SCC layers and crossing order, but then placed
+every physical layer into a fresh X column, discarding the compact internal
+layer placement already available in the legacy engine.
+
+Phase 2.5 keeps the v2 structural ordering but reuses the existing compact
+internal layer placement when assigning physical coordinates. Candidate
+selection also adds width guardrails:
-The primary comparisons are against:
+- reject more than 35% width growth when the candidate provides only a minor
+ crossing/RTL improvement;
+- allow width growth for a significant graph-quality gain;
+- prefer at least 25% compaction when crossings and RTL regress only within
+ small bounded tolerances.
-- master: 6,390 / 461 and 4,320 / 372;
-- first v2: 6,068 / 379 and 4,395 / 318;
-- the `Jibs_Ultimate_SD_Upscale_SDXL_V18_Workflow.json` regression.
+The intent is to remove pathological horizontal expansion without replacing the
+crossing-aware ordering logic with a purely geometric packing heuristic.
## Current Scope Boundary
@@ -197,9 +220,10 @@ The active engine now refines movable group internals and top-level group order.
Ungrouped linked dataflow still uses the legacy placement, including its bridge
and external-source special cases.
-If the weighted group-level pass improves the corpus without introducing new
-large regressions, the next structural target is ungrouped linked flow ordering
-using the same SCC/dummy/sweep core.
+After Phase 2.5 compactness is re-measured, the next structural target is
+ungrouped linked flow ordering using the same SCC/dummy/sweep core. Phase 3
+should not begin until the compactness guard is shown to preserve the Phase 2
+crossing and RTL gains.
## Deferred TODOs
From c068ec94a445f37a9e87575eedcc49c0d5aa51da Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:07:02 +0200
Subject: [PATCH 024/105] Preserve structural flow in width guard
---
flowforge/layout_engine_v2.py | 42 ++++++++++++-----------------------
1 file changed, 14 insertions(+), 28 deletions(-)
diff --git a/flowforge/layout_engine_v2.py b/flowforge/layout_engine_v2.py
index 07bb49e..091d595 100644
--- a/flowforge/layout_engine_v2.py
+++ b/flowforge/layout_engine_v2.py
@@ -72,15 +72,10 @@
V2_ASPECT_WEIGHT = 160.0
V2_TARGET_ASPECT_RATIO = 1.35
V2_MAX_WIDTH_GROWTH_RATIO = 1.35
-V2_COMPACT_WIDTH_RATIO = 0.75
V2_SIGNIFICANT_CROSSING_GAIN_RATIO = 0.05
V2_SIGNIFICANT_CROSSING_GAIN_MIN = 8
V2_SIGNIFICANT_RTL_GAIN_RATIO = 0.10
V2_SIGNIFICANT_RTL_GAIN_MIN = 3
-V2_COMPACT_CROSSING_TOLERANCE_RATIO = 0.02
-V2_COMPACT_CROSSING_TOLERANCE_MIN = 3
-V2_COMPACT_RTL_TOLERANCE_RATIO = 0.05
-V2_COMPACT_RTL_TOLERANCE_MIN = 1
Vertex = Hashable
@@ -188,13 +183,21 @@ def _candidate_is_better(candidate: EngineV2Score, incumbent: EngineV2Score) ->
crossing_gain = incumbent.crossings - candidate.crossings
rtl_gain = incumbent.right_to_left_links - candidate.right_to_left_links
- significant_crossing_gain = crossing_gain >= max(
- V2_SIGNIFICANT_CROSSING_GAIN_MIN,
- math.ceil(incumbent.crossings * V2_SIGNIFICANT_CROSSING_GAIN_RATIO),
+ significant_crossing_gain = (
+ candidate.crossings == 0 < incumbent.crossings
+ or crossing_gain
+ >= max(
+ V2_SIGNIFICANT_CROSSING_GAIN_MIN,
+ math.ceil(incumbent.crossings * V2_SIGNIFICANT_CROSSING_GAIN_RATIO),
+ )
)
- significant_rtl_gain = rtl_gain >= max(
- V2_SIGNIFICANT_RTL_GAIN_MIN,
- math.ceil(incumbent.right_to_left_links * V2_SIGNIFICANT_RTL_GAIN_RATIO),
+ significant_rtl_gain = (
+ candidate.right_to_left_links == 0 < incumbent.right_to_left_links
+ or rtl_gain
+ >= max(
+ V2_SIGNIFICANT_RTL_GAIN_MIN,
+ math.ceil(incumbent.right_to_left_links * V2_SIGNIFICANT_RTL_GAIN_RATIO),
+ )
)
if (
@@ -205,23 +208,6 @@ def _candidate_is_better(candidate: EngineV2Score, incumbent: EngineV2Score) ->
):
return False
- compact_crossing_tolerance = max(
- V2_COMPACT_CROSSING_TOLERANCE_MIN,
- math.ceil(incumbent.crossings * V2_COMPACT_CROSSING_TOLERANCE_RATIO),
- )
- compact_rtl_tolerance = max(
- V2_COMPACT_RTL_TOLERANCE_MIN,
- math.ceil(incumbent.right_to_left_links * V2_COMPACT_RTL_TOLERANCE_RATIO),
- )
- if (
- incumbent.width > 0
- and candidate.width <= incumbent.width * V2_COMPACT_WIDTH_RATIO
- and candidate.crossings <= incumbent.crossings + compact_crossing_tolerance
- and candidate.right_to_left_links
- <= incumbent.right_to_left_links + compact_rtl_tolerance
- ):
- return True
-
return candidate.total < incumbent.total
def _refine_movable_group_internals(workflow: Workflow, settings: LayoutSettings) -> bool:
From a974b987f9d78e0f6894cddff5ec170f121b18fb Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:07:10 +0200
Subject: [PATCH 025/105] Align width guard tests with flow safety
---
tests/test_layout_engine_v2.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_layout_engine_v2.py b/tests/test_layout_engine_v2.py
index dc9713d..3cb9814 100644
--- a/tests/test_layout_engine_v2.py
+++ b/tests/test_layout_engine_v2.py
@@ -164,11 +164,11 @@ def test_candidate_guard_allows_large_width_growth_for_significant_crossing_gain
assert _candidate_is_better(wider, incumbent)
-def test_candidate_guard_prefers_major_compaction_with_small_quality_cost():
- incumbent = _score(total=900.0, crossings=100, rtl=20, width=2000.0)
- compact = _score(total=950.0, crossings=102, rtl=21, width=1400.0)
+def test_candidate_guard_allows_width_growth_when_it_eliminates_all_rtl():
+ incumbent = _score(total=1000.0, crossings=0, rtl=1, width=1000.0)
+ wider = _score(total=900.0, crossings=0, rtl=0, width=1500.0)
- assert _candidate_is_better(compact, incumbent)
+ assert _candidate_is_better(wider, incumbent)
def test_group_refinement_reuses_compact_internal_layer_wrapping():
From d8ecb60f02824e305239f24560760ef2eee2688c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:07:20 +0200
Subject: [PATCH 026/105] Clarify compactness guard safety
---
docs/LAYOUT_ENGINE_V2.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 23f2d92..6cd0bc7 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -207,9 +207,9 @@ selection also adds width guardrails:
- reject more than 35% width growth when the candidate provides only a minor
crossing/RTL improvement;
-- allow width growth for a significant graph-quality gain;
-- prefer at least 25% compaction when crossings and RTL regress only within
- small bounded tolerances.
+- allow width growth for a significant graph-quality gain or when it completely
+ eliminates crossings/RTL links;
+- do not let compactness override existing structural flow invariants on its own.
The intent is to remove pathological horizontal expansion without replacing the
crossing-aware ordering logic with a purely geometric packing heuristic.
From 83007bf68d2810dcbfe83dd0324ca7e51ffe5aa5 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:12:20 +0200
Subject: [PATCH 027/105] Add compact global flow refinement
---
flowforge/layout_engine_v2_phase3.py | 163 +++++++++++++++++++++++++++
1 file changed, 163 insertions(+)
create mode 100644 flowforge/layout_engine_v2_phase3.py
diff --git a/flowforge/layout_engine_v2_phase3.py b/flowforge/layout_engine_v2_phase3.py
new file mode 100644
index 0000000..979e20f
--- /dev/null
+++ b/flowforge/layout_engine_v2_phase3.py
@@ -0,0 +1,163 @@
+"""Compact global-flow refinement for FlowForge layout engine v2.
+
+Phase 3 keeps the Phase 2 structural result as its baseline and creates one
+additional global compactness candidate. The candidate reuses the existing
+boustrophedon wrapping logic for top-level group layers and linked ungrouped
+flow, then re-applies weighted group ordering.
+
+This pass is deliberately conservative: small workflows are ignored and a
+compact candidate may only trade a bounded amount of crossing/RTL quality for a
+substantial width reduction.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+import math
+
+from .layout import (
+ LayoutSettings,
+ _is_decorative_node,
+ _node_visual_width,
+ _position_groups_globally,
+ _position_ungrouped_nodes,
+)
+from .layout_engine_v2 import EngineV2Score, _finalize_refinement, _score_engine_v2
+from .layout_engine_v2_phase2 import (
+ _quality_key,
+ _refine_group_level_order,
+ apply_best_layout as _apply_phase2_best_layout,
+)
+from .logger import setup_logger
+from .model import Workflow
+
+logger = setup_logger(__name__)
+
+COMPACT_GLOBAL_MIN_WIDTH = 7_000.0
+COMPACT_GLOBAL_MIN_WIDTH_REDUCTION_RATIO = 0.18
+COMPACT_GLOBAL_MAX_CROSSING_REGRESSION_RATIO = 0.015
+COMPACT_GLOBAL_MAX_CROSSING_REGRESSION_MIN = 4
+COMPACT_GLOBAL_MAX_RTL_REGRESSION_RATIO = 0.08
+COMPACT_GLOBAL_MAX_RTL_REGRESSION_MIN = 3
+COMPACT_GLOBAL_MAX_PERIMETER_GROWTH_RATIO = 1.15
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Run Phase 2, then try one width-aware compact global-flow candidate."""
+ settings = settings or LayoutSettings()
+ baseline = _apply_phase2_best_layout(workflow, settings, candidate_count)
+ baseline_score = _score_engine_v2(baseline)
+
+ if baseline_score.width < COMPACT_GLOBAL_MIN_WIDTH:
+ return baseline
+
+ compact = deepcopy(baseline)
+ compact_settings = LayoutSettings(
+ node_x_distance=settings.node_x_distance,
+ node_y_distance=settings.node_y_distance,
+ wrap_columns=True,
+ )
+ _compact_global_flow(compact, compact_settings)
+ _refine_group_level_order(compact, compact_settings)
+ _finalize_refinement(compact, compact_settings)
+ compact_score = _score_engine_v2(compact)
+
+ if _compact_candidate_is_better(compact_score, baseline_score):
+ logger.info(
+ "Compact global-flow candidate accepted: size %.0fx%.0f -> %.0fx%.0f, "
+ "crossings %s -> %s, rtl %s -> %s",
+ baseline_score.width,
+ baseline_score.height,
+ compact_score.width,
+ compact_score.height,
+ baseline_score.crossings,
+ compact_score.crossings,
+ baseline_score.right_to_left_links,
+ compact_score.right_to_left_links,
+ )
+ return compact
+
+ logger.info(
+ "Compact global-flow candidate rejected: size %.0fx%.0f -> %.0fx%.0f, "
+ "crossings %s -> %s, rtl %s -> %s",
+ baseline_score.width,
+ baseline_score.height,
+ compact_score.width,
+ compact_score.height,
+ baseline_score.crossings,
+ compact_score.crossings,
+ baseline_score.right_to_left_links,
+ compact_score.right_to_left_links,
+ )
+ return baseline
+
+
+def _compact_global_flow(workflow: Workflow, settings: LayoutSettings) -> None:
+ """Re-run global positioning with wrapping forced on for movable flow."""
+ decorative_right = max(
+ (
+ node.x + _node_visual_width(node)
+ for node in workflow.nodes.values()
+ if _is_decorative_node(node)
+ ),
+ default=20.0,
+ )
+ start_x = max(50.0, decorative_right + settings.group_h_gap)
+
+ _position_groups_globally(workflow, settings, start_x=start_x)
+ _position_ungrouped_nodes(
+ workflow,
+ settings,
+ start_x_floor=decorative_right + settings.group_h_gap,
+ )
+
+
+def _compact_candidate_is_better(
+ candidate: EngineV2Score,
+ baseline: EngineV2Score,
+) -> bool:
+ """Allow substantial compaction with only tightly bounded quality regressions."""
+ if candidate.movable_overlaps > baseline.movable_overlaps:
+ return False
+
+ if _quality_key(candidate) < _quality_key(baseline):
+ return True
+
+ if baseline.width <= 0:
+ return False
+
+ width_reduction = 1.0 - candidate.width / baseline.width
+ if width_reduction < COMPACT_GLOBAL_MIN_WIDTH_REDUCTION_RATIO:
+ return False
+
+ crossing_tolerance = max(
+ COMPACT_GLOBAL_MAX_CROSSING_REGRESSION_MIN,
+ math.ceil(
+ baseline.crossings * COMPACT_GLOBAL_MAX_CROSSING_REGRESSION_RATIO
+ ),
+ )
+ rtl_tolerance = max(
+ COMPACT_GLOBAL_MAX_RTL_REGRESSION_MIN,
+ math.ceil(
+ baseline.right_to_left_links * COMPACT_GLOBAL_MAX_RTL_REGRESSION_RATIO
+ ),
+ )
+ if candidate.crossings > baseline.crossings + crossing_tolerance:
+ return False
+ if (
+ candidate.right_to_left_links
+ > baseline.right_to_left_links + rtl_tolerance
+ ):
+ return False
+
+ baseline_perimeter = baseline.width + baseline.height
+ candidate_perimeter = candidate.width + candidate.height
+ return (
+ baseline_perimeter <= 0
+ or candidate_perimeter
+ <= baseline_perimeter * COMPACT_GLOBAL_MAX_PERIMETER_GROWTH_RATIO
+ )
From 69789dcd82b5396f69c996602791d088f63bbcd9 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:12:29 +0200
Subject: [PATCH 028/105] Use compact global flow runtime
---
flowforge/layout_engine_v2_runtime.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
index 2ed2b90..e723331 100644
--- a/flowforge/layout_engine_v2_runtime.py
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -10,7 +10,7 @@
from __future__ import annotations
from .layout import LayoutSettings
-from .layout_engine_v2_phase2 import apply_best_layout
+from .layout_engine_v2_phase3 import apply_best_layout
from .model import Workflow
From 99d753f084230c9342d74c26c8c049fd93226f55 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:12:38 +0200
Subject: [PATCH 029/105] Test compact global flow refinement
---
tests/test_layout_engine_v2_phase3.py | 103 ++++++++++++++++++++++++++
1 file changed, 103 insertions(+)
create mode 100644 tests/test_layout_engine_v2_phase3.py
diff --git a/tests/test_layout_engine_v2_phase3.py b/tests/test_layout_engine_v2_phase3.py
new file mode 100644
index 0000000..bbfccc7
--- /dev/null
+++ b/tests/test_layout_engine_v2_phase3.py
@@ -0,0 +1,103 @@
+"""Regression tests for compact global-flow refinement."""
+
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import EngineV2Score
+from flowforge.layout_engine_v2_phase3 import (
+ _compact_candidate_is_better,
+ _compact_global_flow,
+)
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _score(
+ *,
+ crossings: int,
+ rtl: int,
+ width: float,
+ height: float,
+ overlaps: int = 0,
+) -> EngineV2Score:
+ return EngineV2Score(
+ total=0.0,
+ crossings=crossings,
+ right_to_left_links=rtl,
+ movable_overlaps=overlaps,
+ link_length=10_000.0,
+ width=width,
+ height=height,
+ )
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def test_compact_candidate_accepts_large_width_reduction_with_small_quality_cost():
+ baseline = _score(crossings=200, rtl=40, width=10_000, height=5_000)
+ compact = _score(crossings=203, rtl=43, width=7_500, height=6_000)
+
+ assert _compact_candidate_is_better(compact, baseline)
+
+
+def test_compact_candidate_rejects_excessive_crossing_regression():
+ baseline = _score(crossings=200, rtl=40, width=10_000, height=5_000)
+ compact = _score(crossings=220, rtl=40, width=7_000, height=6_000)
+
+ assert not _compact_candidate_is_better(compact, baseline)
+
+
+def test_compact_global_flow_wraps_long_group_chain():
+ workflow = Workflow()
+ groups = []
+ for node_id in range(1, 9):
+ node = Node(
+ id=node_id,
+ type=f"Node{node_id}",
+ x=(node_id - 1) * 500,
+ y=100,
+ size=[220, 100],
+ input_count=1 if node_id > 1 else 0,
+ output_count=1 if node_id < 8 else 0,
+ )
+ workflow.nodes[node_id] = node
+ groups.append(
+ Group(
+ id=node_id,
+ name=f"Group {node_id}",
+ nodes=[node],
+ bounding=[(node_id - 1) * 500, 50, 320, 220],
+ )
+ )
+ workflow.groups = groups
+
+ for link_id, source_id in enumerate(range(1, 8), start=1):
+ _attach_link(
+ workflow,
+ Link(
+ id=link_id,
+ source=source_id,
+ source_port=0,
+ target=source_id + 1,
+ target_port=0,
+ type="DATA",
+ ),
+ )
+
+ original_width = (
+ max(group.bounding[0] + group.bounding[2] for group in workflow.groups)
+ - min(group.bounding[0] for group in workflow.groups)
+ )
+
+ _compact_global_flow(
+ workflow,
+ LayoutSettings(node_x_distance=80, node_y_distance=80, wrap_columns=True),
+ )
+
+ compact_width = (
+ max(group.bounding[0] + group.bounding[2] for group in workflow.groups)
+ - min(group.bounding[0] for group in workflow.groups)
+ )
+
+ assert compact_width < original_width * 0.75
From f8fd99783f10c65c10ded8b47b77d3d7fc5647fa Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:13:24 +0200
Subject: [PATCH 030/105] Document compact global flow phase
---
docs/LAYOUT_ENGINE_V2.md | 50 ++++++++++++++++++++++++++++++++++------
1 file changed, 43 insertions(+), 7 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 6cd0bc7..ad71b2b 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -214,16 +214,52 @@ selection also adds width guardrails:
The intent is to remove pathological horizontal expansion without replacing the
crossing-aware ordering logic with a purely geometric packing heuristic.
+## Phase 2.5 corpus result
+
+The compact internal-layer change did not materially reduce the largest workflow
+dimensions. On the 81-workflow corpus:
+
+| Mode | Phase 2 | Phase 2.5 |
+| --- | ---: | ---: |
+| Layout only crossings / RTL | 6,052 / 379 | 6,049 / 386 |
+| Optimize + Layout crossings / RTL | 4,153 / 318 | 4,130 / 333 |
+
+The large workflow dimensions were essentially unchanged. This showed that the
+dominant width bias was not the node-level group refiner alone. The existing
+top-level group and linked ungrouped placement paths both create dataflow
+columns, and wrapping was only available through a candidate flag that was not
+winning for the problematic workflows.
+
+## Phase 3 compact global flow
+
+Phase 3 keeps the complete Phase 2 result as its baseline and creates one
+additional compact global-flow candidate when workflow width is at least 7,000
+pixels.
+
+The compact candidate:
+
+1. forces the existing boustrophedon wrap mode for top-level group flow;
+2. forces the same wrap mode for linked ungrouped dataflow;
+3. re-applies weighted Phase 2 group ordering;
+4. re-runs the normal geometry finalizers;
+5. is accepted immediately if the normal Phase 2 quality key improves;
+6. otherwise requires at least 18% width reduction while allowing only tightly
+ bounded crossing/RTL regressions and at most 15% growth in width+height.
+
+Small workflows therefore keep the existing layout path unchanged. The compact
+pass is an optional post-process candidate and cannot overwrite the Phase 2
+baseline unless its trade-off satisfies the explicit guardrails.
+
## Current Scope Boundary
-The active engine now refines movable group internals and top-level group order.
-Ungrouped linked dataflow still uses the legacy placement, including its bridge
-and external-source special cases.
+The active engine now refines movable group internals and top-level group order,
+then optionally compacts the global group and linked-ungrouped flow with the
+existing wrap geometry. Bridge, external-source, control, and virtual-hub
+special cases still remain authoritative.
-After Phase 2.5 compactness is re-measured, the next structural target is
-ungrouped linked flow ordering using the same SCC/dummy/sweep core. Phase 3
-should not begin until the compactness guard is shown to preserve the Phase 2
-crossing and RTL gains.
+If the Phase 3 corpus confirms useful width reduction without losing the Phase 2
+quality gains, the next structural target is a true SCC/dummy/sweep ordering pass
+for ungrouped linked flow rather than only compact placement.
## Deferred TODOs
From 5109496617bbeb25a54cfdc9c6fa497ac714adb5 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:20:50 +0200
Subject: [PATCH 031/105] Add layout structure diagnostics
---
flowforge/layout_quality.py | 143 +++++++++++++++++++++++++++++++++++-
1 file changed, 141 insertions(+), 2 deletions(-)
diff --git a/flowforge/layout_quality.py b/flowforge/layout_quality.py
index d0312f1..b058b26 100644
--- a/flowforge/layout_quality.py
+++ b/flowforge/layout_quality.py
@@ -9,7 +9,14 @@
from typing import Any
from .api import _workflow_to_comfyui_json
-from .layout import LayoutReport, apply as apply_layout
+from .layout import (
+ LayoutReport,
+ _group_flow_layers,
+ _is_decorative_node,
+ _node_visual_width,
+ _ungrouped_flow_layers,
+ apply as apply_layout,
+)
from .optimizer import optimize as optimize_workflow
from .parser import parse_comfyui_workflow
from .workflow_validation import discover_workflow_files
@@ -27,6 +34,24 @@ class GeometryMetrics:
link_crossings: int
+@dataclass(frozen=True)
+class LayoutStructureMetrics:
+ """Structural width diagnostics for a laid-out workflow."""
+
+ group_span_width: float
+ ungrouped_span_width: float
+ group_flow_layers: int
+ ungrouped_flow_layers: int
+ group_columns: int
+ ungrouped_columns: int
+ widest_group_name: str | None
+ widest_group_width: float
+ widest_ungrouped_type: str | None
+ widest_ungrouped_width: float
+ left_edge_owner: str | None
+ right_edge_owner: str | None
+
+
@dataclass(frozen=True)
class WorkflowQualityReport:
"""Layout quality report for one ComfyUI UI workflow."""
@@ -40,6 +65,7 @@ class WorkflowQualityReport:
original: GeometryMetrics
laid_out: GeometryMetrics
moved_nodes: int
+ structure: LayoutStructureMetrics
laid_out_crossing_categories: dict[str, int] = field(default_factory=dict)
laid_out_right_to_left_categories: dict[str, int] = field(default_factory=dict)
layout_candidate_count: int | None = None
@@ -148,6 +174,7 @@ def build_workflow_quality_report(
original=original_metrics,
laid_out=laid_out_metrics,
moved_nodes=_count_moved_nodes(data, laid_out_data),
+ structure=_layout_structure_metrics(laid_out_workflow),
laid_out_crossing_categories=_count_crossing_categories(category_source, laid_out_data),
laid_out_right_to_left_categories=_count_right_to_left_categories(category_source, laid_out_data),
layout_candidate_count=_layout_candidate_count(layout_report),
@@ -156,7 +183,12 @@ def build_workflow_quality_report(
)
-def summarize_quality_text(summary: WorkflowQualitySummary, *, top: int = 10) -> str:
+def summarize_quality_text(
+ summary: WorkflowQualitySummary,
+ *,
+ top: int = 10,
+ include_structure: bool = False,
+) -> str:
"""Format a compact text summary for terminal output."""
lines = [
f"Root: {summary.root}",
@@ -208,11 +240,118 @@ def summarize_quality_text(summary: WorkflowQualitySummary, *, top: int = 10) ->
f"crossings={report.original.link_crossings}->{report.laid_out.link_crossings} "
f"rtl={report.original.right_to_left_links}->{report.laid_out.right_to_left_links}"
)
+ if include_structure:
+ structure = report.structure
+ widest_group = (
+ f"{structure.widest_group_name} ({structure.widest_group_width:.0f})"
+ if structure.widest_group_name is not None
+ else "none"
+ )
+ widest_ungrouped = (
+ f"{structure.widest_ungrouped_type} ({structure.widest_ungrouped_width:.0f})"
+ if structure.widest_ungrouped_type is not None
+ else "none"
+ )
+ lines.append(
+ " structure: "
+ f"groups span={structure.group_span_width:.0f} "
+ f"layers={structure.group_flow_layers} cols={structure.group_columns} "
+ f"widest={widest_group}; "
+ f"ungrouped span={structure.ungrouped_span_width:.0f} "
+ f"layers={structure.ungrouped_flow_layers} cols={structure.ungrouped_columns} "
+ f"widest={widest_ungrouped}; "
+ f"edges={structure.left_edge_owner} -> {structure.right_edge_owner}"
+ )
for failure in summary.failures[:20]:
lines.append(f"- {failure.path}: {failure.message}")
return "\n".join(lines)
+
+def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
+ """Measure which laid-out structures dominate horizontal workflow span."""
+ groups = [
+ group
+ for group in workflow.groups
+ if group.nodes and len(group.bounding) >= 4 and group.bounding[2] > 0
+ ]
+ ungrouped = [
+ node
+ for node in workflow.ungrouped_nodes
+ if node.id in workflow.nodes and not _is_decorative_node(node)
+ ]
+
+ group_span_width = _span_width(
+ (group.bounding[0], group.bounding[0] + group.bounding[2])
+ for group in groups
+ )
+ ungrouped_span_width = _span_width(
+ (node.x, node.x + _node_visual_width(node))
+ for node in ungrouped
+ )
+
+ group_layers = _group_flow_layers(workflow) if groups else {}
+ ungrouped_layers = _ungrouped_flow_layers(workflow, ungrouped) if ungrouped else {}
+
+ widest_group = max(groups, key=lambda group: group.bounding[2], default=None)
+ widest_ungrouped = max(
+ ungrouped,
+ key=_node_visual_width,
+ default=None,
+ )
+
+ edge_items: list[tuple[float, float, str]] = []
+ for group in groups:
+ label = f"group:{group.name or group.id}"
+ edge_items.append(
+ (group.bounding[0], group.bounding[0] + group.bounding[2], label)
+ )
+ for node in ungrouped:
+ label = f"node:{node.type}#{node.id}"
+ edge_items.append((node.x, node.x + _node_visual_width(node), label))
+
+ left_owner = min(edge_items, key=lambda item: item[0])[2] if edge_items else None
+ right_owner = max(edge_items, key=lambda item: item[1])[2] if edge_items else None
+
+ return LayoutStructureMetrics(
+ group_span_width=group_span_width,
+ ungrouped_span_width=ungrouped_span_width,
+ group_flow_layers=_layer_count(group_layers),
+ ungrouped_flow_layers=_layer_count(ungrouped_layers),
+ group_columns=len({round(group.bounding[0], 3) for group in groups}),
+ ungrouped_columns=len({round(node.x, 3) for node in ungrouped}),
+ widest_group_name=(
+ widest_group.name or f"group-{widest_group.id}"
+ if widest_group is not None
+ else None
+ ),
+ widest_group_width=(
+ float(widest_group.bounding[2]) if widest_group is not None else 0.0
+ ),
+ widest_ungrouped_type=(
+ widest_ungrouped.type if widest_ungrouped is not None else None
+ ),
+ widest_ungrouped_width=(
+ _node_visual_width(widest_ungrouped)
+ if widest_ungrouped is not None
+ else 0.0
+ ),
+ left_edge_owner=left_owner,
+ right_edge_owner=right_owner,
+ )
+
+
+def _span_width(spans) -> float:
+ spans = list(spans)
+ if not spans:
+ return 0.0
+ return max(right for _left, right in spans) - min(left for left, _right in spans)
+
+
+def _layer_count(layers: dict[int, int]) -> int:
+ return max(layers.values(), default=-1) + 1
+
+
def _geometry_metrics(data: dict[str, Any]) -> GeometryMetrics:
centers = _node_centers(data)
return GeometryMetrics(
From 3e609f6d95373a5d2d7e008ffa7ae72e359520b0 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:20:57 +0200
Subject: [PATCH 032/105] Fix structure diagnostic fallback
---
flowforge/layout_quality.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_quality.py b/flowforge/layout_quality.py
index b058b26..bc3addd 100644
--- a/flowforge/layout_quality.py
+++ b/flowforge/layout_quality.py
@@ -321,7 +321,7 @@ def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
group_columns=len({round(group.bounding[0], 3) for group in groups}),
ungrouped_columns=len({round(node.x, 3) for node in ungrouped}),
widest_group_name=(
- widest_group.name or f"group-{widest_group.id}"
+ (widest_group.name or f"group-{widest_group.id}")
if widest_group is not None
else None
),
From dd17e665e26354925874f824d8ae528225ad08a6 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:21:03 +0200
Subject: [PATCH 033/105] Expose layout structure diagnostics
---
tools/report_layout_quality.py | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/tools/report_layout_quality.py b/tools/report_layout_quality.py
index bbfda5d..ed78ea7 100644
--- a/tools/report_layout_quality.py
+++ b/tools/report_layout_quality.py
@@ -38,6 +38,11 @@ def main() -> int:
parser.add_argument("--limit", type=int, help="Report only the first N discovered JSON files.")
parser.add_argument("--top", type=int, default=10, help="Number of largest workflows to print.")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON output.")
+ parser.add_argument(
+ "--structure",
+ action="store_true",
+ help="Print structural width diagnostics for the largest laid-out workflows.",
+ )
args = parser.parse_args()
logging.disable(logging.INFO)
@@ -51,7 +56,13 @@ def main() -> int:
if args.json:
print(json.dumps(summary.to_json_dict(), indent=2))
else:
- print(summarize_quality_text(summary, top=max(0, args.top)))
+ print(
+ summarize_quality_text(
+ summary,
+ top=max(0, args.top),
+ include_structure=args.structure,
+ )
+ )
return 0 if summary.ok else 1
From ac10e45c5ea2272857d6beb6d2fcbcb99c64392e Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:21:11 +0200
Subject: [PATCH 034/105] Test layout structure diagnostics
---
tests/test_layout_quality.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/tests/test_layout_quality.py b/tests/test_layout_quality.py
index 6bd023e..5e44dea 100644
--- a/tests/test_layout_quality.py
+++ b/tests/test_layout_quality.py
@@ -46,6 +46,10 @@ def test_build_workflow_quality_report_counts_geometry_and_layout_metrics():
assert sum(report.laid_out_right_to_left_categories.values()) == report.laid_out.right_to_left_links
assert report.layout_candidate_count is not None
assert report.layout_score is not None
+ assert report.structure.group_span_width > 0
+ assert report.structure.group_columns >= 1
+ assert report.structure.left_edge_owner is not None
+ assert report.structure.right_edge_owner is not None
def test_build_workflow_quality_report_can_optimize_before_layout():
@@ -125,3 +129,14 @@ def test_summarize_quality_text_includes_category_breakdowns(tmp_path):
assert "- within_group x group->group: 2" in text
assert "Top laid-out right-to-left categories:" in text
assert "- within_group: 1" in text
+
+
+def test_summarize_quality_text_can_include_structure_diagnostics(tmp_path):
+ (tmp_path / "workflow.json").write_text(json.dumps(WORKFLOW), encoding="utf-8")
+ summary = build_quality_summary(tmp_path)
+
+ text = summarize_quality_text(summary, include_structure=True)
+
+ assert "structure: groups span=" in text
+ assert "ungrouped span=" in text
+ assert "edges=" in text
From 9450b93cab63493184cda0acfaf827378e3dac3f Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:21:26 +0200
Subject: [PATCH 035/105] Document Phase 3 structure diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index ad71b2b..8f59d60 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -250,6 +250,44 @@ Small workflows therefore keep the existing layout path unchanged. The compact
pass is an optional post-process candidate and cannot overwrite the Phase 2
baseline unless its trade-off satisfies the explicit guardrails.
+## Phase 3 corpus result
+
+The first compact global-flow pass produced only a narrow improvement:
+
+| Mode | Phase 2.5 | Phase 3 |
+| --- | ---: | ---: |
+| Layout only crossings / RTL | 6,049 / 386 | 6,044 / 386 |
+| Optimize + Layout crossings / RTL | 4,130 / 333 | 4,127 / 339 |
+
+The main visible geometry change was the Z-Image Base Ultra workflow:
+
+- Layout Only: `19,318 x 17,462` -> `19,190 x 12,160`;
+- Optimize + Layout: `19,062 x 16,116` -> `18,934 x 12,228`.
+
+Other major cases such as Flux Edit Ultra, Luneva, Qwen Edit, and Jibs remained
+essentially unchanged in size. This shows that simply forcing the existing wrap
+geometry is not a general solution for the width bias.
+
+### Structure diagnostics
+
+The quality reporter now supports an optional structural width diagnosis:
+
+```powershell
+uv run python tools/report_layout_quality.py example-workflows --top 20 --structure
+uv run python tools/report_layout_quality.py example-workflows --optimize --top 20 --structure
+```
+
+For every printed large workflow it reports:
+
+- horizontal span of groups and ungrouped nodes;
+- group-flow and ungrouped-flow layer counts;
+- number of distinct physical X columns;
+- widest group and widest ungrouped node;
+- which group/node owns the left and right horizontal workflow edges.
+
+The next layout change should be based on these measurements rather than making
+global wrap thresholds more aggressive.
+
## Current Scope Boundary
The active engine now refines movable group internals and top-level group order,
From 1b781eec917a5f126401c05fb6c539b2a77a2bbc Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:29:22 +0200
Subject: [PATCH 036/105] Defer structure diagnostics to top workflows
---
flowforge/layout_quality.py | 75 ++++++++++++++++++++++++++++++++-----
1 file changed, 66 insertions(+), 9 deletions(-)
diff --git a/flowforge/layout_quality.py b/flowforge/layout_quality.py
index bc3addd..5e80e00 100644
--- a/flowforge/layout_quality.py
+++ b/flowforge/layout_quality.py
@@ -4,7 +4,7 @@
import json
import math
-from dataclasses import asdict, dataclass, field
+from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import Any
@@ -65,7 +65,7 @@ class WorkflowQualityReport:
original: GeometryMetrics
laid_out: GeometryMetrics
moved_nodes: int
- structure: LayoutStructureMetrics
+ structure: LayoutStructureMetrics | None = None
laid_out_crossing_categories: dict[str, int] = field(default_factory=dict)
laid_out_right_to_left_categories: dict[str, int] = field(default_factory=dict)
layout_candidate_count: int | None = None
@@ -108,6 +108,7 @@ def build_quality_summary(
include_layouted: bool = False,
optimize_first: bool = False,
limit: int | None = None,
+ structure_top: int = 0,
) -> WorkflowQualitySummary:
"""Build read-only layout quality reports for UI workflow JSON files."""
files = discover_workflow_files(root, include_layouted=include_layouted)
@@ -118,6 +119,7 @@ def build_quality_summary(
skipped = 0
reports: list[WorkflowQualityReport] = []
failures: list[WorkflowQualityFailure] = []
+ laid_out_workflows: dict[str, Any] = {}
for path in files:
relative_path = str(path.relative_to(root))
@@ -126,7 +128,15 @@ def build_quality_summary(
if not _looks_like_ui_workflow(data):
skipped += 1
continue
- reports.append(build_workflow_quality_report(data, relative_path, optimize_first=optimize_first))
+ report, laid_out_workflow = _build_workflow_quality_report_and_layout(
+ data,
+ relative_path,
+ optimize_first=optimize_first,
+ include_structure=False,
+ )
+ reports.append(report)
+ if structure_top > 0:
+ laid_out_workflows[relative_path] = laid_out_workflow
checked += 1
except Exception as exc:
failures.append(
@@ -136,6 +146,25 @@ def build_quality_summary(
)
)
+ if structure_top > 0 and reports:
+ selected_paths = {
+ report.path
+ for report in sorted(
+ reports,
+ key=lambda item: item.laid_out.area,
+ reverse=True,
+ )[: max(0, structure_top)]
+ }
+ reports = [
+ replace(
+ report,
+ structure=_layout_structure_metrics(laid_out_workflows[report.path]),
+ )
+ if report.path in selected_paths
+ else report
+ for report in reports
+ ]
+
return WorkflowQualitySummary(
root=str(root),
optimize_first=optimize_first,
@@ -154,6 +183,23 @@ def build_workflow_quality_report(
optimize_first: bool = False,
) -> WorkflowQualityReport:
"""Build a quality report for one parsed UI workflow dictionary."""
+ report, _laid_out_workflow = _build_workflow_quality_report_and_layout(
+ data,
+ path,
+ optimize_first=optimize_first,
+ include_structure=True,
+ )
+ return report
+
+
+def _build_workflow_quality_report_and_layout(
+ data: dict[str, Any],
+ path: str,
+ *,
+ optimize_first: bool,
+ include_structure: bool,
+) -> tuple[WorkflowQualityReport, Any]:
+ """Build one report and retain its laid-out workflow for optional diagnostics."""
workflow = parse_comfyui_workflow(data)
if optimize_first:
workflow = optimize_workflow(workflow)
@@ -164,23 +210,34 @@ def build_workflow_quality_report(
original_metrics = _geometry_metrics(data)
laid_out_metrics = _geometry_metrics(laid_out_data)
- return WorkflowQualityReport(
+ report = WorkflowQualityReport(
path=path,
node_count=len(data.get("nodes", [])),
link_count=len(data.get("links", [])),
laid_out_node_count=len(laid_out_data.get("nodes", [])),
laid_out_link_count=len(laid_out_data.get("links", [])),
- group_count=len(data.get("groups", [])) if isinstance(data.get("groups", []), list) else 0,
+ group_count=len(data.get("groups", []))
+ if isinstance(data.get("groups", []), list)
+ else 0,
original=original_metrics,
laid_out=laid_out_metrics,
moved_nodes=_count_moved_nodes(data, laid_out_data),
- structure=_layout_structure_metrics(laid_out_workflow),
- laid_out_crossing_categories=_count_crossing_categories(category_source, laid_out_data),
- laid_out_right_to_left_categories=_count_right_to_left_categories(category_source, laid_out_data),
+ structure=(
+ _layout_structure_metrics(laid_out_workflow)
+ if include_structure
+ else None
+ ),
+ laid_out_crossing_categories=_count_crossing_categories(
+ category_source, laid_out_data
+ ),
+ laid_out_right_to_left_categories=_count_right_to_left_categories(
+ category_source, laid_out_data
+ ),
layout_candidate_count=_layout_candidate_count(layout_report),
selected_layout_candidate=_selected_layout_candidate(layout_report),
layout_score=_layout_score(layout_report),
)
+ return report, laid_out_workflow
def summarize_quality_text(
@@ -240,7 +297,7 @@ def summarize_quality_text(
f"crossings={report.original.link_crossings}->{report.laid_out.link_crossings} "
f"rtl={report.original.right_to_left_links}->{report.laid_out.right_to_left_links}"
)
- if include_structure:
+ if include_structure and report.structure is not None:
structure = report.structure
widest_group = (
f"{structure.widest_group_name} ({structure.widest_group_width:.0f})"
From 119d5c6aab3429e6b081fe73fa313ade09ae88c7 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:29:28 +0200
Subject: [PATCH 037/105] Limit structure diagnostics to printed workflows
---
tools/report_layout_quality.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tools/report_layout_quality.py b/tools/report_layout_quality.py
index ed78ea7..a67fd39 100644
--- a/tools/report_layout_quality.py
+++ b/tools/report_layout_quality.py
@@ -51,6 +51,7 @@ def main() -> int:
include_layouted=args.include_layouted,
optimize_first=args.optimize,
limit=args.limit,
+ structure_top=max(0, args.top) if args.structure else 0,
)
if args.json:
From c4af8dade87b8fc4c0bca16ec6754599d2dd9289 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:29:33 +0200
Subject: [PATCH 038/105] Test lazy structure diagnostics
---
tests/test_layout_quality.py | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/tests/test_layout_quality.py b/tests/test_layout_quality.py
index 5e44dea..939103e 100644
--- a/tests/test_layout_quality.py
+++ b/tests/test_layout_quality.py
@@ -133,10 +133,26 @@ def test_summarize_quality_text_includes_category_breakdowns(tmp_path):
def test_summarize_quality_text_can_include_structure_diagnostics(tmp_path):
(tmp_path / "workflow.json").write_text(json.dumps(WORKFLOW), encoding="utf-8")
- summary = build_quality_summary(tmp_path)
+ summary = build_quality_summary(tmp_path, structure_top=1)
text = summarize_quality_text(summary, include_structure=True)
assert "structure: groups span=" in text
assert "ungrouped span=" in text
assert "edges=" in text
+
+
+def test_build_quality_summary_defers_structure_for_non_top_reports(tmp_path):
+ first = dict(WORKFLOW)
+ second = json.loads(json.dumps(WORKFLOW))
+ second["nodes"][2]["pos"] = [1200, 0]
+ second["nodes"][3]["pos"] = [1200, 200]
+ second["groups"][0]["bounding"] = [-20, -20, 1360, 340]
+
+ (tmp_path / "small.json").write_text(json.dumps(first), encoding="utf-8")
+ (tmp_path / "large.json").write_text(json.dumps(second), encoding="utf-8")
+
+ summary = build_quality_summary(tmp_path, structure_top=1)
+ structured = [report for report in summary.reports if report.structure is not None]
+
+ assert len(structured) == 1
From 1d3e3ccd5d6b5e7a8747ce228024e7e9262ce06c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:44:00 +0200
Subject: [PATCH 039/105] Compact decorative annotations above wide workflows
---
flowforge/layout_engine_v2_phase3.py | 96 ++++++++++++++++++++++------
1 file changed, 78 insertions(+), 18 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase3.py b/flowforge/layout_engine_v2_phase3.py
index 979e20f..547ecde 100644
--- a/flowforge/layout_engine_v2_phase3.py
+++ b/flowforge/layout_engine_v2_phase3.py
@@ -18,6 +18,8 @@
from .layout import (
LayoutSettings,
_is_decorative_node,
+ _is_pinned_node,
+ _node_visual_height,
_node_visual_width,
_position_groups_globally,
_position_ungrouped_nodes,
@@ -55,47 +57,105 @@ def apply_best_layout(
if baseline_score.width < COMPACT_GLOBAL_MIN_WIDTH:
return baseline
- compact = deepcopy(baseline)
compact_settings = LayoutSettings(
node_x_distance=settings.node_x_distance,
node_y_distance=settings.node_y_distance,
wrap_columns=True,
)
- _compact_global_flow(compact, compact_settings)
- _refine_group_level_order(compact, compact_settings)
- _finalize_refinement(compact, compact_settings)
- compact_score = _score_engine_v2(compact)
- if _compact_candidate_is_better(compact_score, baseline_score):
+ candidates: list[tuple[str, Workflow, EngineV2Score]] = []
+
+ decorative_compact = deepcopy(baseline)
+ if _compact_decorative_nodes_above(decorative_compact, compact_settings):
+ candidates.append(
+ (
+ "decorative",
+ decorative_compact,
+ _score_engine_v2(decorative_compact),
+ )
+ )
+
+ global_compact = deepcopy(baseline)
+ _compact_global_flow(global_compact, compact_settings)
+ _refine_group_level_order(global_compact, compact_settings)
+ _finalize_refinement(global_compact, compact_settings)
+ _compact_decorative_nodes_above(global_compact, compact_settings)
+ candidates.append(
+ ("global", global_compact, _score_engine_v2(global_compact))
+ )
+
+ best_workflow = baseline
+ best_score = baseline_score
+ best_name = "baseline"
+ for name, candidate, candidate_score in candidates:
+ if _compact_candidate_is_better(candidate_score, best_score):
+ best_workflow = candidate
+ best_score = candidate_score
+ best_name = name
+
+ if best_workflow is not baseline:
logger.info(
- "Compact global-flow candidate accepted: size %.0fx%.0f -> %.0fx%.0f, "
+ "Compact %s candidate accepted: size %.0fx%.0f -> %.0fx%.0f, "
"crossings %s -> %s, rtl %s -> %s",
+ best_name,
baseline_score.width,
baseline_score.height,
- compact_score.width,
- compact_score.height,
+ best_score.width,
+ best_score.height,
baseline_score.crossings,
- compact_score.crossings,
+ best_score.crossings,
baseline_score.right_to_left_links,
- compact_score.right_to_left_links,
+ best_score.right_to_left_links,
)
- return compact
+ return best_workflow
logger.info(
- "Compact global-flow candidate rejected: size %.0fx%.0f -> %.0fx%.0f, "
- "crossings %s -> %s, rtl %s -> %s",
+ "Compact candidates rejected: baseline size %.0fx%.0f, crossings=%s, rtl=%s",
baseline_score.width,
baseline_score.height,
- compact_score.width,
- compact_score.height,
baseline_score.crossings,
- compact_score.crossings,
baseline_score.right_to_left_links,
- compact_score.right_to_left_links,
)
return baseline
+def _compact_decorative_nodes_above(
+ workflow: Workflow,
+ settings: LayoutSettings,
+) -> bool:
+ """Move wide decorative annotations above the graph instead of beside it."""
+ decorative = [
+ node
+ for node in workflow.nodes.values()
+ if _is_decorative_node(node) and not _is_pinned_node(workflow, node)
+ ]
+ graph_nodes = [
+ node
+ for node in workflow.nodes.values()
+ if not _is_decorative_node(node)
+ ]
+ if not decorative or not graph_nodes:
+ return False
+
+ graph_left = min(node.x for node in graph_nodes)
+ graph_top = min(node.y for node in graph_nodes)
+ gap = max(settings.node_v_gap, settings.group_v_gap)
+
+ ordered = sorted(decorative, key=lambda node: (node.y, node.x, node.id))
+ total_height = sum(_node_visual_height(node) for node in ordered)
+ total_height += gap * max(0, len(ordered) - 1)
+
+ current_y = graph_top - gap - total_height
+ changed = False
+ for node in ordered:
+ if abs(node.x - graph_left) > 1e-9 or abs(node.y - current_y) > 1e-9:
+ changed = True
+ node.x = graph_left
+ node.y = current_y
+ current_y += _node_visual_height(node) + gap
+ return changed
+
+
def _compact_global_flow(workflow: Workflow, settings: LayoutSettings) -> None:
"""Re-run global positioning with wrapping forced on for movable flow."""
decorative_right = max(
From 630dff00cd20487d3a7e642ac3ac1ac638093bbc Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:44:10 +0200
Subject: [PATCH 040/105] Test decorative annotation compaction
---
tests/test_layout_engine_v2_phase3.py | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/tests/test_layout_engine_v2_phase3.py b/tests/test_layout_engine_v2_phase3.py
index bbfccc7..4b968fe 100644
--- a/tests/test_layout_engine_v2_phase3.py
+++ b/tests/test_layout_engine_v2_phase3.py
@@ -4,6 +4,7 @@
from flowforge.layout_engine_v2 import EngineV2Score
from flowforge.layout_engine_v2_phase3 import (
_compact_candidate_is_better,
+ _compact_decorative_nodes_above,
_compact_global_flow,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -101,3 +102,23 @@ def test_compact_global_flow_wraps_long_group_chain():
)
assert compact_width < original_width * 0.75
+
+
+def test_compact_decorative_nodes_move_above_graph_without_moving_graph():
+ workflow = Workflow()
+ note = Node(id=1, type="MarkdownNote", x=20, y=50, size=[8_000, 300])
+ source = Node(id=2, type="Source", x=9_000, y=200, size=[220, 100])
+ target = Node(id=3, type="Target", x=9_500, y=200, size=[220, 100])
+ workflow.nodes = {1: note, 2: source, 3: target}
+ workflow.ungrouped_nodes = [note, source, target]
+
+ source_before = (source.x, source.y)
+ target_before = (target.x, target.y)
+
+ changed = _compact_decorative_nodes_above(workflow, LayoutSettings())
+
+ assert changed
+ assert (source.x, source.y) == source_before
+ assert (target.x, target.y) == target_before
+ assert note.x == source.x
+ assert note.y + note.size[1] < min(source.y, target.y)
From 4ab74f05a4f33aeae248a74bf880951ddfd32f2c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:44:24 +0200
Subject: [PATCH 041/105] Report decorative width diagnostics
---
flowforge/layout_quality.py | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/flowforge/layout_quality.py b/flowforge/layout_quality.py
index 5e80e00..65c4220 100644
--- a/flowforge/layout_quality.py
+++ b/flowforge/layout_quality.py
@@ -44,6 +44,10 @@ class LayoutStructureMetrics:
ungrouped_flow_layers: int
group_columns: int
ungrouped_columns: int
+ decorative_count: int
+ decorative_span_width: float
+ widest_decorative_type: str | None
+ widest_decorative_width: float
widest_group_name: str | None
widest_group_width: float
widest_ungrouped_type: str | None
@@ -309,6 +313,11 @@ def summarize_quality_text(
if structure.widest_ungrouped_type is not None
else "none"
)
+ widest_decorative = (
+ f"{structure.widest_decorative_type} ({structure.widest_decorative_width:.0f})"
+ if structure.widest_decorative_type is not None
+ else "none"
+ )
lines.append(
" structure: "
f"groups span={structure.group_span_width:.0f} "
@@ -317,6 +326,9 @@ def summarize_quality_text(
f"ungrouped span={structure.ungrouped_span_width:.0f} "
f"layers={structure.ungrouped_flow_layers} cols={structure.ungrouped_columns} "
f"widest={widest_ungrouped}; "
+ f"decorative count={structure.decorative_count} "
+ f"span={structure.decorative_span_width:.0f} "
+ f"widest={widest_decorative}; "
f"edges={structure.left_edge_owner} -> {structure.right_edge_owner}"
)
for failure in summary.failures[:20]:
@@ -337,6 +349,11 @@ def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
for node in workflow.ungrouped_nodes
if node.id in workflow.nodes and not _is_decorative_node(node)
]
+ decorative = [
+ node
+ for node in workflow.nodes.values()
+ if _is_decorative_node(node)
+ ]
group_span_width = _span_width(
(group.bounding[0], group.bounding[0] + group.bounding[2])
@@ -346,6 +363,10 @@ def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
(node.x, node.x + _node_visual_width(node))
for node in ungrouped
)
+ decorative_span_width = _span_width(
+ (node.x, node.x + _node_visual_width(node))
+ for node in decorative
+ )
group_layers = _group_flow_layers(workflow) if groups else {}
ungrouped_layers = _ungrouped_flow_layers(workflow, ungrouped) if ungrouped else {}
@@ -356,6 +377,11 @@ def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
key=_node_visual_width,
default=None,
)
+ widest_decorative = max(
+ decorative,
+ key=_node_visual_width,
+ default=None,
+ )
edge_items: list[tuple[float, float, str]] = []
for group in groups:
@@ -377,6 +403,16 @@ def _layout_structure_metrics(workflow) -> LayoutStructureMetrics:
ungrouped_flow_layers=_layer_count(ungrouped_layers),
group_columns=len({round(group.bounding[0], 3) for group in groups}),
ungrouped_columns=len({round(node.x, 3) for node in ungrouped}),
+ decorative_count=len(decorative),
+ decorative_span_width=decorative_span_width,
+ widest_decorative_type=(
+ widest_decorative.type if widest_decorative is not None else None
+ ),
+ widest_decorative_width=(
+ _node_visual_width(widest_decorative)
+ if widest_decorative is not None
+ else 0.0
+ ),
widest_group_name=(
(widest_group.name or f"group-{widest_group.id}")
if widest_group is not None
From 701ac265dc19e93b22e01bac24b9a1d828e7b7a8 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:45:01 +0200
Subject: [PATCH 042/105] Document workflow width root causes
---
docs/LAYOUT_ENGINE_V2.md | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 8f59d60..29a9ac2 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -288,6 +288,34 @@ For every printed large workflow it reports:
The next layout change should be based on these measurements rather than making
global wrap thresholds more aggressive.
+## Width root-cause findings
+
+The structural diagnostics showed that workflow width is not caused by one
+single mechanism.
+
+Representative Optimize + Layout results:
+
+- Flux Edit Ultra: groups span 7,031 px, ungrouped span 8,318 px;
+- Z-Image Base Ultra: groups span 8,720 px, ungrouped span only 440 px;
+- Luneva: groups span 7,826 px, ungrouped span 5,822 px;
+- Amazing Z-Image: ungrouped span 12,220 px across 20 flow layers / 43 columns;
+- Jibs: groups span 3,090 px while ungrouped flow spans 5,977 px.
+
+The legacy pipeline also places all decorative Note/Markdown/Label nodes in a
+left annotation column and then starts every group and ungrouped flow to the
+right of the widest annotation. Very wide documentation nodes can therefore
+create a large empty horizontal margin even though they carry no dataflow.
+
+Phase 3 now evaluates a separate decorative-compaction candidate for workflows
+that are already at least 7,000 px wide. It moves movable decorative annotations
+above the graph while preserving every non-decorative node/group coordinate.
+Because decorative nodes carry no graph ordering, this candidate does not alter
+crossings or RTL by itself and is still subject to the normal compactness gate.
+
+The structure reporter also prints decorative-node count/span and the widest
+decorative node so corpus measurements can confirm how much width comes from
+annotations versus actual flow geometry.
+
## Current Scope Boundary
The active engine now refines movable group internals and top-level group order,
From e64afede35b3aa287b0df6b3ae3bc0e988f3d074 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:09:06 +0200
Subject: [PATCH 043/105] Separate structural and decorative compaction
---
flowforge/layout_engine_v2_phase3.py | 136 ++++++++++++++++++---------
1 file changed, 91 insertions(+), 45 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase3.py b/flowforge/layout_engine_v2_phase3.py
index 547ecde..de65b21 100644
--- a/flowforge/layout_engine_v2_phase3.py
+++ b/flowforge/layout_engine_v2_phase3.py
@@ -63,51 +63,51 @@ def apply_best_layout(
wrap_columns=True,
)
- candidates: list[tuple[str, Workflow, EngineV2Score]] = []
-
- decorative_compact = deepcopy(baseline)
- if _compact_decorative_nodes_above(decorative_compact, compact_settings):
- candidates.append(
- (
- "decorative",
- decorative_compact,
- _score_engine_v2(decorative_compact),
- )
- )
+ structural_workflow = baseline
+ structural_score = baseline_score
+ structural_name = "baseline"
global_compact = deepcopy(baseline)
_compact_global_flow(global_compact, compact_settings)
_refine_group_level_order(global_compact, compact_settings)
_finalize_refinement(global_compact, compact_settings)
- _compact_decorative_nodes_above(global_compact, compact_settings)
- candidates.append(
- ("global", global_compact, _score_engine_v2(global_compact))
- )
+ global_score = _score_engine_v2(global_compact)
+ if _compact_candidate_is_better(global_score, baseline_score):
+ structural_workflow = global_compact
+ structural_score = global_score
+ structural_name = "global"
- best_workflow = baseline
- best_score = baseline_score
- best_name = "baseline"
- for name, candidate, candidate_score in candidates:
- if _compact_candidate_is_better(candidate_score, best_score):
- best_workflow = candidate
- best_score = candidate_score
- best_name = name
+ decorative_compact = deepcopy(structural_workflow)
+ if _compact_decorative_nodes_above(decorative_compact, compact_settings):
+ decorative_score = _score_engine_v2(decorative_compact)
+ if _decorative_candidate_is_better(decorative_score, structural_score):
+ logger.info(
+ "Compact %s+decorative candidate accepted: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings=%s, rtl=%s",
+ structural_name,
+ structural_score.width,
+ structural_score.height,
+ decorative_score.width,
+ decorative_score.height,
+ decorative_score.crossings,
+ decorative_score.right_to_left_links,
+ )
+ return decorative_compact
- if best_workflow is not baseline:
+ if structural_workflow is not baseline:
logger.info(
- "Compact %s candidate accepted: size %.0fx%.0f -> %.0fx%.0f, "
- "crossings %s -> %s, rtl %s -> %s",
- best_name,
+ "Compact global candidate accepted without decorative compaction: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s",
baseline_score.width,
baseline_score.height,
- best_score.width,
- best_score.height,
+ structural_score.width,
+ structural_score.height,
baseline_score.crossings,
- best_score.crossings,
+ structural_score.crossings,
baseline_score.right_to_left_links,
- best_score.right_to_left_links,
+ structural_score.right_to_left_links,
)
- return best_workflow
+ return structural_workflow
logger.info(
"Compact candidates rejected: baseline size %.0fx%.0f, crossings=%s, rtl=%s",
@@ -123,7 +123,7 @@ def _compact_decorative_nodes_above(
workflow: Workflow,
settings: LayoutSettings,
) -> bool:
- """Move wide decorative annotations above the graph instead of beside it."""
+ """Pack decorative annotations into rows above the non-decorative graph."""
decorative = [
node
for node in workflow.nodes.values()
@@ -138,21 +138,46 @@ def _compact_decorative_nodes_above(
return False
graph_left = min(node.x for node in graph_nodes)
+ graph_right = max(
+ node.x + _node_visual_width(node)
+ for node in graph_nodes
+ )
graph_top = min(node.y for node in graph_nodes)
- gap = max(settings.node_v_gap, settings.group_v_gap)
+ h_gap = max(settings.node_h_gap, 24.0)
+ v_gap = max(settings.node_v_gap, settings.group_v_gap)
+ widest_decorative = max(_node_visual_width(node) for node in decorative)
+ row_width_cap = max(graph_right - graph_left, widest_decorative)
+
+ rows: list[list[Node]] = [[]]
+ row_widths = [0.0]
+ row_heights = [0.0]
+ for node in sorted(decorative, key=lambda item: (item.y, item.x, item.id)):
+ node_width = _node_visual_width(node)
+ addition = node_width if not rows[-1] else node_width + h_gap
+ if rows[-1] and row_widths[-1] + addition > row_width_cap:
+ rows.append([])
+ row_widths.append(0.0)
+ row_heights.append(0.0)
+ addition = node_width
+ rows[-1].append(node)
+ row_widths[-1] += addition
+ row_heights[-1] = max(row_heights[-1], _node_visual_height(node))
+
+ total_height = sum(row_heights)
+ total_height += v_gap * max(0, len(rows) - 1)
+ current_y = graph_top - v_gap - total_height
+ changed = False
- ordered = sorted(decorative, key=lambda node: (node.y, node.x, node.id))
- total_height = sum(_node_visual_height(node) for node in ordered)
- total_height += gap * max(0, len(ordered) - 1)
+ for row, row_height in zip(rows, row_heights):
+ current_x = graph_left
+ for node in row:
+ if abs(node.x - current_x) > 1e-9 or abs(node.y - current_y) > 1e-9:
+ changed = True
+ node.x = current_x
+ node.y = current_y
+ current_x += _node_visual_width(node) + h_gap
+ current_y += row_height + v_gap
- current_y = graph_top - gap - total_height
- changed = False
- for node in ordered:
- if abs(node.x - graph_left) > 1e-9 or abs(node.y - current_y) > 1e-9:
- changed = True
- node.x = graph_left
- node.y = current_y
- current_y += _node_visual_height(node) + gap
return changed
@@ -176,6 +201,27 @@ def _compact_global_flow(workflow: Workflow, settings: LayoutSettings) -> None:
)
+def _decorative_candidate_is_better(
+ candidate: EngineV2Score,
+ structural: EngineV2Score,
+) -> bool:
+ """Accept decoration-only compaction without changing graph quality."""
+ if candidate.movable_overlaps != structural.movable_overlaps:
+ return False
+ if candidate.crossings != structural.crossings:
+ return False
+ if candidate.right_to_left_links != structural.right_to_left_links:
+ return False
+ if not math.isclose(candidate.link_length, structural.link_length, rel_tol=0.0, abs_tol=1e-6):
+ return False
+ if candidate.width >= structural.width:
+ return False
+
+ structural_area = structural.width * structural.height
+ candidate_area = candidate.width * candidate.height
+ return structural_area <= 0 or candidate_area < structural_area
+
+
def _compact_candidate_is_better(
candidate: EngineV2Score,
baseline: EngineV2Score,
From a37332f0fe10e7750da830c63789aeaee9bf8032 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:09:21 +0200
Subject: [PATCH 044/105] Test separated decorative compaction
---
tests/test_layout_engine_v2_phase3.py | 50 +++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/tests/test_layout_engine_v2_phase3.py b/tests/test_layout_engine_v2_phase3.py
index 4b968fe..0eec278 100644
--- a/tests/test_layout_engine_v2_phase3.py
+++ b/tests/test_layout_engine_v2_phase3.py
@@ -5,6 +5,7 @@
from flowforge.layout_engine_v2_phase3 import (
_compact_candidate_is_better,
_compact_decorative_nodes_above,
+ _decorative_candidate_is_better,
_compact_global_flow,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -122,3 +123,52 @@ def test_compact_decorative_nodes_move_above_graph_without_moving_graph():
assert (target.x, target.y) == target_before
assert note.x == source.x
assert note.y + note.size[1] < min(source.y, target.y)
+
+
+def test_decorative_candidate_requires_identical_graph_quality():
+ structural = _score(crossings=100, rtl=20, width=10_000, height=8_000)
+ decorative = EngineV2Score(
+ total=0.0,
+ crossings=100,
+ right_to_left_links=20,
+ movable_overlaps=0,
+ link_length=10_000.0,
+ width=7_000,
+ height=9_000,
+ )
+ regressed = EngineV2Score(
+ total=0.0,
+ crossings=101,
+ right_to_left_links=20,
+ movable_overlaps=0,
+ link_length=10_000.0,
+ width=6_000,
+ height=9_000,
+ )
+
+ assert _decorative_candidate_is_better(decorative, structural)
+ assert not _decorative_candidate_is_better(regressed, structural)
+
+
+def test_compact_decorative_nodes_pack_multiple_annotations_into_rows():
+ workflow = Workflow()
+ source = Node(id=10, type="Source", x=1000, y=1000, size=[600, 100])
+ target = Node(id=11, type="Target", x=2600, y=1000, size=[600, 100])
+ notes = [
+ Node(id=index, type="MarkdownNote", x=20, y=index * 100, size=[700, 120])
+ for index in range(1, 5)
+ ]
+ workflow.nodes = {source.id: source, target.id: target}
+ workflow.nodes.update({node.id: node for node in notes})
+ workflow.ungrouped_nodes = [*notes, source, target]
+
+ changed = _compact_decorative_nodes_above(
+ workflow,
+ LayoutSettings(node_x_distance=80, node_y_distance=80),
+ )
+
+ assert changed
+ distinct_y = {node.y for node in notes}
+ assert len(distinct_y) < len(notes)
+ assert max(node.x + node.size[0] for node in notes) <= target.x + target.size[0]
+ assert max(node.y + node.size[1] for node in notes) < source.y
From 243076cd153a08ea5e3c5591a921cda1f2c42d10 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:09:55 +0200
Subject: [PATCH 045/105] Fix compact decoration type import
---
flowforge/layout_engine_v2_phase3.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_phase3.py b/flowforge/layout_engine_v2_phase3.py
index de65b21..f5abd89 100644
--- a/flowforge/layout_engine_v2_phase3.py
+++ b/flowforge/layout_engine_v2_phase3.py
@@ -31,7 +31,7 @@
apply_best_layout as _apply_phase2_best_layout,
)
from .logger import setup_logger
-from .model import Workflow
+from .model import Node, Workflow
logger = setup_logger(__name__)
From d1e98eb82f2221d2c6fe739cc18fe0c09dc9afb7 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:16:48 +0200
Subject: [PATCH 046/105] Add conservative ungrouped flow compaction
---
flowforge/layout_engine_v2_phase4.py | 420 +++++++++++++++++++++++++++
1 file changed, 420 insertions(+)
create mode 100644 flowforge/layout_engine_v2_phase4.py
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
new file mode 100644
index 0000000..3f3126d
--- /dev/null
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -0,0 +1,420 @@
+"""Ungrouped-flow compaction for FlowForge layout engine v2.
+
+Phase 4 keeps the complete Phase 3 result as a baseline and tries to compact
+pure linked ungrouped components into a shared horizontal band. Components that
+are directly incident to groups or belong to established local-placement
+special cases remain untouched.
+
+The first Phase 4 gate is intentionally strict: no additional crossings, RTL
+links, or movable overlaps are allowed.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+
+from .layout import (
+ LayoutSettings,
+ _group_by_node_id,
+ _has_positive_bounding,
+ _is_control_source_node,
+ _is_decorative_node,
+ _is_pinned_node,
+ _is_terminal_text_preview_node,
+ _is_virtual_hub_node,
+ _node_visual_height,
+ _node_visual_width,
+ _wrap_layer_columns,
+)
+from .layout_engine_v2 import (
+ EngineV2Score,
+ _assign_scc_longest_path_layers,
+ _finalize_refinement,
+ _score_engine_v2,
+)
+from .layout_engine_v2_phase3 import apply_best_layout as _apply_phase3_best_layout
+from .logger import setup_logger
+from .model import Node, Workflow
+
+logger = setup_logger(__name__)
+
+UNGROUPED_COMPACT_MIN_COMPONENT_NODES = 3
+UNGROUPED_COMPACT_TARGET_WORKFLOW_RATIO = 0.62
+UNGROUPED_COMPACT_MIN_POTENTIAL_REDUCTION_RATIO = 0.10
+UNGROUPED_COMPACT_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO = 0.08
+UNGROUPED_COMPACT_MAX_AREA_RATIO = 1.0
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Run Phase 3, then try conservative pure-ungrouped compaction."""
+ settings = settings or LayoutSettings()
+ baseline = _apply_phase3_best_layout(workflow, settings, candidate_count)
+ baseline_score = _score_engine_v2(baseline)
+
+ compact = deepcopy(baseline)
+ if not _compact_pure_ungrouped_components(compact, settings, baseline_score.width):
+ return baseline
+
+ _finalize_refinement(compact, settings)
+ compact_score = _score_engine_v2(compact)
+ if _ungrouped_candidate_is_better(compact_score, baseline_score):
+ logger.info(
+ "Phase 4 ungrouped compaction accepted: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings=%s, rtl=%s",
+ baseline_score.width,
+ baseline_score.height,
+ compact_score.width,
+ compact_score.height,
+ compact_score.crossings,
+ compact_score.right_to_left_links,
+ )
+ return compact
+
+ logger.info(
+ "Phase 4 ungrouped compaction rejected: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s",
+ baseline_score.width,
+ baseline_score.height,
+ compact_score.width,
+ compact_score.height,
+ baseline_score.crossings,
+ compact_score.crossings,
+ baseline_score.right_to_left_links,
+ compact_score.right_to_left_links,
+ )
+ return baseline
+
+
+def _compact_pure_ungrouped_components(
+ workflow: Workflow,
+ settings: LayoutSettings,
+ workflow_width: float,
+) -> bool:
+ """Compact linked ungrouped components that do not touch group geometry."""
+ eligible = _eligible_ungrouped_nodes(workflow)
+ if len(eligible) < UNGROUPED_COMPACT_MIN_COMPONENT_NODES:
+ return False
+
+ eligible_by_id = {node.id: node for node in eligible}
+ adjacency: dict[int, list[int]] = {node_id: [] for node_id in eligible_by_id}
+ undirected: dict[int, set[int]] = {node_id: set() for node_id in eligible_by_id}
+
+ for link in workflow.links.values():
+ if link.source not in eligible_by_id or link.target not in eligible_by_id:
+ continue
+ adjacency[link.source].append(link.target)
+ undirected[link.source].add(link.target)
+ undirected[link.target].add(link.source)
+
+ components = _connected_components(undirected)
+ components = [
+ component
+ for component in components
+ if len(component) >= UNGROUPED_COMPACT_MIN_COMPONENT_NODES
+ and any(adjacency[node_id] for node_id in component)
+ ]
+ if not components:
+ return False
+
+ components.sort(
+ key=lambda component: _component_span(
+ [eligible_by_id[node_id] for node_id in component]
+ ),
+ reverse=True,
+ )
+
+ moved = False
+ for component in components:
+ nodes = [eligible_by_id[node_id] for node_id in component]
+ if _compact_component(
+ workflow,
+ nodes,
+ {node_id: adjacency[node_id] for node_id in component},
+ settings,
+ workflow_width,
+ ):
+ moved = True
+ return moved
+
+
+def _eligible_ungrouped_nodes(workflow: Workflow) -> list[Node]:
+ """Return movable ungrouped nodes safe for independent band compaction."""
+ group_by_node_id = _group_by_node_id(workflow)
+ result: list[Node] = []
+ for node in workflow.ungrouped_nodes:
+ if node.id not in workflow.nodes:
+ continue
+ if (
+ _is_pinned_node(workflow, node)
+ or _is_decorative_node(node)
+ or _is_virtual_hub_node(node)
+ or _is_control_source_node(workflow, node)
+ or _is_terminal_text_preview_node(node)
+ ):
+ continue
+ if _has_direct_group_incident_link(workflow, node, group_by_node_id):
+ continue
+ result.append(node)
+ return result
+
+
+def _has_direct_group_incident_link(
+ workflow: Workflow,
+ node: Node,
+ group_by_node_id,
+) -> bool:
+ for link_id in (*node.input_links, *node.output_links):
+ link = workflow.links.get(link_id)
+ if link is None:
+ continue
+ other_id = link.source if link.target == node.id else link.target
+ if other_id in group_by_node_id:
+ return True
+ return False
+
+
+def _connected_components(undirected: dict[int, set[int]]) -> list[set[int]]:
+ components: list[set[int]] = []
+ unseen = set(undirected)
+ while unseen:
+ start = min(unseen)
+ stack = [start]
+ component: set[int] = set()
+ while stack:
+ node_id = stack.pop()
+ if node_id in component:
+ continue
+ component.add(node_id)
+ unseen.discard(node_id)
+ stack.extend(sorted(undirected[node_id] - component, reverse=True))
+ components.append(component)
+ return components
+
+
+def _compact_component(
+ workflow: Workflow,
+ nodes: list[Node],
+ adjacency: dict[int, list[int]],
+ settings: LayoutSettings,
+ workflow_width: float,
+) -> bool:
+ node_ids = {node.id for node in nodes}
+ current_span = _component_span(nodes)
+ if current_span <= 0:
+ return False
+
+ layers = _assign_scc_longest_path_layers(node_ids, adjacency)
+ if len(set(layers.values())) <= 1:
+ return False
+
+ layer_to_nodes: dict[int, list[Node]] = {}
+ for node in nodes:
+ layer_to_nodes.setdefault(layers[node.id], []).append(node)
+
+ layer_sizes: dict[int, tuple[float, float]] = {}
+ for layer, layer_nodes in layer_to_nodes.items():
+ width = max(_node_visual_width(node) for node in layer_nodes)
+ height = sum(_node_visual_height(node) for node in layer_nodes)
+ height += settings.node_v_gap * max(0, len(layer_nodes) - 1)
+ layer_sizes[layer] = (width, height)
+
+ group_span = _group_span(workflow)
+ max_layer_width = max(width for width, _height in layer_sizes.values())
+ target_cap = max(
+ max_layer_width,
+ min(
+ current_span,
+ max(
+ group_span,
+ workflow_width * UNGROUPED_COMPACT_TARGET_WORKFLOW_RATIO,
+ ),
+ ),
+ )
+ potential_reduction = 1.0 - target_cap / current_span
+ if potential_reduction < UNGROUPED_COMPACT_MIN_POTENTIAL_REDUCTION_RATIO:
+ return False
+
+ graph_left = _graph_left(workflow)
+ component_top = min(node.y for node in nodes)
+ positions = _wrap_layer_columns(
+ layer_sizes,
+ target_cap,
+ graph_left,
+ component_top,
+ settings.node_h_gap,
+ settings.node_v_gap,
+ )
+
+ proposed: dict[int, tuple[float, float]] = {}
+ for layer in sorted(layer_to_nodes):
+ x, y = positions[layer]
+ for node in sorted(
+ layer_to_nodes[layer],
+ key=lambda item: (item.y, item.x, item.id),
+ ):
+ proposed[node.id] = (x, y)
+ y += _node_visual_height(node) + settings.node_v_gap
+
+ obstacles = _component_obstacles(workflow, node_ids)
+ vertical_shift = _resolve_component_vertical_shift(
+ workflow,
+ nodes,
+ proposed,
+ obstacles,
+ settings,
+ )
+
+ changed = False
+ for node in nodes:
+ x, y = proposed[node.id]
+ y += vertical_shift
+ if abs(node.x - x) > 1e-9 or abs(node.y - y) > 1e-9:
+ changed = True
+ node.x = x
+ node.y = y
+ return changed
+
+
+def _component_span(nodes: list[Node]) -> float:
+ if not nodes:
+ return 0.0
+ left = min(node.x for node in nodes)
+ right = max(node.x + _node_visual_width(node) for node in nodes)
+ return max(0.0, right - left)
+
+
+def _group_span(workflow: Workflow) -> float:
+ groups = [
+ group
+ for group in workflow.groups
+ if group.nodes and _has_positive_bounding(group)
+ ]
+ if not groups:
+ return 0.0
+ left = min(group.bounding[0] for group in groups)
+ right = max(group.bounding[0] + group.bounding[2] for group in groups)
+ return max(0.0, right - left)
+
+
+def _graph_left(workflow: Workflow) -> float:
+ candidates = [
+ group.bounding[0]
+ for group in workflow.groups
+ if group.nodes and _has_positive_bounding(group)
+ ]
+ candidates.extend(
+ node.x
+ for node in workflow.nodes.values()
+ if not _is_decorative_node(node)
+ )
+ return min(candidates, default=50.0)
+
+
+def _component_obstacles(
+ workflow: Workflow,
+ component_node_ids: set[int],
+) -> list[tuple[float, float, float, float]]:
+ rects: list[tuple[float, float, float, float]] = []
+ rects.extend(
+ (
+ group.bounding[0],
+ group.bounding[1],
+ group.bounding[0] + group.bounding[2],
+ group.bounding[1] + group.bounding[3],
+ )
+ for group in workflow.groups
+ if group.nodes and _has_positive_bounding(group)
+ )
+ rects.extend(
+ (
+ node.x,
+ node.y,
+ node.x + _node_visual_width(node),
+ node.y + _node_visual_height(node),
+ )
+ for node in workflow.nodes.values()
+ if node.id not in component_node_ids and not _is_decorative_node(node)
+ )
+ return rects
+
+
+def _resolve_component_vertical_shift(
+ workflow: Workflow,
+ nodes: list[Node],
+ proposed: dict[int, tuple[float, float]],
+ obstacles: list[tuple[float, float, float, float]],
+ settings: LayoutSettings,
+) -> float:
+ del workflow
+ shift = 0.0
+ gap = max(settings.node_v_gap, 12.0)
+
+ for _ in range(len(obstacles) + len(nodes) + 1):
+ required = shift
+ collision_found = False
+ for node in nodes:
+ x, y = proposed[node.id]
+ rect = (
+ x,
+ y + shift,
+ x + _node_visual_width(node),
+ y + shift + _node_visual_height(node),
+ )
+ for obstacle in obstacles:
+ if not _rects_overlap(rect, obstacle):
+ continue
+ collision_found = True
+ required = max(required, obstacle[3] + gap - y)
+ if not collision_found or required <= shift:
+ return shift
+ shift = required
+
+ return shift
+
+
+def _rects_overlap(
+ first: tuple[float, float, float, float],
+ second: tuple[float, float, float, float],
+) -> bool:
+ return not (
+ first[2] <= second[0]
+ or second[2] <= first[0]
+ or first[3] <= second[1]
+ or second[3] <= first[1]
+ )
+
+
+def _ungrouped_candidate_is_better(
+ candidate: EngineV2Score,
+ baseline: EngineV2Score,
+) -> bool:
+ """Require graph-quality preservation plus a real compactness gain."""
+ if candidate.movable_overlaps > baseline.movable_overlaps:
+ return False
+ if candidate.crossings > baseline.crossings:
+ return False
+ if candidate.right_to_left_links > baseline.right_to_left_links:
+ return False
+
+ if (
+ candidate.crossings < baseline.crossings
+ or candidate.right_to_left_links < baseline.right_to_left_links
+ ):
+ return True
+
+ if baseline.width <= 0:
+ return False
+ width_reduction = 1.0 - candidate.width / baseline.width
+ if width_reduction < UNGROUPED_COMPACT_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO:
+ return False
+
+ baseline_area = baseline.width * baseline.height
+ candidate_area = candidate.width * candidate.height
+ return (
+ baseline_area <= 0
+ or candidate_area <= baseline_area * UNGROUPED_COMPACT_MAX_AREA_RATIO
+ )
From 6a3ce45ef2e7cf14077e5d339ec59c69ca4f8c5c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:16:55 +0200
Subject: [PATCH 047/105] Use ungrouped flow compaction runtime
---
flowforge/layout_engine_v2_runtime.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
index e723331..0ae1fb2 100644
--- a/flowforge/layout_engine_v2_runtime.py
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -10,7 +10,7 @@
from __future__ import annotations
from .layout import LayoutSettings
-from .layout_engine_v2_phase3 import apply_best_layout
+from .layout_engine_v2_phase4 import apply_best_layout
from .model import Workflow
From f653dc2600086403a03cb20181b9baf7cd7d0fed Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:17:13 +0200
Subject: [PATCH 048/105] Test conservative ungrouped flow compaction
---
tests/test_layout_engine_v2_phase4.py | 160 ++++++++++++++++++++++++++
1 file changed, 160 insertions(+)
create mode 100644 tests/test_layout_engine_v2_phase4.py
diff --git a/tests/test_layout_engine_v2_phase4.py b/tests/test_layout_engine_v2_phase4.py
new file mode 100644
index 0000000..caef64b
--- /dev/null
+++ b/tests/test_layout_engine_v2_phase4.py
@@ -0,0 +1,160 @@
+"""Regression tests for conservative ungrouped-flow compaction."""
+
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import EngineV2Score
+from flowforge.layout_engine_v2_phase4 import (
+ _compact_pure_ungrouped_components,
+ _eligible_ungrouped_nodes,
+ _ungrouped_candidate_is_better,
+)
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _score(
+ *,
+ crossings: int,
+ rtl: int,
+ width: float,
+ height: float,
+ overlaps: int = 0,
+) -> EngineV2Score:
+ return EngineV2Score(
+ total=0.0,
+ crossings=crossings,
+ right_to_left_links=rtl,
+ movable_overlaps=overlaps,
+ link_length=10_000.0,
+ width=width,
+ height=height,
+ )
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def test_ungrouped_candidate_requires_crossing_and_rtl_preservation():
+ baseline = _score(crossings=100, rtl=20, width=6_000, height=4_000)
+ compact = _score(crossings=100, rtl=20, width=5_000, height=4_200)
+ crossing_regression = _score(crossings=101, rtl=20, width=4_000, height=4_000)
+ rtl_regression = _score(crossings=100, rtl=21, width=4_000, height=4_000)
+
+ assert _ungrouped_candidate_is_better(compact, baseline)
+ assert not _ungrouped_candidate_is_better(crossing_regression, baseline)
+ assert not _ungrouped_candidate_is_better(rtl_regression, baseline)
+
+
+def test_eligible_ungrouped_nodes_skip_direct_group_incident_nodes():
+ workflow = Workflow()
+ grouped = Node(id=1, type="Grouped", x=100, y=100, size=[200, 100])
+ pure_a = Node(id=2, type="PureA", x=2_000, y=100, size=[200, 100])
+ pure_b = Node(id=3, type="PureB", x=2_500, y=100, size=[200, 100])
+ incident = Node(id=4, type="Incident", x=3_000, y=100, size=[200, 100])
+ workflow.nodes = {
+ grouped.id: grouped,
+ pure_a.id: pure_a,
+ pure_b.id: pure_b,
+ incident.id: incident,
+ }
+ workflow.groups = [
+ Group(
+ id=10,
+ name="Grouped",
+ nodes=[grouped],
+ bounding=[50, 50, 300, 220],
+ )
+ ]
+ workflow.ungrouped_nodes = [pure_a, pure_b, incident]
+
+ _attach_link(
+ workflow,
+ Link(id=1, source=2, source_port=0, target=3, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=2, source=1, source_port=0, target=4, target_port=0, type="DATA"),
+ )
+
+ eligible_ids = {node.id for node in _eligible_ungrouped_nodes(workflow)}
+
+ assert eligible_ids == {2, 3}
+
+
+def test_compact_pure_ungrouped_chain_reuses_group_horizontal_band():
+ workflow = Workflow()
+ grouped = Node(id=1, type="Grouped", x=100, y=100, size=[400, 150])
+ workflow.nodes[grouped.id] = grouped
+ workflow.groups = [
+ Group(
+ id=10,
+ name="Main",
+ nodes=[grouped],
+ bounding=[50, 50, 3_000, 600],
+ )
+ ]
+
+ chain = []
+ for index in range(6):
+ node_id = index + 2
+ node = Node(
+ id=node_id,
+ type=f"Node{node_id}",
+ x=3_500 + index * 700,
+ y=100,
+ size=[300, 120],
+ input_count=1 if index else 0,
+ output_count=1 if index < 5 else 0,
+ )
+ workflow.nodes[node_id] = node
+ chain.append(node)
+ workflow.ungrouped_nodes = list(chain)
+
+ for index in range(5):
+ _attach_link(
+ workflow,
+ Link(
+ id=index + 1,
+ source=chain[index].id,
+ source_port=0,
+ target=chain[index + 1].id,
+ target_port=0,
+ type="DATA",
+ ),
+ )
+
+ original_span = (
+ max(node.x + node.size[0] for node in chain)
+ - min(node.x for node in chain)
+ )
+
+ changed = _compact_pure_ungrouped_components(
+ workflow,
+ LayoutSettings(node_x_distance=100, node_y_distance=80, wrap_columns=True),
+ workflow_width=7_000,
+ )
+
+ compact_span = (
+ max(node.x + node.size[0] for node in chain)
+ - min(node.x for node in chain)
+ )
+
+ assert changed
+ assert compact_span < original_span
+ assert compact_span <= 4_500
+ group = workflow.groups[0]
+ group_rect = (
+ group.bounding[0],
+ group.bounding[1],
+ group.bounding[0] + group.bounding[2],
+ group.bounding[1] + group.bounding[3],
+ )
+ for node in chain:
+ node_rect = (node.x, node.y, node.x + node.size[0], node.y + node.size[1])
+ assert (
+ node_rect[2] <= group_rect[0]
+ or group_rect[2] <= node_rect[0]
+ or node_rect[3] <= group_rect[1]
+ or group_rect[3] <= node_rect[1]
+ )
From e6277a4900868f74e022dcf95e811a5f69248ed6 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:17:51 +0200
Subject: [PATCH 049/105] Exercise meaningful ungrouped width reduction
---
tests/test_layout_engine_v2_phase4.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_layout_engine_v2_phase4.py b/tests/test_layout_engine_v2_phase4.py
index caef64b..df4163b 100644
--- a/tests/test_layout_engine_v2_phase4.py
+++ b/tests/test_layout_engine_v2_phase4.py
@@ -101,7 +101,7 @@ def test_compact_pure_ungrouped_chain_reuses_group_horizontal_band():
node = Node(
id=node_id,
type=f"Node{node_id}",
- x=3_500 + index * 700,
+ x=3_500 + index * 1_000,
y=100,
size=[300, 120],
input_count=1 if index else 0,
From d34594e53ce9c68d1e66075af1d1c817f944003e Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:18:34 +0200
Subject: [PATCH 050/105] Document Phase 4 ungrouped compaction
---
docs/LAYOUT_ENGINE_V2.md | 64 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 29a9ac2..edef51f 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -316,6 +316,70 @@ The structure reporter also prints decorative-node count/span and the widest
decorative node so corpus measurements can confirm how much width comes from
annotations versus actual flow geometry.
+## Phase 3 final compactness result
+
+After separating structural candidate selection from decorative compaction, the
+Optimize + Layout corpus returned to the structurally better result:
+
+- crossings: `4,127`;
+- RTL links: `339`.
+
+Decorative compaction then reduced large workflow dimensions without changing
+crossings, RTL, or physical link length. Representative results:
+
+- Flux Edit Ultra: `18,288 x 13,211` -> `10,549 x 14,833`;
+- Z-Image Base Ultra: `18,934 x 12,228` -> `10,364 x 14,076`;
+- Luneva: `13,200 x 16,536` -> `11,108 x 10,610`;
+- Qwen Edit: `13,024 x 7,967` -> `7,985 x 9,527`.
+
+The remaining wide workflows are now dominated by actual ungrouped flow rather
+than decorative margins:
+
+- Amazing Z-Image: ungrouped span `12,220` px across 20 flow layers / 43 columns;
+- Jibs: ungrouped span `5,977` px while grouped span is only `3,090` px;
+- several Flux2 VTON workflows also have ungrouped spans larger than their
+ grouped spans.
+
+## Phase 4 conservative ungrouped-flow compaction
+
+Phase 4 is an isolated post-process on top of the complete Phase 3 result.
+
+The first implementation deliberately targets only linked ungrouped components
+that are safe to move independently. It excludes:
+
+- pinned nodes;
+- decorative nodes;
+- virtual Set/Get hubs;
+- local primitive/control sources;
+- terminal text previews;
+- nodes with a direct physical link to a group.
+
+Remaining pure ungrouped components are SCC-layered, wrapped into a bounded
+horizontal band, and moved vertically only when needed to clear existing group
+and node geometry.
+
+The width target for a component is constrained by:
+
+- its widest layer;
+- the current grouped horizontal span;
+- 62% of the current workflow width.
+
+Components are skipped unless this predicts at least a 10% horizontal
+reduction.
+
+The Phase 4 acceptance gate is intentionally stricter than earlier compactness
+passes:
+
+- movable overlaps may not increase;
+- crossings may not increase;
+- RTL links may not increase;
+- if graph quality is unchanged, workflow width must fall by at least 8%;
+- workflow area may not increase.
+
+This preserves Phase 3 as a safe fallback and lets the corpus show whether
+pure-ungrouped band compaction is useful before attempting a more invasive mixed
+group/ungrouped global layer model.
+
## Current Scope Boundary
The active engine now refines movable group internals and top-level group order,
From fdd8aa123ad62761a4592f52ed0f0a7a31e4fb75 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:46:28 +0200
Subject: [PATCH 051/105] Explain Phase 4 compaction decisions
---
flowforge/layout_engine_v2_phase4.py | 275 +++++++++++++++++++++++++--
1 file changed, 263 insertions(+), 12 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
index 3f3126d..6a467b4 100644
--- a/flowforge/layout_engine_v2_phase4.py
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -12,6 +12,7 @@
from __future__ import annotations
from copy import deepcopy
+from dataclasses import dataclass
from .layout import (
LayoutSettings,
@@ -45,6 +46,37 @@
UNGROUPED_COMPACT_MAX_AREA_RATIO = 1.0
+@dataclass(frozen=True)
+class Phase4Diagnostics:
+ """Explain whether conservative ungrouped compaction can affect a workflow."""
+
+ total_ungrouped: int
+ eligible_nodes: int
+ excluded_pinned: int
+ excluded_decorative: int
+ excluded_virtual_hub: int
+ excluded_control: int
+ excluded_text_preview: int
+ excluded_direct_group: int
+ linked_components: int
+ candidate_components: int
+ compactable_components: int
+ largest_component_nodes: int
+ largest_component_span: float
+ max_potential_reduction: float
+ attempted: bool
+ accepted: bool
+ rejection_reason: str
+ baseline_width: float
+ baseline_height: float
+ proposed_width: float | None
+ proposed_height: float | None
+ baseline_crossings: int
+ proposed_crossings: int | None
+ baseline_rtl: int
+ proposed_rtl: int | None
+
+
def apply_best_layout(
workflow: Workflow,
settings: LayoutSettings | None = None,
@@ -89,26 +121,241 @@ def apply_best_layout(
return baseline
-def _compact_pure_ungrouped_components(
+def diagnose_phase4(
workflow: Workflow,
- settings: LayoutSettings,
- workflow_width: float,
-) -> bool:
- """Compact linked ungrouped components that do not touch group geometry."""
+ settings: LayoutSettings | None = None,
+) -> Phase4Diagnostics:
+ """Return a read-only explanation of the Phase 4 decision for one layout."""
+ settings = settings or LayoutSettings()
+ baseline_score = _score_engine_v2(workflow)
+ counts = _phase4_exclusion_counts(workflow)
eligible = _eligible_ungrouped_nodes(workflow)
- if len(eligible) < UNGROUPED_COMPACT_MIN_COMPONENT_NODES:
- return False
-
eligible_by_id = {node.id: node for node in eligible}
+ adjacency, undirected = _eligible_graph(workflow, eligible_by_id)
+
+ linked_components = [
+ component
+ for component in _connected_components(undirected)
+ if any(adjacency[node_id] for node_id in component)
+ ]
+ candidate_components = [
+ component
+ for component in linked_components
+ if len(component) >= UNGROUPED_COMPACT_MIN_COMPONENT_NODES
+ ]
+
+ component_rows: list[tuple[set[int], float, float]] = []
+ for component in candidate_components:
+ nodes = [eligible_by_id[node_id] for node_id in component]
+ span = _component_span(nodes)
+ potential = _component_potential_reduction(
+ workflow,
+ nodes,
+ {node_id: adjacency[node_id] for node_id in component},
+ baseline_score.width,
+ )
+ component_rows.append((component, span, potential))
+
+ compactable = [
+ row
+ for row in component_rows
+ if row[2] >= UNGROUPED_COMPACT_MIN_POTENTIAL_REDUCTION_RATIO
+ ]
+ largest = max(component_rows, key=lambda row: row[1], default=(set(), 0.0, 0.0))
+ max_potential = max((row[2] for row in component_rows), default=0.0)
+
+ compact = deepcopy(workflow)
+ moved = _compact_pure_ungrouped_components(
+ compact,
+ settings,
+ baseline_score.width,
+ )
+ if not moved:
+ if len(eligible) < UNGROUPED_COMPACT_MIN_COMPONENT_NODES:
+ reason = "too_few_eligible_nodes"
+ elif not candidate_components:
+ reason = "no_linked_component_with_min_nodes"
+ elif not compactable:
+ reason = "no_component_meets_potential_reduction_threshold"
+ else:
+ reason = "no_component_moved"
+ return Phase4Diagnostics(
+ total_ungrouped=len(workflow.ungrouped_nodes),
+ eligible_nodes=len(eligible),
+ linked_components=len(linked_components),
+ candidate_components=len(candidate_components),
+ compactable_components=len(compactable),
+ largest_component_nodes=len(largest[0]),
+ largest_component_span=largest[1],
+ max_potential_reduction=max_potential,
+ attempted=False,
+ accepted=False,
+ rejection_reason=reason,
+ baseline_width=baseline_score.width,
+ baseline_height=baseline_score.height,
+ proposed_width=None,
+ proposed_height=None,
+ baseline_crossings=baseline_score.crossings,
+ proposed_crossings=None,
+ baseline_rtl=baseline_score.right_to_left_links,
+ proposed_rtl=None,
+ **counts,
+ )
+
+ _finalize_refinement(compact, settings)
+ compact_score = _score_engine_v2(compact)
+ accepted = _ungrouped_candidate_is_better(compact_score, baseline_score)
+ return Phase4Diagnostics(
+ total_ungrouped=len(workflow.ungrouped_nodes),
+ eligible_nodes=len(eligible),
+ linked_components=len(linked_components),
+ candidate_components=len(candidate_components),
+ compactable_components=len(compactable),
+ largest_component_nodes=len(largest[0]),
+ largest_component_span=largest[1],
+ max_potential_reduction=max_potential,
+ attempted=True,
+ accepted=accepted,
+ rejection_reason=(
+ "accepted"
+ if accepted
+ else _ungrouped_rejection_reason(compact_score, baseline_score)
+ ),
+ baseline_width=baseline_score.width,
+ baseline_height=baseline_score.height,
+ proposed_width=compact_score.width,
+ proposed_height=compact_score.height,
+ baseline_crossings=baseline_score.crossings,
+ proposed_crossings=compact_score.crossings,
+ baseline_rtl=baseline_score.right_to_left_links,
+ proposed_rtl=compact_score.right_to_left_links,
+ **counts,
+ )
+
+
+def _phase4_exclusion_counts(workflow: Workflow) -> dict[str, int]:
+ group_by_node_id = _group_by_node_id(workflow)
+ counts = {
+ "excluded_pinned": 0,
+ "excluded_decorative": 0,
+ "excluded_virtual_hub": 0,
+ "excluded_control": 0,
+ "excluded_text_preview": 0,
+ "excluded_direct_group": 0,
+ }
+ for node in workflow.ungrouped_nodes:
+ if node.id not in workflow.nodes:
+ continue
+ if _is_pinned_node(workflow, node):
+ counts["excluded_pinned"] += 1
+ elif _is_decorative_node(node):
+ counts["excluded_decorative"] += 1
+ elif _is_virtual_hub_node(node):
+ counts["excluded_virtual_hub"] += 1
+ elif _is_control_source_node(workflow, node):
+ counts["excluded_control"] += 1
+ elif _is_terminal_text_preview_node(node):
+ counts["excluded_text_preview"] += 1
+ elif _has_direct_group_incident_link(workflow, node, group_by_node_id):
+ counts["excluded_direct_group"] += 1
+ return counts
+
+
+def _eligible_graph(
+ workflow: Workflow,
+ eligible_by_id: dict[int, Node],
+) -> tuple[dict[int, list[int]], dict[int, set[int]]]:
adjacency: dict[int, list[int]] = {node_id: [] for node_id in eligible_by_id}
undirected: dict[int, set[int]] = {node_id: set() for node_id in eligible_by_id}
-
for link in workflow.links.values():
if link.source not in eligible_by_id or link.target not in eligible_by_id:
continue
adjacency[link.source].append(link.target)
undirected[link.source].add(link.target)
undirected[link.target].add(link.source)
+ return adjacency, undirected
+
+
+def _component_potential_reduction(
+ workflow: Workflow,
+ nodes: list[Node],
+ adjacency: dict[int, list[int]],
+ workflow_width: float,
+) -> float:
+ current_span = _component_span(nodes)
+ if current_span <= 0:
+ return 0.0
+
+ layers = _assign_scc_longest_path_layers({node.id for node in nodes}, adjacency)
+ if len(set(layers.values())) <= 1:
+ return 0.0
+
+ layer_widths: dict[int, float] = {}
+ for node in nodes:
+ layer = layers[node.id]
+ layer_widths[layer] = max(
+ layer_widths.get(layer, 0.0),
+ _node_visual_width(node),
+ )
+
+ max_layer_width = max(layer_widths.values(), default=0.0)
+ target_cap = max(
+ max_layer_width,
+ min(
+ current_span,
+ max(
+ _group_span(workflow),
+ workflow_width * UNGROUPED_COMPACT_TARGET_WORKFLOW_RATIO,
+ ),
+ ),
+ )
+ return max(0.0, 1.0 - target_cap / current_span)
+
+
+def _ungrouped_rejection_reason(
+ candidate: EngineV2Score,
+ baseline: EngineV2Score,
+) -> str:
+ if candidate.movable_overlaps > baseline.movable_overlaps:
+ return "movable_overlap_regression"
+ if candidate.crossings > baseline.crossings:
+ return "crossing_regression"
+ if candidate.right_to_left_links > baseline.right_to_left_links:
+ return "rtl_regression"
+ if (
+ candidate.crossings < baseline.crossings
+ or candidate.right_to_left_links < baseline.right_to_left_links
+ ):
+ return "accepted_quality_gain"
+
+ if baseline.width <= 0:
+ return "invalid_baseline_width"
+ width_reduction = 1.0 - candidate.width / baseline.width
+ if width_reduction < UNGROUPED_COMPACT_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO:
+ return "insufficient_final_width_reduction"
+
+ baseline_area = baseline.width * baseline.height
+ candidate_area = candidate.width * candidate.height
+ if (
+ baseline_area > 0
+ and candidate_area > baseline_area * UNGROUPED_COMPACT_MAX_AREA_RATIO
+ ):
+ return "area_regression"
+ return "accepted"
+
+
+def _compact_pure_ungrouped_components(
+ workflow: Workflow,
+ settings: LayoutSettings,
+ workflow_width: float,
+) -> bool:
+ """Compact linked ungrouped components that do not touch group geometry."""
+ eligible = _eligible_ungrouped_nodes(workflow)
+ if len(eligible) < UNGROUPED_COMPACT_MIN_COMPONENT_NODES:
+ return False
+
+ eligible_by_id = {node.id: node for node in eligible}
+ adjacency, undirected = _eligible_graph(workflow, eligible_by_id)
components = _connected_components(undirected)
components = [
@@ -222,19 +469,23 @@ def _compact_component(
height += settings.node_v_gap * max(0, len(layer_nodes) - 1)
layer_sizes[layer] = (width, height)
- group_span = _group_span(workflow)
max_layer_width = max(width for width, _height in layer_sizes.values())
target_cap = max(
max_layer_width,
min(
current_span,
max(
- group_span,
+ _group_span(workflow),
workflow_width * UNGROUPED_COMPACT_TARGET_WORKFLOW_RATIO,
),
),
)
- potential_reduction = 1.0 - target_cap / current_span
+ potential_reduction = _component_potential_reduction(
+ workflow,
+ nodes,
+ adjacency,
+ workflow_width,
+ )
if potential_reduction < UNGROUPED_COMPACT_MIN_POTENTIAL_REDUCTION_RATIO:
return False
From 7d2cdac31a9bfdbdf041329ca83452bd7687bb6d Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:46:42 +0200
Subject: [PATCH 052/105] Add targeted Phase 4 diagnostics
---
tools/report_phase4_diagnostics.py | 91 ++++++++++++++++++++++++++++++
1 file changed, 91 insertions(+)
create mode 100644 tools/report_phase4_diagnostics.py
diff --git a/tools/report_phase4_diagnostics.py b/tools/report_phase4_diagnostics.py
new file mode 100644
index 0000000..7adfdd1
--- /dev/null
+++ b/tools/report_phase4_diagnostics.py
@@ -0,0 +1,91 @@
+"""Targeted diagnostics for Phase 4 ungrouped-flow compaction."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from flowforge.layout_engine_v2_phase3 import apply_best_layout as apply_phase3_layout
+from flowforge.layout_engine_v2_phase4 import diagnose_phase4
+from flowforge.optimizer import optimize as optimize_workflow
+from flowforge.parser import parse_comfyui_workflow
+from flowforge.workflow_validation import discover_workflow_files
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Explain why Phase 4 accepts or rejects selected workflows."
+ )
+ parser.add_argument("root", type=Path, help="Workflow corpus root.")
+ parser.add_argument(
+ "--match",
+ action="append",
+ default=[],
+ help="Case-insensitive substring filter; may be repeated.",
+ )
+ parser.add_argument(
+ "--optimize",
+ action="store_true",
+ help="Run FlowForge Optimize before the Phase 3 layout baseline.",
+ )
+ args = parser.parse_args()
+
+ files = discover_workflow_files(args.root)
+ matches = [value.casefold() for value in args.match]
+ selected = [
+ path
+ for path in files
+ if not matches
+ or any(value in str(path.relative_to(args.root)).casefold() for value in matches)
+ ]
+
+ if not selected:
+ print("No matching workflow files found.")
+ return 1
+
+ for path in selected:
+ relative = path.relative_to(args.root)
+ data = json.loads(path.read_text(encoding="utf-8"))
+ workflow = parse_comfyui_workflow(data)
+ if args.optimize:
+ workflow = optimize_workflow(workflow)
+ baseline = apply_phase3_layout(workflow)
+ diag = diagnose_phase4(baseline)
+
+ print(relative)
+ print(
+ " ungrouped: "
+ f"total={diag.total_ungrouped} eligible={diag.eligible_nodes} "
+ f"excluded[pinned={diag.excluded_pinned}, deco={diag.excluded_decorative}, "
+ f"hub={diag.excluded_virtual_hub}, control={diag.excluded_control}, "
+ f"preview={diag.excluded_text_preview}, direct_group={diag.excluded_direct_group}]"
+ )
+ print(
+ " components: "
+ f"linked={diag.linked_components} candidates={diag.candidate_components} "
+ f"compactable={diag.compactable_components} "
+ f"largest_nodes={diag.largest_component_nodes} "
+ f"largest_span={diag.largest_component_span:.0f} "
+ f"max_potential={diag.max_potential_reduction * 100:.1f}%"
+ )
+ if diag.attempted:
+ print(
+ " proposal: "
+ f"{diag.baseline_width:.0f}x{diag.baseline_height:.0f} -> "
+ f"{diag.proposed_width:.0f}x{diag.proposed_height:.0f}; "
+ f"crossings={diag.baseline_crossings}->{diag.proposed_crossings}; "
+ f"rtl={diag.baseline_rtl}->{diag.proposed_rtl}; "
+ f"accepted={diag.accepted}; reason={diag.rejection_reason}"
+ )
+ else:
+ print(
+ " proposal: not attempted; "
+ f"reason={diag.rejection_reason}"
+ )
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 83c36394d0a6cd549c661768b8c7051dce9ca945 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:50:51 +0200
Subject: [PATCH 053/105] Allow group-incident nodes in Phase 4 candidates
---
flowforge/layout_engine_v2_phase4.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
index 6a467b4..a219942 100644
--- a/flowforge/layout_engine_v2_phase4.py
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -389,7 +389,7 @@ def _compact_pure_ungrouped_components(
def _eligible_ungrouped_nodes(workflow: Workflow) -> list[Node]:
- """Return movable ungrouped nodes safe for independent band compaction."""
+ """Return movable ungrouped nodes safe for candidate band compaction."""
group_by_node_id = _group_by_node_id(workflow)
result: list[Node] = []
for node in workflow.ungrouped_nodes:
@@ -403,8 +403,10 @@ def _eligible_ungrouped_nodes(workflow: Workflow) -> list[Node]:
or _is_terminal_text_preview_node(node)
):
continue
- if _has_direct_group_incident_link(workflow, node, group_by_node_id):
- continue
+ # Direct group incidence is allowed in Phase 4. Those nodes are part of
+ # the mixed group/ungrouped flow that causes the remaining width
+ # regressions. Groups themselves remain fixed obstacles, and the global
+ # acceptance gate rejects any crossing/RTL regression.
result.append(node)
return result
From 4c3814e08a629feff270084bf9f50cff9e8f2e49 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:51:03 +0200
Subject: [PATCH 054/105] Clarify Phase 4 group incidence diagnostics
---
flowforge/layout_engine_v2_phase4.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
index a219942..4bc7696 100644
--- a/flowforge/layout_engine_v2_phase4.py
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -57,7 +57,7 @@ class Phase4Diagnostics:
excluded_virtual_hub: int
excluded_control: int
excluded_text_preview: int
- excluded_direct_group: int
+ direct_group_nodes: int
linked_components: int
candidate_components: int
compactable_components: int
@@ -241,7 +241,7 @@ def _phase4_exclusion_counts(workflow: Workflow) -> dict[str, int]:
"excluded_virtual_hub": 0,
"excluded_control": 0,
"excluded_text_preview": 0,
- "excluded_direct_group": 0,
+ "direct_group_nodes": 0,
}
for node in workflow.ungrouped_nodes:
if node.id not in workflow.nodes:
@@ -257,7 +257,7 @@ def _phase4_exclusion_counts(workflow: Workflow) -> dict[str, int]:
elif _is_terminal_text_preview_node(node):
counts["excluded_text_preview"] += 1
elif _has_direct_group_incident_link(workflow, node, group_by_node_id):
- counts["excluded_direct_group"] += 1
+ counts["direct_group_nodes"] += 1
return counts
From d1443bf11b3bcc024bb864d011aa211336b58a58 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:51:10 +0200
Subject: [PATCH 055/105] Report Phase 4 group incidence separately
---
tools/report_phase4_diagnostics.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tools/report_phase4_diagnostics.py b/tools/report_phase4_diagnostics.py
index 7adfdd1..11e41ad 100644
--- a/tools/report_phase4_diagnostics.py
+++ b/tools/report_phase4_diagnostics.py
@@ -59,7 +59,8 @@ def main() -> int:
f"total={diag.total_ungrouped} eligible={diag.eligible_nodes} "
f"excluded[pinned={diag.excluded_pinned}, deco={diag.excluded_decorative}, "
f"hub={diag.excluded_virtual_hub}, control={diag.excluded_control}, "
- f"preview={diag.excluded_text_preview}, direct_group={diag.excluded_direct_group}]"
+ f"preview={diag.excluded_text_preview}]; "
+ f"direct_group={diag.direct_group_nodes}"
)
print(
" components: "
From 61c85f160efe7a6e3838aba4ec3cf09db1fd9c25 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:51:19 +0200
Subject: [PATCH 056/105] Test group-incident Phase 4 eligibility
---
tests/test_layout_engine_v2_phase4.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/test_layout_engine_v2_phase4.py b/tests/test_layout_engine_v2_phase4.py
index df4163b..702c333 100644
--- a/tests/test_layout_engine_v2_phase4.py
+++ b/tests/test_layout_engine_v2_phase4.py
@@ -46,7 +46,7 @@ def test_ungrouped_candidate_requires_crossing_and_rtl_preservation():
assert not _ungrouped_candidate_is_better(rtl_regression, baseline)
-def test_eligible_ungrouped_nodes_skip_direct_group_incident_nodes():
+def test_eligible_ungrouped_nodes_include_direct_group_incident_nodes():
workflow = Workflow()
grouped = Node(id=1, type="Grouped", x=100, y=100, size=[200, 100])
pure_a = Node(id=2, type="PureA", x=2_000, y=100, size=[200, 100])
@@ -79,7 +79,7 @@ def test_eligible_ungrouped_nodes_skip_direct_group_incident_nodes():
eligible_ids = {node.id for node in _eligible_ungrouped_nodes(workflow)}
- assert eligible_ids == {2, 3}
+ assert eligible_ids == {2, 3, 4}
def test_compact_pure_ungrouped_chain_reuses_group_horizontal_band():
From e6decb72bf9d0a80c1bcf5d83edcc35fdc8e70db Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:52:08 +0200
Subject: [PATCH 057/105] Remove stale Phase 4 eligibility variable
---
flowforge/layout_engine_v2_phase4.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
index 4bc7696..d8da986 100644
--- a/flowforge/layout_engine_v2_phase4.py
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -390,7 +390,6 @@ def _compact_pure_ungrouped_components(
def _eligible_ungrouped_nodes(workflow: Workflow) -> list[Node]:
"""Return movable ungrouped nodes safe for candidate band compaction."""
- group_by_node_id = _group_by_node_id(workflow)
result: list[Node] = []
for node in workflow.ungrouped_nodes:
if node.id not in workflow.nodes:
From 19d44eeb9da9b42d2f880f958b309f885e1888a0 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:52:36 +0200
Subject: [PATCH 058/105] Test Phase 4 mixed-flow diagnostics
---
tests/test_layout_engine_v2_phase4.py | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/tests/test_layout_engine_v2_phase4.py b/tests/test_layout_engine_v2_phase4.py
index 702c333..5e4d8ad 100644
--- a/tests/test_layout_engine_v2_phase4.py
+++ b/tests/test_layout_engine_v2_phase4.py
@@ -5,6 +5,7 @@
from flowforge.layout_engine_v2_phase4 import (
_compact_pure_ungrouped_components,
_eligible_ungrouped_nodes,
+ _phase4_exclusion_counts,
_ungrouped_candidate_is_better,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -158,3 +159,24 @@ def test_compact_pure_ungrouped_chain_reuses_group_horizontal_band():
or node_rect[3] <= group_rect[1]
or group_rect[3] <= node_rect[1]
)
+
+
+def test_phase4_diagnostics_report_group_incidence_without_excluding_it():
+ workflow = Workflow()
+ grouped = Node(id=1, type="Grouped", x=100, y=100, size=[200, 100])
+ incident = Node(id=2, type="Incident", x=500, y=100, size=[200, 100])
+ workflow.nodes = {1: grouped, 2: incident}
+ workflow.groups = [
+ Group(id=10, name="Grouped", nodes=[grouped], bounding=[50, 50, 300, 220])
+ ]
+ workflow.ungrouped_nodes = [incident]
+ _attach_link(
+ workflow,
+ Link(id=1, source=1, source_port=0, target=2, target_port=0, type="DATA"),
+ )
+
+ counts = _phase4_exclusion_counts(workflow)
+ eligible_ids = {node.id for node in _eligible_ungrouped_nodes(workflow)}
+
+ assert counts["direct_group_nodes"] == 1
+ assert incident.id in eligible_ids
From b6628aaf9c7e66169928d9ca3ff5b6ff477354ba Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:53:02 +0200
Subject: [PATCH 059/105] Document Phase 4 mixed-flow diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 39 ++++++++++++++++++++++++++++++++++-----
1 file changed, 34 insertions(+), 5 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index edef51f..31c04fe 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -344,17 +344,25 @@ than decorative margins:
Phase 4 is an isolated post-process on top of the complete Phase 3 result.
-The first implementation deliberately targets only linked ungrouped components
-that are safe to move independently. It excludes:
+The first implementation started with only linked ungrouped components that
+were safe to move independently. Corpus diagnostics showed that this was too
+conservative: the remaining width hotspots are mixed group/ungrouped flows, so
+excluding every node with a direct physical group link fragmented Jibs and the
+Flux2 VTON workflows into useless islands.
+
+Phase 4 therefore keeps these exclusions:
- pinned nodes;
- decorative nodes;
- virtual Set/Get hubs;
- local primitive/control sources;
-- terminal text previews;
-- nodes with a direct physical link to a group.
+- terminal text previews.
+
+Direct group incidence is now allowed. Groups themselves remain fixed
+obstacles/anchors, and the candidate is still rejected globally if crossings,
+RTL links, or movable overlaps increase.
-Remaining pure ungrouped components are SCC-layered, wrapped into a bounded
+Eligible mixed-flow components are SCC-layered, wrapped into a bounded
horizontal band, and moved vertically only when needed to clear existing group
and node geometry.
@@ -380,6 +388,27 @@ This preserves Phase 3 as a safe fallback and lets the corpus show whether
pure-ungrouped band compaction is useful before attempting a more invasive mixed
group/ungrouped global layer model.
+### Phase 4 diagnostic findings
+
+The first corpus run produced no accepted Phase 4 changes. Targeted diagnostics
+showed why:
+
+- Jibs: 25 ungrouped nodes, but only 6 remained eligible after excluding
+ Set/Get hubs, controls, previews, and direct group incidence; those 6 formed
+ no linked component;
+- Flux2 VTON 6.0.2: 7 of 9 ungrouped nodes were directly group-incident, leaving
+ only one eligible node;
+- Flux2 VTON 6.0.2.7: the remaining 3-node pure component already fit inside the
+ target width band, so it offered 0% potential reduction;
+- Amazing Z-Image: all 114 ungrouped nodes are explicitly pinned in the source
+ workflow. The parser maps ComfyUI `flags.pinned` directly, so this is a real
+ hard layout constraint rather than a parser artifact.
+
+The Phase 4 experiment therefore now permits direct group incidence while still
+respecting pinned nodes and the established local-placement special cases.
+Amazing Z-Image is intentionally not compacted unless the product later gains an
+explicit user option to ignore authored pins.
+
## Current Scope Boundary
The active engine now refines movable group internals and top-level group order,
From 76302e4a381a9e1b1f9dc7b9479b117d350d3d07 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:25:01 +0200
Subject: [PATCH 060/105] Tighten Phase 4 mixed-flow diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 50 ++++++++--
flowforge/layout_engine_v2_phase4.py | 135 ++++++++++++++++++++++----
tests/test_layout_engine_v2_phase4.py | 76 ++++++++++++++-
tools/report_phase4_diagnostics.py | 7 ++
4 files changed, 242 insertions(+), 26 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 31c04fe..e06a644 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -362,9 +362,11 @@ Direct group incidence is now allowed. Groups themselves remain fixed
obstacles/anchors, and the candidate is still rejected globally if crossings,
RTL links, or movable overlaps increase.
-Eligible mixed-flow components are SCC-layered, wrapped into a bounded
+Eligible ungrouped components are SCC-layered, wrapped into a bounded
horizontal band, and moved vertically only when needed to clear existing group
-and node geometry.
+and node geometry. Direct group incidence no longer excludes a node, but the
+active compaction graph still connects only eligible ungrouped nodes to each
+other; groups are not yet structural vertices in the placement pass.
The width target for a component is constrained by:
@@ -381,9 +383,13 @@ passes:
- movable overlaps may not increase;
- crossings may not increase;
- RTL links may not increase;
-- if graph quality is unchanged, workflow width must fall by at least 8%;
+- workflow width must fall by at least 8%;
- workflow area may not increase.
+Crossing or RTL improvements do not bypass the width or area requirements. This
+keeps Phase 4 a compaction pass rather than allowing a structurally better but
+nearly equally wide candidate to enter through a separate acceptance path.
+
This preserves Phase 3 as a safe fallback and lets the corpus show whether
pure-ungrouped band compaction is useful before attempting a more invasive mixed
group/ungrouped global layer model.
@@ -409,6 +415,35 @@ respecting pinned nodes and the established local-placement special cases.
Amazing Z-Image is intentionally not compacted unless the product later gains an
explicit user option to ignore authored pins.
+A targeted rerun after enabling direct group incidence confirmed that the filter
+was only the first blocker:
+
+- Jibs: eligibility increased to 12 nodes. One 4-node component became
+ compactable with 11.8% estimated potential. Its proposal reduced crossings
+ from 756 to 674, but width only changed from 5,977 to 5,909 px and RTL rose
+ from 41 to 42, so it was rejected.
+- Flux2 VTON 6.0.2: 8 of 9 ungrouped nodes are eligible and 7 are directly
+ group-incident, but the pure eligible graph still has no component with the
+ minimum node count.
+- Flux2 VTON 6.0.2.7: 15 of 16 ungrouped nodes are eligible and 9 are directly
+ group-incident. The largest 6-node pure component spans 3,784 px but predicts
+ 0% reduction inside the current target band.
+
+These results show that direct-group eligibility alone does not make Phase 4 a
+mixed structural graph. The diagnostics now additionally build a read-only
+group-bridged connectivity graph: eligible ungrouped nodes are normal vertices,
+groups are fixed connector supernodes, and physical group/group or
+group/ungrouped links preserve connectivity. The reporter prints the number of
+such mixed components plus the largest component's eligible-node count, group
+count, and horizontal span. This graph is diagnostic only and does not move
+groups.
+
+If the targeted workflows collapse from several pure eligible fragments into
+one large group-bridged component, that is evidence for the next architecture:
+a shared global dependency graph with fixed group supernodes and movable
+ungrouped nodes. Thresholds should not be relaxed to compensate for missing
+group connectivity.
+
## Current Scope Boundary
The active engine now refines movable group internals and top-level group order,
@@ -416,9 +451,12 @@ then optionally compacts the global group and linked-ungrouped flow with the
existing wrap geometry. Bridge, external-source, control, and virtual-hub
special cases still remain authoritative.
-If the Phase 3 corpus confirms useful width reduction without losing the Phase 2
-quality gains, the next structural target is a true SCC/dummy/sweep ordering pass
-for ungrouped linked flow rather than only compact placement.
+The next structural decision is gated by the new group-bridged diagnostics. If
+they show that groups join the fragmented ungrouped islands in Jibs and Flux2
+VTON, the next target is a shared Group/Ungrouped dependency graph with group
+supernodes, SCC condensation, dummy vertices, weighted ordering, and separate
+physical band placement. Until then, Phase 3 remains the fallback and Phase 4
+stays conservative.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase4.py b/flowforge/layout_engine_v2_phase4.py
index d8da986..78a0899 100644
--- a/flowforge/layout_engine_v2_phase4.py
+++ b/flowforge/layout_engine_v2_phase4.py
@@ -1,12 +1,18 @@
"""Ungrouped-flow compaction for FlowForge layout engine v2.
Phase 4 keeps the complete Phase 3 result as a baseline and tries to compact
-pure linked ungrouped components into a shared horizontal band. Components that
-are directly incident to groups or belong to established local-placement
-special cases remain untouched.
-
-The first Phase 4 gate is intentionally strict: no additional crossings, RTL
-links, or movable overlaps are allowed.
+eligible linked ungrouped components into a shared horizontal band. Direct
+group incidence is eligible, but groups remain fixed geometry and are not part
+of the active compaction graph.
+
+Diagnostics also build a read-only group-bridged connectivity graph that treats
+groups as connector supernodes. This measures whether fixed groups are what
+join otherwise fragmented ungrouped flow before a more invasive global graph is
+considered.
+
+The Phase 4 gate is intentionally strict: no additional crossings, RTL links,
+or movable overlaps are allowed, workflow width must fall by at least 8%, and
+workflow area may not increase.
"""
from __future__ import annotations
@@ -59,6 +65,10 @@ class Phase4Diagnostics:
excluded_text_preview: int
direct_group_nodes: int
linked_components: int
+ group_bridged_components: int
+ largest_group_bridged_ungrouped_nodes: int
+ largest_group_bridged_groups: int
+ largest_group_bridged_span: float
candidate_components: int
compactable_components: int
largest_component_nodes: int
@@ -138,6 +148,12 @@ def diagnose_phase4(
for component in _connected_components(undirected)
if any(adjacency[node_id] for node_id in component)
]
+ group_bridged_rows = _group_bridged_component_rows(workflow, eligible_by_id)
+ largest_group_bridged = max(
+ group_bridged_rows,
+ key=lambda row: row[2],
+ default=(set(), set(), 0.0),
+ )
candidate_components = [
component
for component in linked_components
@@ -183,6 +199,10 @@ def diagnose_phase4(
total_ungrouped=len(workflow.ungrouped_nodes),
eligible_nodes=len(eligible),
linked_components=len(linked_components),
+ group_bridged_components=len(group_bridged_rows),
+ largest_group_bridged_ungrouped_nodes=len(largest_group_bridged[0]),
+ largest_group_bridged_groups=len(largest_group_bridged[1]),
+ largest_group_bridged_span=largest_group_bridged[2],
candidate_components=len(candidate_components),
compactable_components=len(compactable),
largest_component_nodes=len(largest[0]),
@@ -209,6 +229,10 @@ def diagnose_phase4(
total_ungrouped=len(workflow.ungrouped_nodes),
eligible_nodes=len(eligible),
linked_components=len(linked_components),
+ group_bridged_components=len(group_bridged_rows),
+ largest_group_bridged_ungrouped_nodes=len(largest_group_bridged[0]),
+ largest_group_bridged_groups=len(largest_group_bridged[1]),
+ largest_group_bridged_span=largest_group_bridged[2],
candidate_components=len(candidate_components),
compactable_components=len(compactable),
largest_component_nodes=len(largest[0]),
@@ -276,6 +300,91 @@ def _eligible_graph(
return adjacency, undirected
+def _group_bridged_component_rows(
+ workflow: Workflow,
+ eligible_by_id: dict[int, Node],
+) -> list[tuple[set[int], set[int], float]]:
+ """Describe eligible-node connectivity when groups act as fixed connectors."""
+ group_by_node_id = _group_by_node_id(workflow)
+ graph: dict[tuple[str, int], set[tuple[str, int]]] = {
+ ("node", node_id): set() for node_id in eligible_by_id
+ }
+
+ def endpoint_vertex(node_id: int) -> tuple[str, int] | None:
+ if node_id in eligible_by_id:
+ return ("node", node_id)
+ group = group_by_node_id.get(node_id)
+ if group is not None:
+ return ("group", group.id)
+ return None
+
+ for link in workflow.links.values():
+ source = endpoint_vertex(link.source)
+ target = endpoint_vertex(link.target)
+ if source is None or target is None or source == target:
+ continue
+ graph.setdefault(source, set()).add(target)
+ graph.setdefault(target, set()).add(source)
+
+ rows: list[tuple[set[int], set[int], float]] = []
+ for component in _mixed_connected_components(graph):
+ node_ids = {vertex_id for kind, vertex_id in component if kind == "node"}
+ group_ids = {vertex_id for kind, vertex_id in component if kind == "group"}
+ if not node_ids or not group_ids:
+ continue
+ rows.append(
+ (
+ node_ids,
+ group_ids,
+ _mixed_component_span(workflow, eligible_by_id, node_ids, group_ids),
+ )
+ )
+ return rows
+
+
+def _mixed_connected_components(
+ undirected: dict[tuple[str, int], set[tuple[str, int]]],
+) -> list[set[tuple[str, int]]]:
+ components: list[set[tuple[str, int]]] = []
+ unseen = set(undirected)
+ while unseen:
+ start = min(unseen)
+ stack = [start]
+ component: set[tuple[str, int]] = set()
+ while stack:
+ vertex = stack.pop()
+ if vertex in component:
+ continue
+ component.add(vertex)
+ unseen.discard(vertex)
+ stack.extend(sorted(undirected[vertex] - component, reverse=True))
+ components.append(component)
+ return components
+
+
+def _mixed_component_span(
+ workflow: Workflow,
+ eligible_by_id: dict[int, Node],
+ node_ids: set[int],
+ group_ids: set[int],
+) -> float:
+ bounds: list[tuple[float, float]] = []
+ for node_id in node_ids:
+ node = eligible_by_id[node_id]
+ bounds.append((node.x, node.x + _node_visual_width(node)))
+
+ groups_by_id = {group.id: group for group in workflow.groups}
+ for group_id in group_ids:
+ group = groups_by_id.get(group_id)
+ if group is None or not _has_positive_bounding(group):
+ continue
+ bounds.append((group.bounding[0], group.bounding[0] + group.bounding[2]))
+
+ if not bounds:
+ return 0.0
+ return max(right for _left, right in bounds) - min(left for left, _right in bounds)
+
+
def _component_potential_reduction(
workflow: Workflow,
nodes: list[Node],
@@ -322,12 +431,6 @@ def _ungrouped_rejection_reason(
return "crossing_regression"
if candidate.right_to_left_links > baseline.right_to_left_links:
return "rtl_regression"
- if (
- candidate.crossings < baseline.crossings
- or candidate.right_to_left_links < baseline.right_to_left_links
- ):
- return "accepted_quality_gain"
-
if baseline.width <= 0:
return "invalid_baseline_width"
width_reduction = 1.0 - candidate.width / baseline.width
@@ -349,7 +452,7 @@ def _compact_pure_ungrouped_components(
settings: LayoutSettings,
workflow_width: float,
) -> bool:
- """Compact linked ungrouped components that do not touch group geometry."""
+ """Compact eligible linked ungrouped components around fixed group geometry."""
eligible = _eligible_ungrouped_nodes(workflow)
if len(eligible) < UNGROUPED_COMPACT_MIN_COMPONENT_NODES:
return False
@@ -652,12 +755,6 @@ def _ungrouped_candidate_is_better(
if candidate.right_to_left_links > baseline.right_to_left_links:
return False
- if (
- candidate.crossings < baseline.crossings
- or candidate.right_to_left_links < baseline.right_to_left_links
- ):
- return True
-
if baseline.width <= 0:
return False
width_reduction = 1.0 - candidate.width / baseline.width
diff --git a/tests/test_layout_engine_v2_phase4.py b/tests/test_layout_engine_v2_phase4.py
index 5e4d8ad..ed90cd1 100644
--- a/tests/test_layout_engine_v2_phase4.py
+++ b/tests/test_layout_engine_v2_phase4.py
@@ -5,8 +5,10 @@
from flowforge.layout_engine_v2_phase4 import (
_compact_pure_ungrouped_components,
_eligible_ungrouped_nodes,
+ _group_bridged_component_rows,
_phase4_exclusion_counts,
_ungrouped_candidate_is_better,
+ _ungrouped_rejection_reason,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -36,15 +38,37 @@ def _attach_link(workflow: Workflow, link: Link) -> None:
workflow.nodes[link.target].input_links.append(link.id)
-def test_ungrouped_candidate_requires_crossing_and_rtl_preservation():
+def test_ungrouped_candidate_requires_full_compaction_gate():
baseline = _score(crossings=100, rtl=20, width=6_000, height=4_000)
compact = _score(crossings=100, rtl=20, width=5_000, height=4_200)
crossing_regression = _score(crossings=101, rtl=20, width=4_000, height=4_000)
rtl_regression = _score(crossings=100, rtl=21, width=4_000, height=4_000)
+ quality_gain_too_narrow = _score(
+ crossings=90,
+ rtl=20,
+ width=5_800,
+ height=3_800,
+ )
+ quality_gain_area_regression = _score(
+ crossings=90,
+ rtl=20,
+ width=5_000,
+ height=5_000,
+ )
assert _ungrouped_candidate_is_better(compact, baseline)
assert not _ungrouped_candidate_is_better(crossing_regression, baseline)
assert not _ungrouped_candidate_is_better(rtl_regression, baseline)
+ assert not _ungrouped_candidate_is_better(quality_gain_too_narrow, baseline)
+ assert not _ungrouped_candidate_is_better(quality_gain_area_regression, baseline)
+ assert (
+ _ungrouped_rejection_reason(quality_gain_too_narrow, baseline)
+ == "insufficient_final_width_reduction"
+ )
+ assert (
+ _ungrouped_rejection_reason(quality_gain_area_regression, baseline)
+ == "area_regression"
+ )
def test_eligible_ungrouped_nodes_include_direct_group_incident_nodes():
@@ -180,3 +204,53 @@ def test_phase4_diagnostics_report_group_incidence_without_excluding_it():
assert counts["direct_group_nodes"] == 1
assert incident.id in eligible_ids
+
+
+def test_group_bridged_diagnostics_connect_ungrouped_nodes_through_fixed_groups():
+ workflow = Workflow()
+ group_a_node = Node(id=1, type="GroupA", x=500, y=100, size=[200, 100])
+ group_b_node = Node(id=2, type="GroupB", x=1_500, y=100, size=[200, 100])
+ first = Node(id=10, type="First", x=100, y=100, size=[200, 100])
+ middle = Node(id=11, type="Middle", x=1_000, y=100, size=[200, 100])
+ last = Node(id=12, type="Last", x=2_000, y=100, size=[200, 100])
+ workflow.nodes = {
+ group_a_node.id: group_a_node,
+ group_b_node.id: group_b_node,
+ first.id: first,
+ middle.id: middle,
+ last.id: last,
+ }
+ workflow.groups = [
+ Group(id=100, name="A", nodes=[group_a_node], bounding=[450, 50, 300, 220]),
+ Group(id=200, name="B", nodes=[group_b_node], bounding=[1_450, 50, 300, 220]),
+ ]
+ workflow.ungrouped_nodes = [first, middle, last]
+
+ _attach_link(
+ workflow,
+ Link(id=1, source=10, source_port=0, target=1, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=2, source=1, source_port=0, target=11, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=3, source=11, source_port=0, target=2, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=4, source=2, source_port=0, target=12, target_port=0, type="DATA"),
+ )
+
+ eligible = _eligible_ungrouped_nodes(workflow)
+ rows = _group_bridged_component_rows(
+ workflow,
+ {node.id: node for node in eligible},
+ )
+
+ assert len(rows) == 1
+ node_ids, group_ids, span = rows[0]
+ assert node_ids == {10, 11, 12}
+ assert group_ids == {100, 200}
+ assert span == 2_100
diff --git a/tools/report_phase4_diagnostics.py b/tools/report_phase4_diagnostics.py
index 11e41ad..7ee0d11 100644
--- a/tools/report_phase4_diagnostics.py
+++ b/tools/report_phase4_diagnostics.py
@@ -70,6 +70,13 @@ def main() -> int:
f"largest_span={diag.largest_component_span:.0f} "
f"max_potential={diag.max_potential_reduction * 100:.1f}%"
)
+ print(
+ " group-bridged: "
+ f"components={diag.group_bridged_components} "
+ f"largest_ungrouped={diag.largest_group_bridged_ungrouped_nodes} "
+ f"largest_groups={diag.largest_group_bridged_groups} "
+ f"largest_span={diag.largest_group_bridged_span:.0f}"
+ )
if diag.attempted:
print(
" proposal: "
From 051f2dffcc097ab8c85433613c77c48d869c0106 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:39:47 +0200
Subject: [PATCH 061/105] Add Phase 5 mixed global-flow candidate
---
docs/LAYOUT_ENGINE_V2.md | 93 +++-
flowforge/layout_engine_v2_phase5.py | 768 ++++++++++++++++++++++++++
flowforge/layout_engine_v2_runtime.py | 2 +-
tests/test_layout_engine_v2_phase5.py | 176 ++++++
tools/report_phase5_diagnostics.py | 83 +++
5 files changed, 1105 insertions(+), 17 deletions(-)
create mode 100644 flowforge/layout_engine_v2_phase5.py
create mode 100644 tests/test_layout_engine_v2_phase5.py
create mode 100644 tools/report_phase5_diagnostics.py
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index e06a644..56b9768 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -438,25 +438,82 @@ such mixed components plus the largest component's eligible-node count, group
count, and horizontal span. This graph is diagnostic only and does not move
groups.
-If the targeted workflows collapse from several pure eligible fragments into
-one large group-bridged component, that is evidence for the next architecture:
-a shared global dependency graph with fixed group supernodes and movable
-ungrouped nodes. Thresholds should not be relaxed to compensate for missing
-group connectivity.
+The targeted group-bridged rerun confirmed that architecture:
+
+- Jibs: the pure eligible graph had 2 linked components, while the group-bridged
+ graph collapsed the relevant flow to 1 component containing 8 eligible
+ ungrouped nodes and 14 groups across a 4,202 px span.
+- Flux2 VTON 6.0.2: the pure eligible graph had no component large enough to
+ attempt compaction, while the group-bridged graph produced 1 component with
+ all 8 eligible ungrouped nodes, 4 groups, and a 6,175 px span.
+- Flux2 VTON 6.0.2.7: the pure eligible graph had 3 linked components and a
+ largest 6-node span of 3,784 px with 0% predicted reduction. The
+ group-bridged graph produced 1 component with 14 eligible ungrouped nodes,
+ 5 groups, and a 7,982 px span.
+
+This is direct evidence that the remaining width problem is not a pure
+ungrouped-packing threshold issue. Groups are the structural connectors that
+join the wide flow, so further Phase 4 threshold tuning would optimize the
+wrong graph.
+
+## Phase 5 shared Group/Ungrouped global graph
+
+Phase 5 is now an isolated candidate on top of the complete Phase 4 result.
+
+Its real graph vertices are:
+
+- whole groups represented as supernodes;
+- eligible ungrouped nodes.
+
+Physical group/ungrouped links become directed mixed-graph edges. Existing
+group-flow dependencies are also retained so bridge-only group relationships
+are not lost. The graph then uses:
+
+1. SCC condensation and longest-path layers;
+2. virtual dummy vertices for long mixed edges;
+3. weighted median sweeps;
+4. weighted local transposition;
+5. shared physical layer placement;
+6. boustrophedon row wrapping.
+
+A group always moves as one rectangle with every member node, preserving its
+internal geometry. The established finalizer then re-applies control, text
+preview, virtual Set/Get, group-overlap, and pinned-geometry contracts.
+
+The first Phase 5 experiment deliberately skips any workflow that contains
+pinned group geometry or pinned ungrouped nodes. This keeps authored pins as
+hard constraints and excludes Amazing Z-Image from automatic reorganization.
+
+Phase 5 uses a strict acceptance gate:
+
+- movable overlaps may not increase;
+- crossings may not increase;
+- RTL links may not increase;
+- workflow width must fall by at least 8%;
+- workflow area may not increase.
+
+Crossing or RTL improvements do not bypass the width/area requirements.
+
+Targeted diagnostics are available with:
+
+```powershell
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Jibs_Ultimate" `
+ --match "6.0.2.7" `
+ --match "6.0.2 (Codex)"
+```
## Current Scope Boundary
-The active engine now refines movable group internals and top-level group order,
-then optionally compacts the global group and linked-ungrouped flow with the
-existing wrap geometry. Bridge, external-source, control, and virtual-hub
-special cases still remain authoritative.
+The active runtime now reaches Phase 5. Phase 4 remains the complete fallback
+baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
+text-preview, virtual-hub, decorative, and pin-specific contracts are still
+authoritative after mixed placement.
-The next structural decision is gated by the new group-bridged diagnostics. If
-they show that groups join the fragmented ungrouped islands in Jibs and Flux2
-VTON, the next target is a shared Group/Ungrouped dependency graph with group
-supernodes, SCC condensation, dummy vertices, weighted ordering, and separate
-physical band placement. Until then, Phase 3 remains the fallback and Phase 4
-stays conservative.
+Phase 5 is still experimental. Its next gate is the targeted Jibs/Flux2
+diagnostic run followed by the full 81-workflow Optimize + Layout corpus. The
+aggregate structural reference remains 4,127 crossings / 339 RTL until corpus
+evidence justifies updating it.
## Deferred TODOs
@@ -489,7 +546,11 @@ Regression coverage includes:
- weighted group medians;
- weighted group crossing elimination;
- fixed-group protection at group level;
-- crossing-first group-level candidate selection.
+- crossing-first group-level candidate selection;
+- shared Group/Ungrouped dependency layering;
+- preservation of group-internal geometry during mixed placement;
+- strict Phase 5 width/area and crossing/RTL acceptance gates;
+- Phase 5 pin-constraint blocking.
## Documentation Status
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
new file mode 100644
index 0000000..59fed14
--- /dev/null
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -0,0 +1,768 @@
+"""Mixed group/ungrouped global-flow refinement for layout engine v2.
+
+Phase 5 is an isolated candidate on top of the complete Phase 4 result. It
+builds one directed dependency graph whose real vertices are whole groups and
+eligible ungrouped nodes. Group internals remain unchanged: a group is moved as
+one rectangle together with all of its member nodes.
+
+Pinned geometry remains a hard constraint. Workflows with pinned groups or
+pinned ungrouped nodes are skipped entirely by this first Phase 5 experiment.
+Established local-placement special cases remain outside the mixed graph and
+are re-applied by the normal refinement finalizer.
+
+The acceptance gate is deliberately strict: no additional movable overlaps,
+crossings, or right-to-left links; at least 8% workflow-width reduction; and no
+workflow-area growth.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+import math
+from typing import TypeAlias
+
+from .layout import (
+ LayoutSettings,
+ _group_by_node_id,
+ _group_flow_adjacency,
+ _group_has_fixed_geometry,
+ _has_positive_bounding,
+ _is_pinned_node,
+ _move_group_geometry,
+ _node_visual_height,
+ _node_visual_width,
+ _toplevel_wrap_row_width_cap,
+ _wrap_layer_columns,
+)
+from .layout_engine_v2 import (
+ EngineV2Score,
+ _assign_scc_longest_path_layers,
+ _finalize_refinement,
+ _score_engine_v2,
+)
+from .layout_engine_v2_phase4 import (
+ _eligible_ungrouped_nodes,
+ apply_best_layout as _apply_phase4_best_layout,
+)
+from .logger import setup_logger
+from .model import Group, Node, Workflow
+
+logger = setup_logger(__name__)
+
+MIXED_GLOBAL_MIN_WIDTH = 5_000.0
+MIXED_GLOBAL_MIN_GROUPS = 2
+MIXED_GLOBAL_MIN_UNGROUPED = 3
+MIXED_GLOBAL_TARGET_WIDTH_RATIO = 0.70
+MIXED_GLOBAL_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO = 0.08
+MIXED_GLOBAL_MAX_AREA_RATIO = 1.0
+MIXED_SWEEP_ROUNDS = 6
+MIXED_TRANSPOSE_PASSES = 6
+
+MixedSpec: TypeAlias = tuple[str, int]
+MixedDummy: TypeAlias = tuple[str, int, int, int]
+MixedOrderVertex: TypeAlias = int | MixedDummy
+WeightedMixedEdge: TypeAlias = tuple[MixedOrderVertex, MixedOrderVertex, int]
+
+
+@dataclass(frozen=True)
+class Phase5Diagnostics:
+ """Explain whether mixed global-flow placement can affect one workflow."""
+
+ group_vertices: int
+ ungrouped_vertices: int
+ graph_edges: int
+ layers: int
+ attempted: bool
+ accepted: bool
+ rejection_reason: str
+ baseline_width: float
+ baseline_height: float
+ proposed_width: float | None
+ proposed_height: float | None
+ baseline_crossings: int
+ proposed_crossings: int | None
+ baseline_rtl: int
+ proposed_rtl: int | None
+
+
+def apply_best_layout(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+ candidate_count: int | None = None,
+) -> Workflow:
+ """Run Phase 4, then try one shared group/ungrouped global-flow candidate."""
+ settings = settings or LayoutSettings()
+ baseline = _apply_phase4_best_layout(workflow, settings, candidate_count)
+ baseline_score = _score_engine_v2(baseline)
+ if baseline_score.width < MIXED_GLOBAL_MIN_WIDTH:
+ return baseline
+
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(candidate, settings, baseline_score.width):
+ return baseline
+
+ _finalize_refinement(candidate, settings)
+ candidate_score = _score_engine_v2(candidate)
+ if _mixed_candidate_is_better(candidate_score, baseline_score):
+ logger.info(
+ "Phase 5 mixed global-flow accepted: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s",
+ baseline_score.width,
+ baseline_score.height,
+ candidate_score.width,
+ candidate_score.height,
+ baseline_score.crossings,
+ candidate_score.crossings,
+ baseline_score.right_to_left_links,
+ candidate_score.right_to_left_links,
+ )
+ return candidate
+
+ logger.info(
+ "Phase 5 mixed global-flow rejected: "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s; reason=%s",
+ baseline_score.width,
+ baseline_score.height,
+ candidate_score.width,
+ candidate_score.height,
+ baseline_score.crossings,
+ candidate_score.crossings,
+ baseline_score.right_to_left_links,
+ candidate_score.right_to_left_links,
+ _mixed_rejection_reason(candidate_score, baseline_score),
+ )
+ return baseline
+
+
+def diagnose_phase5(
+ workflow: Workflow,
+ settings: LayoutSettings | None = None,
+) -> Phase5Diagnostics:
+ """Return a read-only explanation of the Phase 5 decision."""
+ settings = settings or LayoutSettings()
+ baseline_score = _score_engine_v2(workflow)
+ specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
+ group_count = sum(1 for kind, _value in specs if kind == "group")
+ ungrouped_count = sum(1 for kind, _value in specs if kind == "node")
+ layers_map = (
+ _assign_scc_longest_path_layers(set(spec_by_vertex), adjacency)
+ if spec_by_vertex
+ else {}
+ )
+ layer_count = len(set(layers_map.values()))
+
+ precheck_reason = _mixed_precheck_reason(
+ workflow,
+ baseline_score,
+ group_count,
+ ungrouped_count,
+ edge_weights,
+ layer_count,
+ )
+ if precheck_reason is not None:
+ return Phase5Diagnostics(
+ group_vertices=group_count,
+ ungrouped_vertices=ungrouped_count,
+ graph_edges=len(edge_weights),
+ layers=layer_count,
+ attempted=False,
+ accepted=False,
+ rejection_reason=precheck_reason,
+ baseline_width=baseline_score.width,
+ baseline_height=baseline_score.height,
+ proposed_width=None,
+ proposed_height=None,
+ baseline_crossings=baseline_score.crossings,
+ proposed_crossings=None,
+ baseline_rtl=baseline_score.right_to_left_links,
+ proposed_rtl=None,
+ )
+
+ candidate = deepcopy(workflow)
+ moved = _place_mixed_global_flow(candidate, settings, baseline_score.width)
+ if not moved:
+ return Phase5Diagnostics(
+ group_vertices=group_count,
+ ungrouped_vertices=ungrouped_count,
+ graph_edges=len(edge_weights),
+ layers=layer_count,
+ attempted=False,
+ accepted=False,
+ rejection_reason="no_geometry_change",
+ baseline_width=baseline_score.width,
+ baseline_height=baseline_score.height,
+ proposed_width=None,
+ proposed_height=None,
+ baseline_crossings=baseline_score.crossings,
+ proposed_crossings=None,
+ baseline_rtl=baseline_score.right_to_left_links,
+ proposed_rtl=None,
+ )
+
+ _finalize_refinement(candidate, settings)
+ candidate_score = _score_engine_v2(candidate)
+ accepted = _mixed_candidate_is_better(candidate_score, baseline_score)
+ return Phase5Diagnostics(
+ group_vertices=group_count,
+ ungrouped_vertices=ungrouped_count,
+ graph_edges=len(edge_weights),
+ layers=layer_count,
+ attempted=True,
+ accepted=accepted,
+ rejection_reason=(
+ "accepted"
+ if accepted
+ else _mixed_rejection_reason(candidate_score, baseline_score)
+ ),
+ baseline_width=baseline_score.width,
+ baseline_height=baseline_score.height,
+ proposed_width=candidate_score.width,
+ proposed_height=candidate_score.height,
+ baseline_crossings=baseline_score.crossings,
+ proposed_crossings=candidate_score.crossings,
+ baseline_rtl=baseline_score.right_to_left_links,
+ proposed_rtl=candidate_score.right_to_left_links,
+ )
+
+
+def _mixed_precheck_reason(
+ workflow: Workflow,
+ baseline_score: EngineV2Score,
+ group_count: int,
+ ungrouped_count: int,
+ edge_weights: dict[tuple[int, int], int],
+ layer_count: int,
+) -> str | None:
+ if baseline_score.width < MIXED_GLOBAL_MIN_WIDTH:
+ return "workflow_below_min_width"
+ if _has_phase5_pin_constraint(workflow):
+ return "pinned_geometry_constraint"
+ if group_count < MIXED_GLOBAL_MIN_GROUPS:
+ return "too_few_groups"
+ if ungrouped_count < MIXED_GLOBAL_MIN_UNGROUPED:
+ return "too_few_eligible_ungrouped_nodes"
+ if not edge_weights:
+ return "no_mixed_graph_edges"
+ if layer_count <= 1:
+ return "single_mixed_layer"
+ return None
+
+
+def _has_phase5_pin_constraint(workflow: Workflow) -> bool:
+ if any(
+ group.nodes and _group_has_fixed_geometry(group)
+ for group in workflow.groups
+ ):
+ return True
+ return any(
+ _is_pinned_node(workflow, node)
+ for node in workflow.ungrouped_nodes
+ if node.id in workflow.nodes
+ )
+
+
+def _build_mixed_graph(
+ workflow: Workflow,
+) -> tuple[
+ list[MixedSpec],
+ dict[int, MixedSpec],
+ dict[int, list[int]],
+ dict[tuple[int, int], int],
+]:
+ """Build a directed graph of group supernodes and eligible ungrouped nodes."""
+ groups = [
+ group
+ for group in workflow.groups
+ if group.nodes and _has_positive_bounding(group)
+ ]
+ eligible_nodes = _eligible_ungrouped_nodes(workflow)
+
+ specs: list[MixedSpec] = [
+ ("group", group.id) for group in sorted(groups, key=lambda item: item.id)
+ ]
+ specs.extend(
+ ("node", node.id) for node in sorted(eligible_nodes, key=lambda item: item.id)
+ )
+ vertex_by_spec = {spec: index for index, spec in enumerate(specs)}
+ spec_by_vertex = {index: spec for spec, index in vertex_by_spec.items()}
+ adjacency_sets: dict[int, set[int]] = {
+ vertex: set() for vertex in spec_by_vertex
+ }
+ edge_weights: dict[tuple[int, int], int] = {}
+
+ group_by_node_id = _group_by_node_id(workflow)
+ eligible_ids = {node.id for node in eligible_nodes}
+
+ def endpoint_spec(node_id: int) -> MixedSpec | None:
+ group = group_by_node_id.get(node_id)
+ if group is not None and ("group", group.id) in vertex_by_spec:
+ return ("group", group.id)
+ if node_id in eligible_ids:
+ return ("node", node_id)
+ return None
+
+ for link in sorted(workflow.links.values(), key=lambda item: item.id):
+ source_spec = endpoint_spec(link.source)
+ target_spec = endpoint_spec(link.target)
+ if source_spec is None or target_spec is None or source_spec == target_spec:
+ continue
+ source = vertex_by_spec[source_spec]
+ target = vertex_by_spec[target_spec]
+ adjacency_sets[source].add(target)
+ pair = (source, target)
+ edge_weights[pair] = edge_weights.get(pair, 0) + 1
+
+ group_adjacency, _reverse = _group_flow_adjacency(workflow)
+ for source_group_id, targets in group_adjacency.items():
+ source_spec = ("group", source_group_id)
+ if source_spec not in vertex_by_spec:
+ continue
+ source = vertex_by_spec[source_spec]
+ for target_group_id in targets:
+ target_spec = ("group", target_group_id)
+ if target_spec not in vertex_by_spec or target_spec == source_spec:
+ continue
+ target = vertex_by_spec[target_spec]
+ adjacency_sets[source].add(target)
+ edge_weights.setdefault((source, target), 1)
+
+ adjacency = {
+ vertex: sorted(targets)
+ for vertex, targets in adjacency_sets.items()
+ }
+ return specs, spec_by_vertex, adjacency, edge_weights
+
+
+def _place_mixed_global_flow(
+ workflow: Workflow,
+ settings: LayoutSettings,
+ workflow_width: float,
+) -> bool:
+ """Place groups and eligible ungrouped nodes in one shared layered flow."""
+ specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
+ group_count = sum(1 for kind, _value in specs if kind == "group")
+ ungrouped_count = sum(1 for kind, _value in specs if kind == "node")
+ if (
+ _has_phase5_pin_constraint(workflow)
+ or group_count < MIXED_GLOBAL_MIN_GROUPS
+ or ungrouped_count < MIXED_GLOBAL_MIN_UNGROUPED
+ or not edge_weights
+ ):
+ return False
+
+ layers = _assign_scc_longest_path_layers(set(spec_by_vertex), adjacency)
+ if len(set(layers.values())) <= 1:
+ return False
+
+ order = _weighted_mixed_order(
+ workflow,
+ spec_by_vertex,
+ layers,
+ edge_weights,
+ )
+ layer_to_real = {
+ layer: [
+ vertex
+ for vertex in vertices
+ if isinstance(vertex, int)
+ ]
+ for layer, vertices in order.items()
+ }
+ layer_to_real = {
+ layer: vertices
+ for layer, vertices in layer_to_real.items()
+ if vertices
+ }
+ if not layer_to_real:
+ return False
+
+ groups_by_id = {group.id: group for group in workflow.groups}
+ nodes_by_id = {node.id: node for node in workflow.nodes.values()}
+ layer_sizes: dict[int, tuple[float, float]] = {}
+ vertical_gap = max(settings.group_v_gap, settings.node_v_gap)
+ horizontal_gap = max(settings.group_h_gap, settings.node_h_gap)
+
+ for layer, vertices in layer_to_real.items():
+ widths: list[float] = []
+ heights: list[float] = []
+ for vertex in vertices:
+ width, height = _mixed_vertex_size(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )
+ widths.append(width)
+ heights.append(height)
+ layer_sizes[layer] = (
+ max(widths),
+ sum(heights) + vertical_gap * max(0, len(heights) - 1),
+ )
+
+ max_layer_width = max(width for width, _height in layer_sizes.values())
+ automatic_cap = _toplevel_wrap_row_width_cap(layer_sizes)
+ target_cap = max(
+ max_layer_width,
+ min(automatic_cap, workflow_width * MIXED_GLOBAL_TARGET_WIDTH_RATIO),
+ )
+
+ real_specs = [spec_by_vertex[vertex] for vertices in layer_to_real.values() for vertex in vertices]
+ base_x = min(
+ (_mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[0] for spec in real_specs),
+ default=50.0,
+ )
+ base_y = min(
+ (_mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[1] for spec in real_specs),
+ default=50.0,
+ )
+ layer_positions = _wrap_layer_columns(
+ layer_sizes,
+ target_cap,
+ base_x,
+ base_y,
+ horizontal_gap,
+ vertical_gap,
+ )
+
+ changed = False
+ for layer in sorted(layer_to_real):
+ x, current_y = layer_positions[layer]
+ for vertex in layer_to_real[layer]:
+ spec = spec_by_vertex[vertex]
+ old_x, old_y = _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)
+ if abs(old_x - x) > 1e-9 or abs(old_y - current_y) > 1e-9:
+ changed = True
+ _move_mixed_vertex(
+ spec,
+ groups_by_id,
+ nodes_by_id,
+ x,
+ current_y,
+ )
+ _width, height = _mixed_vertex_size(
+ spec,
+ groups_by_id,
+ nodes_by_id,
+ )
+ current_y += height + vertical_gap
+ return changed
+
+
+def _mixed_vertex_size(
+ spec: MixedSpec,
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+) -> tuple[float, float]:
+ kind, value = spec
+ if kind == "group":
+ group = groups_by_id[value]
+ return group.bounding[2], group.bounding[3]
+ node = nodes_by_id[value]
+ return _node_visual_width(node), _node_visual_height(node)
+
+
+def _mixed_vertex_xy(
+ spec: MixedSpec,
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+) -> tuple[float, float]:
+ kind, value = spec
+ if kind == "group":
+ group = groups_by_id[value]
+ return group.bounding[0], group.bounding[1]
+ node = nodes_by_id[value]
+ return node.x, node.y
+
+
+def _mixed_vertex_center_y(
+ spec: MixedSpec,
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+) -> float:
+ x, y = _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)
+ del x
+ _width, height = _mixed_vertex_size(spec, groups_by_id, nodes_by_id)
+ return y + height / 2.0
+
+
+def _move_mixed_vertex(
+ spec: MixedSpec,
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+ x: float,
+ y: float,
+) -> None:
+ kind, value = spec
+ if kind == "group":
+ group = groups_by_id[value]
+ _move_group_geometry(
+ group,
+ x - group.bounding[0],
+ y - group.bounding[1],
+ )
+ return
+ node = nodes_by_id[value]
+ node.x = x
+ node.y = y
+
+
+def _weighted_mixed_order(
+ workflow: Workflow,
+ spec_by_vertex: dict[int, MixedSpec],
+ layers: dict[int, int],
+ edge_weights: dict[tuple[int, int], int],
+) -> dict[int, list[MixedOrderVertex]]:
+ """Order the shared mixed graph with weighted median sweeps and transpose."""
+ max_layer = max(layers.values(), default=0)
+ groups_by_id = {group.id: group for group in workflow.groups}
+ nodes_by_id = {node.id: node for node in workflow.nodes.values()}
+ layer_to_vertices: dict[int, list[MixedOrderVertex]] = {
+ layer: [] for layer in range(max_layer + 1)
+ }
+ stable_y: dict[MixedOrderVertex, float] = {}
+
+ for vertex, layer in layers.items():
+ layer_to_vertices.setdefault(layer, []).append(vertex)
+ stable_y[vertex] = _mixed_vertex_center_y(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )
+
+ expanded_edges: list[WeightedMixedEdge] = []
+ for (source, target), weight in sorted(edge_weights.items()):
+ source_layer = layers.get(source, 0)
+ target_layer = layers.get(target, 0)
+ if target_layer <= source_layer:
+ continue
+
+ previous: MixedOrderVertex = source
+ source_y = stable_y.get(source, 0.0)
+ target_y = stable_y.get(target, 0.0)
+ span = target_layer - source_layer
+ for layer in range(source_layer + 1, target_layer):
+ dummy: MixedDummy = ("mixed-dummy", source, target, layer)
+ layer_to_vertices.setdefault(layer, []).append(dummy)
+ fraction = (layer - source_layer) / span
+ stable_y[dummy] = source_y + (target_y - source_y) * fraction
+ expanded_edges.append((previous, dummy, weight))
+ previous = dummy
+ expanded_edges.append((previous, target, weight))
+
+ for vertices in layer_to_vertices.values():
+ vertices.sort(
+ key=lambda vertex: (
+ stable_y.get(vertex, 0.0),
+ _mixed_order_key(vertex),
+ )
+ )
+
+ predecessors: dict[MixedOrderVertex, list[tuple[MixedOrderVertex, int]]] = {}
+ successors: dict[MixedOrderVertex, list[tuple[MixedOrderVertex, int]]] = {}
+ for source, target, weight in expanded_edges:
+ successors.setdefault(source, []).append((target, weight))
+ predecessors.setdefault(target, []).append((source, weight))
+
+ for _ in range(MIXED_SWEEP_ROUNDS):
+ for layer in range(1, max_layer + 1):
+ _weighted_mixed_median_reorder(
+ layer_to_vertices,
+ layer,
+ predecessors,
+ layer - 1,
+ stable_y,
+ )
+ for layer in range(max_layer - 1, -1, -1):
+ _weighted_mixed_median_reorder(
+ layer_to_vertices,
+ layer,
+ successors,
+ layer + 1,
+ stable_y,
+ )
+ _weighted_mixed_transpose(layer_to_vertices, expanded_edges, max_layer)
+
+ return layer_to_vertices
+
+
+def _weighted_mixed_median_reorder(
+ layer_to_vertices: dict[int, list[MixedOrderVertex]],
+ layer: int,
+ neighbours: dict[MixedOrderVertex, list[tuple[MixedOrderVertex, int]]],
+ reference_layer: int,
+ stable_y: dict[MixedOrderVertex, float],
+) -> None:
+ vertices = layer_to_vertices.get(layer, [])
+ reference = layer_to_vertices.get(reference_layer, [])
+ if len(vertices) <= 1 or not reference:
+ return
+ positions = {vertex: index for index, vertex in enumerate(reference)}
+
+ def sort_key(vertex: MixedOrderVertex) -> tuple[float, float, str]:
+ weighted_positions = [
+ (positions[other], weight)
+ for other, weight in neighbours.get(vertex, [])
+ if other in positions
+ ]
+ median_position = (
+ _weighted_median(weighted_positions)
+ if weighted_positions
+ else math.inf
+ )
+ return (
+ median_position,
+ stable_y.get(vertex, 0.0),
+ _mixed_order_key(vertex),
+ )
+
+ layer_to_vertices[layer] = sorted(vertices, key=sort_key)
+
+
+def _weighted_median(values: list[tuple[int, int]]) -> float:
+ ordered = sorted(values)
+ total_weight = sum(max(0, weight) for _position, weight in ordered)
+ if total_weight <= 0:
+ return math.inf
+
+ threshold = total_weight / 2.0
+ cumulative = 0
+ for position, weight in ordered:
+ cumulative += max(0, weight)
+ if cumulative >= threshold:
+ return float(position)
+ return float(ordered[-1][0])
+
+
+def _weighted_mixed_transpose(
+ layer_to_vertices: dict[int, list[MixedOrderVertex]],
+ edges: list[WeightedMixedEdge],
+ max_layer: int,
+) -> None:
+ for _ in range(MIXED_TRANSPOSE_PASSES):
+ improved = False
+ for layer in range(max_layer + 1):
+ vertices = layer_to_vertices.get(layer, [])
+ if len(vertices) <= 1:
+ continue
+ index = 0
+ while index < len(vertices) - 1:
+ before = _weighted_mixed_local_crossings(
+ layer_to_vertices,
+ edges,
+ layer,
+ max_layer,
+ )
+ vertices[index], vertices[index + 1] = (
+ vertices[index + 1],
+ vertices[index],
+ )
+ after = _weighted_mixed_local_crossings(
+ layer_to_vertices,
+ edges,
+ layer,
+ max_layer,
+ )
+ if after < before:
+ improved = True
+ else:
+ vertices[index], vertices[index + 1] = (
+ vertices[index + 1],
+ vertices[index],
+ )
+ index += 1
+ if not improved:
+ break
+
+
+def _weighted_mixed_local_crossings(
+ layer_to_vertices: dict[int, list[MixedOrderVertex]],
+ edges: list[WeightedMixedEdge],
+ layer: int,
+ max_layer: int,
+) -> int:
+ crossings = 0
+ if layer > 0:
+ crossings += _weighted_mixed_boundary_crossings(
+ layer_to_vertices,
+ edges,
+ layer - 1,
+ layer,
+ )
+ if layer < max_layer:
+ crossings += _weighted_mixed_boundary_crossings(
+ layer_to_vertices,
+ edges,
+ layer,
+ layer + 1,
+ )
+ return crossings
+
+
+def _weighted_mixed_boundary_crossings(
+ layer_to_vertices: dict[int, list[MixedOrderVertex]],
+ edges: list[WeightedMixedEdge],
+ left_layer: int,
+ right_layer: int,
+) -> int:
+ left = layer_to_vertices.get(left_layer, [])
+ right = layer_to_vertices.get(right_layer, [])
+ if not left or not right:
+ return 0
+
+ left_pos = {vertex: index for index, vertex in enumerate(left)}
+ right_pos = {vertex: index for index, vertex in enumerate(right)}
+ boundary_edges = [
+ (source, target, weight)
+ for source, target, weight in edges
+ if source in left_pos and target in right_pos
+ ]
+
+ crossings = 0
+ for index, first in enumerate(boundary_edges):
+ for second in boundary_edges[index + 1 :]:
+ if first[0] == second[0] or first[1] == second[1]:
+ continue
+ if (left_pos[first[0]] - left_pos[second[0]]) * (
+ right_pos[first[1]] - right_pos[second[1]]
+ ) < 0:
+ crossings += first[2] * second[2]
+ return crossings
+
+
+def _mixed_order_key(vertex: MixedOrderVertex) -> str:
+ return str(vertex)
+
+
+def _mixed_candidate_is_better(
+ candidate: EngineV2Score,
+ baseline: EngineV2Score,
+) -> bool:
+ """Require strict graph-quality preservation plus real width/area gain."""
+ return _mixed_rejection_reason(candidate, baseline) == "accepted"
+
+
+def _mixed_rejection_reason(
+ candidate: EngineV2Score,
+ baseline: EngineV2Score,
+) -> str:
+ if candidate.movable_overlaps > baseline.movable_overlaps:
+ return "movable_overlap_regression"
+ if candidate.crossings > baseline.crossings:
+ return "crossing_regression"
+ if candidate.right_to_left_links > baseline.right_to_left_links:
+ return "rtl_regression"
+ if baseline.width <= 0:
+ return "invalid_baseline_width"
+
+ width_reduction = 1.0 - candidate.width / baseline.width
+ if width_reduction < MIXED_GLOBAL_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO:
+ return "insufficient_final_width_reduction"
+
+ baseline_area = baseline.width * baseline.height
+ candidate_area = candidate.width * candidate.height
+ if (
+ baseline_area > 0
+ and candidate_area > baseline_area * MIXED_GLOBAL_MAX_AREA_RATIO
+ ):
+ return "area_regression"
+ return "accepted"
diff --git a/flowforge/layout_engine_v2_runtime.py b/flowforge/layout_engine_v2_runtime.py
index 0ae1fb2..c63fecc 100644
--- a/flowforge/layout_engine_v2_runtime.py
+++ b/flowforge/layout_engine_v2_runtime.py
@@ -10,7 +10,7 @@
from __future__ import annotations
from .layout import LayoutSettings
-from .layout_engine_v2_phase4 import apply_best_layout
+from .layout_engine_v2_phase5 import apply_best_layout
from .model import Workflow
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
new file mode 100644
index 0000000..e944010
--- /dev/null
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -0,0 +1,176 @@
+"""Regression tests for Phase 5 mixed global-flow refinement."""
+
+from flowforge.layout import LayoutSettings
+from flowforge.layout_engine_v2 import EngineV2Score, _assign_scc_longest_path_layers
+from flowforge.layout_engine_v2_phase5 import (
+ _build_mixed_graph,
+ _mixed_candidate_is_better,
+ _mixed_rejection_reason,
+ _place_mixed_global_flow,
+)
+from flowforge.model import Group, Link, Node, Workflow
+
+
+def _score(
+ *,
+ crossings: int,
+ rtl: int,
+ width: float,
+ height: float,
+ overlaps: int = 0,
+) -> EngineV2Score:
+ return EngineV2Score(
+ total=0.0,
+ crossings=crossings,
+ right_to_left_links=rtl,
+ movable_overlaps=overlaps,
+ link_length=10_000.0,
+ width=width,
+ height=height,
+ )
+
+
+def _attach_link(workflow: Workflow, link: Link) -> None:
+ workflow.links[link.id] = link
+ workflow.nodes[link.source].output_links.append(link.id)
+ workflow.nodes[link.target].input_links.append(link.id)
+
+
+def _mixed_chain_workflow() -> Workflow:
+ workflow = Workflow()
+ group_a_node = Node(id=1, type="GroupA", x=150, y=150, size=[200, 100])
+ group_b_node = Node(id=2, type="GroupB", x=4_050, y=150, size=[200, 100])
+ middle = Node(id=10, type="Middle", x=2_000, y=150, size=[200, 100])
+ last = Node(id=11, type="Last", x=6_000, y=150, size=[200, 100])
+
+ workflow.nodes = {
+ group_a_node.id: group_a_node,
+ group_b_node.id: group_b_node,
+ middle.id: middle,
+ last.id: last,
+ }
+ workflow.groups = [
+ Group(
+ id=100,
+ name="A",
+ nodes=[group_a_node],
+ bounding=[100, 100, 300, 220],
+ ),
+ Group(
+ id=200,
+ name="B",
+ nodes=[group_b_node],
+ bounding=[4_000, 100, 300, 220],
+ ),
+ ]
+ workflow.ungrouped_nodes = [middle, last]
+
+ _attach_link(
+ workflow,
+ Link(id=1, source=1, source_port=0, target=10, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=2, source=10, source_port=0, target=2, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=3, source=2, source_port=0, target=11, target_port=0, type="DATA"),
+ )
+ return workflow
+
+
+def test_mixed_graph_keeps_group_ungrouped_dependency_chain():
+ workflow = _mixed_chain_workflow()
+
+ specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
+
+ assert set(specs) == {
+ ("group", 100),
+ ("group", 200),
+ ("node", 10),
+ ("node", 11),
+ }
+ assert edge_weights
+ layers = _assign_scc_longest_path_layers(set(spec_by_vertex), adjacency)
+ by_spec = {spec_by_vertex[vertex]: layer for vertex, layer in layers.items()}
+
+ assert by_spec[("group", 100)] == 0
+ assert by_spec[("node", 10)] == 1
+ assert by_spec[("group", 200)] == 2
+ assert by_spec[("node", 11)] == 3
+
+
+def test_mixed_global_placement_preserves_group_internal_geometry():
+ workflow = _mixed_chain_workflow()
+ extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
+ workflow.nodes[extra.id] = extra
+ workflow.ungrouped_nodes.append(extra)
+ _attach_link(
+ workflow,
+ Link(id=4, source=11, source_port=0, target=12, target_port=0, type="DATA"),
+ )
+
+ group = workflow.groups[0]
+ member = group.nodes[0]
+ original_offset = (
+ member.x - group.bounding[0],
+ member.y - group.bounding[1],
+ )
+ original_span = max(
+ node.x + node.size[0] for node in workflow.nodes.values()
+ ) - min(node.x for node in workflow.nodes.values())
+
+ changed = _place_mixed_global_flow(
+ workflow,
+ LayoutSettings(node_x_distance=100, node_y_distance=80, wrap_columns=True),
+ workflow_width=7_000,
+ )
+
+ new_span = max(
+ node.x + node.size[0] for node in workflow.nodes.values()
+ ) - min(node.x for node in workflow.nodes.values())
+ new_offset = (
+ member.x - group.bounding[0],
+ member.y - group.bounding[1],
+ )
+
+ assert changed
+ assert new_span < original_span
+ assert new_offset == original_offset
+
+
+def test_phase5_skips_pinned_group_geometry():
+ workflow = _mixed_chain_workflow()
+ extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
+ workflow.nodes[extra.id] = extra
+ workflow.ungrouped_nodes.append(extra)
+ workflow.groups[0].pinned = True
+
+ changed = _place_mixed_global_flow(
+ workflow,
+ LayoutSettings(),
+ workflow_width=7_000,
+ )
+
+ assert not changed
+
+
+def test_phase5_candidate_requires_full_strict_gate():
+ baseline = _score(crossings=100, rtl=20, width=6_000, height=4_000)
+ accepted = _score(crossings=90, rtl=18, width=5_000, height=4_100)
+ narrow_gain = _score(crossings=80, rtl=15, width=5_800, height=3_800)
+ area_regression = _score(crossings=80, rtl=15, width=5_000, height=5_000)
+ crossing_regression = _score(crossings=101, rtl=20, width=4_000, height=4_000)
+ rtl_regression = _score(crossings=100, rtl=21, width=4_000, height=4_000)
+
+ assert _mixed_candidate_is_better(accepted, baseline)
+ assert not _mixed_candidate_is_better(narrow_gain, baseline)
+ assert not _mixed_candidate_is_better(area_regression, baseline)
+ assert not _mixed_candidate_is_better(crossing_regression, baseline)
+ assert not _mixed_candidate_is_better(rtl_regression, baseline)
+ assert (
+ _mixed_rejection_reason(narrow_gain, baseline)
+ == "insufficient_final_width_reduction"
+ )
+ assert _mixed_rejection_reason(area_regression, baseline) == "area_regression"
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
new file mode 100644
index 0000000..70778fd
--- /dev/null
+++ b/tools/report_phase5_diagnostics.py
@@ -0,0 +1,83 @@
+"""Targeted diagnostics for Phase 5 mixed global-flow refinement."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
+from flowforge.layout_engine_v2_phase5 import diagnose_phase5
+from flowforge.optimizer import optimize as optimize_workflow
+from flowforge.parser import parse_comfyui_workflow
+from flowforge.workflow_validation import discover_workflow_files
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Explain why Phase 5 accepts or rejects selected workflows."
+ )
+ parser.add_argument("root", type=Path, help="Workflow corpus root.")
+ parser.add_argument(
+ "--match",
+ action="append",
+ default=[],
+ help="Case-insensitive substring filter; may be repeated.",
+ )
+ parser.add_argument(
+ "--optimize",
+ action="store_true",
+ help="Run FlowForge Optimize before the Phase 4 baseline.",
+ )
+ args = parser.parse_args()
+
+ files = discover_workflow_files(args.root)
+ matches = [value.casefold() for value in args.match]
+ selected = [
+ path
+ for path in files
+ if not matches
+ or any(value in str(path.relative_to(args.root)).casefold() for value in matches)
+ ]
+
+ if not selected:
+ print("No matching workflow files found.")
+ return 1
+
+ for path in selected:
+ relative = path.relative_to(args.root)
+ data = json.loads(path.read_text(encoding="utf-8"))
+ workflow = parse_comfyui_workflow(data)
+ if args.optimize:
+ workflow = optimize_workflow(workflow)
+ baseline = apply_phase4_layout(workflow)
+ diag = diagnose_phase5(baseline)
+
+ print(relative)
+ print(
+ " mixed-graph: "
+ f"groups={diag.group_vertices} "
+ f"ungrouped={diag.ungrouped_vertices} "
+ f"edges={diag.graph_edges} "
+ f"layers={diag.layers}"
+ )
+ if diag.attempted:
+ print(
+ " proposal: "
+ f"{diag.baseline_width:.0f}x{diag.baseline_height:.0f} -> "
+ f"{diag.proposed_width:.0f}x{diag.proposed_height:.0f}; "
+ f"crossings={diag.baseline_crossings}->{diag.proposed_crossings}; "
+ f"rtl={diag.baseline_rtl}->{diag.proposed_rtl}; "
+ f"accepted={diag.accepted}; reason={diag.rejection_reason}"
+ )
+ else:
+ print(
+ " proposal: not attempted; "
+ f"reason={diag.rejection_reason}"
+ )
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From aaf571ba2f2dcb458366cb2a6ea8fa1497dba9f3 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:40:54 +0200
Subject: [PATCH 062/105] Fix Phase 5 mixed edge typing
---
flowforge/layout_engine_v2_phase5.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 59fed14..2ee09d3 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -559,9 +559,9 @@ def _weighted_mixed_order(
predecessors: dict[MixedOrderVertex, list[tuple[MixedOrderVertex, int]]] = {}
successors: dict[MixedOrderVertex, list[tuple[MixedOrderVertex, int]]] = {}
- for source, target, weight in expanded_edges:
- successors.setdefault(source, []).append((target, weight))
- predecessors.setdefault(target, []).append((source, weight))
+ for expanded_source, expanded_target, weight in expanded_edges:
+ successors.setdefault(expanded_source, []).append((expanded_target, weight))
+ predecessors.setdefault(expanded_target, []).append((expanded_source, weight))
for _ in range(MIXED_SWEEP_ROUNDS):
for layer in range(1, max_layer + 1):
From 2bfe3c107e2d440b1db8da1ee53e190d47a5999b Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:45:36 +0200
Subject: [PATCH 063/105] Use monotonic Phase 5 layer placement
---
docs/LAYOUT_ENGINE_V2.md | 31 ++++++++++++----
flowforge/layout_engine_v2_phase5.py | 52 ++++++++++++++++++---------
tests/test_layout_engine_v2_phase5.py | 4 +++
3 files changed, 64 insertions(+), 23 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 56b9768..5332d6f 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -473,8 +473,26 @@ are not lost. The graph then uses:
2. virtual dummy vertices for long mixed edges;
3. weighted median sweeps;
4. weighted local transposition;
-5. shared physical layer placement;
-6. boustrophedon row wrapping.
+5. shared physical layer placement.
+
+The first physical-placement prototype wrapped those layers into
+boustrophedon rows. Targeted diagnostics showed that this was structurally
+wrong for the strict Phase 5 quality contract:
+
+- Jibs: width improved from 5,977 to 4,697 px, but crossings rose from 756 to
+ 1,411 and RTL from 41 to 46;
+- Flux2 VTON 6.0.2: width improved from 6,723 to 3,331 px, but crossings rose
+ from 31 to 62 and RTL from 6 to 13;
+- Flux2 VTON 6.0.2.7: width improved from 9,162 to 4,232 px, but crossings rose
+ from 44 to 82 and RTL from 11 to 21.
+
+The height also grew substantially, especially for Flux2 VTON 6.0.2.7
+(3,510 -> 9,444 px). The mixed dependency graph is therefore retained, but
+physical placement now keeps graph layers monotonic from left to right in one
+shared vertical band. Graph ordering and physical wrapping are treated as
+separate concerns; row wrapping is no longer part of Phase 5 because reversing
+alternate rows inherently introduces right-to-left flow and long cross-row
+segments.
A group always moves as one rectangle with every member node, preserving its
internal geometry. The established finalizer then re-applies control, text
@@ -510,10 +528,11 @@ baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
text-preview, virtual-hub, decorative, and pin-specific contracts are still
authoritative after mixed placement.
-Phase 5 is still experimental. Its next gate is the targeted Jibs/Flux2
-diagnostic run followed by the full 81-workflow Optimize + Layout corpus. The
-aggregate structural reference remains 4,127 crossings / 339 RTL until corpus
-evidence justifies updating it.
+Phase 5 is still experimental. Its next gate is a second targeted
+Jibs/Flux2 diagnostic run using monotonic mixed-layer placement. Only if that
+preserves graph quality while reducing width should the full 81-workflow
+Optimize + Layout corpus be run. The aggregate structural reference remains
+4,127 crossings / 339 RTL until corpus evidence justifies updating it.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 2ee09d3..980b7dc 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -10,6 +10,10 @@
Established local-placement special cases remain outside the mixed graph and
are re-applied by the normal refinement finalizer.
+Physical placement keeps dependency layers monotonic from left to right. This
+is intentionally separate from graph ordering: row wrapping is not used because
+reversing alternate rows creates right-to-left links and long cross-row wires.
+
The acceptance gate is deliberately strict: no additional movable overlaps,
crossings, or right-to-left links; at least 8% workflow-width reduction; and no
workflow-area growth.
@@ -32,8 +36,6 @@
_move_group_geometry,
_node_visual_height,
_node_visual_width,
- _toplevel_wrap_row_width_cap,
- _wrap_layer_columns,
)
from .layout_engine_v2 import (
EngineV2Score,
@@ -53,7 +55,6 @@
MIXED_GLOBAL_MIN_WIDTH = 5_000.0
MIXED_GLOBAL_MIN_GROUPS = 2
MIXED_GLOBAL_MIN_UNGROUPED = 3
-MIXED_GLOBAL_TARGET_WIDTH_RATIO = 0.70
MIXED_GLOBAL_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO = 0.08
MIXED_GLOBAL_MAX_AREA_RATIO = 1.0
MIXED_SWEEP_ROUNDS = 6
@@ -339,7 +340,8 @@ def _place_mixed_global_flow(
settings: LayoutSettings,
workflow_width: float,
) -> bool:
- """Place groups and eligible ungrouped nodes in one shared layered flow."""
+ """Place mixed-flow layers in monotonic left-to-right columns."""
+ del workflow_width
specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
group_count = sum(1 for kind, _value in specs if kind == "group")
ungrouped_count = sum(1 for kind, _value in specs if kind == "node")
@@ -399,29 +401,30 @@ def _place_mixed_global_flow(
sum(heights) + vertical_gap * max(0, len(heights) - 1),
)
- max_layer_width = max(width for width, _height in layer_sizes.values())
- automatic_cap = _toplevel_wrap_row_width_cap(layer_sizes)
- target_cap = max(
- max_layer_width,
- min(automatic_cap, workflow_width * MIXED_GLOBAL_TARGET_WIDTH_RATIO),
- )
-
- real_specs = [spec_by_vertex[vertex] for vertices in layer_to_real.values() for vertex in vertices]
+ real_specs = [
+ spec_by_vertex[vertex]
+ for vertices in layer_to_real.values()
+ for vertex in vertices
+ ]
base_x = min(
- (_mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[0] for spec in real_specs),
+ (
+ _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[0]
+ for spec in real_specs
+ ),
default=50.0,
)
base_y = min(
- (_mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[1] for spec in real_specs),
+ (
+ _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)[1]
+ for spec in real_specs
+ ),
default=50.0,
)
- layer_positions = _wrap_layer_columns(
+ layer_positions = _monotonic_layer_positions(
layer_sizes,
- target_cap,
base_x,
base_y,
horizontal_gap,
- vertical_gap,
)
changed = False
@@ -448,6 +451,21 @@ def _place_mixed_global_flow(
return changed
+def _monotonic_layer_positions(
+ layer_sizes: dict[int, tuple[float, float]],
+ base_x: float,
+ base_y: float,
+ horizontal_gap: float,
+) -> dict[int, tuple[float, float]]:
+ """Keep dependency layers monotonic in X while sharing one vertical band."""
+ positions: dict[int, tuple[float, float]] = {}
+ current_x = base_x
+ for layer in sorted(layer_sizes):
+ positions[layer] = (current_x, base_y)
+ current_x += layer_sizes[layer][0] + horizontal_gap
+ return positions
+
+
def _mixed_vertex_size(
spec: MixedSpec,
groups_by_id: dict[int, Group],
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index e944010..0298db3 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -138,6 +138,10 @@ def test_mixed_global_placement_preserves_group_internal_geometry():
assert changed
assert new_span < original_span
assert new_offset == original_offset
+ assert workflow.groups[0].bounding[0] < middle.x
+ assert middle.x < workflow.groups[1].bounding[0]
+ assert workflow.groups[1].bounding[0] < last.x
+ assert last.x < extra.x
def test_phase5_skips_pinned_group_geometry():
From f61103934e35543d70618e0db1b82f65b6617c7c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:46:08 +0200
Subject: [PATCH 064/105] Fix Phase 5 monotonic placement test
---
tests/test_layout_engine_v2_phase5.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 0298db3..f36dbc9 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -138,10 +138,10 @@ def test_mixed_global_placement_preserves_group_internal_geometry():
assert changed
assert new_span < original_span
assert new_offset == original_offset
- assert workflow.groups[0].bounding[0] < middle.x
- assert middle.x < workflow.groups[1].bounding[0]
- assert workflow.groups[1].bounding[0] < last.x
- assert last.x < extra.x
+ assert workflow.groups[0].bounding[0] < workflow.nodes[10].x
+ assert workflow.nodes[10].x < workflow.groups[1].bounding[0]
+ assert workflow.groups[1].bounding[0] < workflow.nodes[11].x
+ assert workflow.nodes[11].x < extra.x
def test_phase5_skips_pinned_group_geometry():
From c717b33ddcc0ac76c86694080c26a94d2a855b33 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:54:35 +0200
Subject: [PATCH 065/105] Evaluate Phase 5 physical placement variants
---
docs/LAYOUT_ENGINE_V2.md | 37 +++-
flowforge/layout_engine_v2_phase5.py | 263 +++++++++++++++++++++-----
tests/test_layout_engine_v2_phase5.py | 37 ++++
tools/report_phase5_diagnostics.py | 4 +-
4 files changed, 287 insertions(+), 54 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 5332d6f..2e8b9b7 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -494,6 +494,31 @@ separate concerns; row wrapping is no longer part of Phase 5 because reversing
alternate rows inherently introduces right-to-left flow and long cross-row
segments.
+The first monotonic rerun removed the catastrophic graph regressions and showed
+that the graph itself is useful:
+
+- Jibs: 5,977 x 12,448 -> 8,747 x 9,087, crossings 756 -> 475, RTL 41 -> 29.
+ Graph quality improved strongly, but the workflow became wider, so the strict
+ width gate rejected it.
+- Flux2 VTON 6.0.2: 6,723 x 4,861 -> 6,107 x 4,869, crossings 31 -> 41,
+ RTL 6 -> 4. Width and area improved, but physical crossings regressed.
+- Flux2 VTON 6.0.2.7: 9,162 x 3,510 -> 7,746 x 4,332, crossings 44 -> 36,
+ RTL 11 -> 8. Width fell by about 15.5% and graph quality improved, but area
+ grew by about 4.3%.
+
+This isolates the remaining problem to physical realization inside the monotonic
+layers rather than graph topology. Phase 5 now evaluates a small conservative
+candidate matrix over the same mixed graph:
+
+- weighted graph order and baseline-stable vertical order;
+- standard vertical gap;
+- node vertical gap;
+- a compact gap equal to half the node gap, clamped to a 12 px minimum.
+
+With default spacing this yields 100, 80, and 40 px gap variants. The strict
+acceptance gate is unchanged; no candidate can trade crossings, RTL, overlaps,
+width, or area outside the existing limits.
+
A group always moves as one rectangle with every member node, preserving its
internal geometry. The established finalizer then re-applies control, text
preview, virtual Set/Get, group-overlap, and pinned-geometry contracts.
@@ -528,11 +553,13 @@ baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
text-preview, virtual-hub, decorative, and pin-specific contracts are still
authoritative after mixed placement.
-Phase 5 is still experimental. Its next gate is a second targeted
-Jibs/Flux2 diagnostic run using monotonic mixed-layer placement. Only if that
-preserves graph quality while reducing width should the full 81-workflow
-Optimize + Layout corpus be run. The aggregate structural reference remains
-4,127 crossings / 339 RTL until corpus evidence justifies updating it.
+Phase 5 is still experimental. Its next gate is a targeted rerun of the
+Jibs/Flux2 diagnostics using the multi-variant monotonic placement search. The
+reporter now prints the number of evaluated candidates and the selected proposal
+variant. Only if at least one targeted workflow satisfies the full strict gate
+should the full 81-workflow Optimize + Layout corpus be run. The aggregate
+structural reference remains 4,127 crossings / 339 RTL until corpus evidence
+justifies updating it.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 980b7dc..dafd639 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -13,6 +13,8 @@
Physical placement keeps dependency layers monotonic from left to right. This
is intentionally separate from graph ordering: row wrapping is not used because
reversing alternate rows creates right-to-left links and long cross-row wires.
+Phase 5 evaluates both weighted graph order and baseline-stable order, each with
+conservative vertical-gap profiles derived from the existing layout settings.
The acceptance gate is deliberately strict: no additional movable overlaps,
crossings, or right-to-left links; at least 8% workflow-width reduction; and no
@@ -57,6 +59,8 @@
MIXED_GLOBAL_MIN_UNGROUPED = 3
MIXED_GLOBAL_MIN_ACCEPTED_WIDTH_REDUCTION_RATIO = 0.08
MIXED_GLOBAL_MAX_AREA_RATIO = 1.0
+MIXED_GLOBAL_MIN_VERTICAL_GAP = 12.0
+MIXED_GLOBAL_COMPACT_GAP_RATIO = 0.5
MIXED_SWEEP_ROUNDS = 6
MIXED_TRANSPOSE_PASSES = 6
@@ -74,6 +78,8 @@ class Phase5Diagnostics:
ungrouped_vertices: int
graph_edges: int
layers: int
+ candidate_count: int
+ proposal_variant: str | None
attempted: bool
accepted: bool
rejection_reason: str
@@ -87,52 +93,62 @@ class Phase5Diagnostics:
proposed_rtl: int | None
+@dataclass
+class _Phase5Candidate:
+ """One scored physical realization of the shared mixed dependency graph."""
+
+ name: str
+ workflow: Workflow
+ score: EngineV2Score
+
+
def apply_best_layout(
workflow: Workflow,
settings: LayoutSettings | None = None,
candidate_count: int | None = None,
) -> Workflow:
- """Run Phase 4, then try one shared group/ungrouped global-flow candidate."""
+ """Run Phase 4, then evaluate conservative mixed-flow placement variants."""
settings = settings or LayoutSettings()
baseline = _apply_phase4_best_layout(workflow, settings, candidate_count)
baseline_score = _score_engine_v2(baseline)
if baseline_score.width < MIXED_GLOBAL_MIN_WIDTH:
return baseline
- candidate = deepcopy(baseline)
- if not _place_mixed_global_flow(candidate, settings, baseline_score.width):
- return baseline
-
- _finalize_refinement(candidate, settings)
- candidate_score = _score_engine_v2(candidate)
- if _mixed_candidate_is_better(candidate_score, baseline_score):
+ candidates = _phase5_candidate_variants(baseline, settings, baseline_score)
+ selected = _select_accepted_phase5_candidate(candidates, baseline_score)
+ if selected is not None:
logger.info(
- "Phase 5 mixed global-flow accepted: "
+ "Phase 5 mixed global-flow accepted (%s): "
"size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s",
+ selected.name,
baseline_score.width,
baseline_score.height,
- candidate_score.width,
- candidate_score.height,
+ selected.score.width,
+ selected.score.height,
baseline_score.crossings,
- candidate_score.crossings,
+ selected.score.crossings,
baseline_score.right_to_left_links,
- candidate_score.right_to_left_links,
+ selected.score.right_to_left_links,
+ )
+ return selected.workflow
+
+ rejected = _best_diagnostic_candidate(candidates, baseline_score)
+ if rejected is not None:
+ logger.info(
+ "Phase 5 mixed global-flow rejected (%s): "
+ "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s; "
+ "reason=%s",
+ rejected.name,
+ baseline_score.width,
+ baseline_score.height,
+ rejected.score.width,
+ rejected.score.height,
+ baseline_score.crossings,
+ rejected.score.crossings,
+ baseline_score.right_to_left_links,
+ rejected.score.right_to_left_links,
+ _mixed_rejection_reason(rejected.score, baseline_score),
)
- return candidate
-
- logger.info(
- "Phase 5 mixed global-flow rejected: "
- "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s; reason=%s",
- baseline_score.width,
- baseline_score.height,
- candidate_score.width,
- candidate_score.height,
- baseline_score.crossings,
- candidate_score.crossings,
- baseline_score.right_to_left_links,
- candidate_score.right_to_left_links,
- _mixed_rejection_reason(candidate_score, baseline_score),
- )
return baseline
@@ -167,6 +183,8 @@ def diagnose_phase5(
ungrouped_vertices=ungrouped_count,
graph_edges=len(edge_weights),
layers=layer_count,
+ candidate_count=0,
+ proposal_variant=None,
attempted=False,
accepted=False,
rejection_reason=precheck_reason,
@@ -180,14 +198,17 @@ def diagnose_phase5(
proposed_rtl=None,
)
- candidate = deepcopy(workflow)
- moved = _place_mixed_global_flow(candidate, settings, baseline_score.width)
- if not moved:
+ candidates = _phase5_candidate_variants(workflow, settings, baseline_score)
+ selected = _select_accepted_phase5_candidate(candidates, baseline_score)
+ proposal = selected or _best_diagnostic_candidate(candidates, baseline_score)
+ if proposal is None:
return Phase5Diagnostics(
group_vertices=group_count,
ungrouped_vertices=ungrouped_count,
graph_edges=len(edge_weights),
layers=layer_count,
+ candidate_count=0,
+ proposal_variant=None,
attempted=False,
accepted=False,
rejection_reason="no_geometry_change",
@@ -201,32 +222,140 @@ def diagnose_phase5(
proposed_rtl=None,
)
- _finalize_refinement(candidate, settings)
- candidate_score = _score_engine_v2(candidate)
- accepted = _mixed_candidate_is_better(candidate_score, baseline_score)
+ accepted = selected is not None
return Phase5Diagnostics(
group_vertices=group_count,
ungrouped_vertices=ungrouped_count,
graph_edges=len(edge_weights),
layers=layer_count,
+ candidate_count=len(candidates),
+ proposal_variant=proposal.name,
attempted=True,
accepted=accepted,
rejection_reason=(
"accepted"
if accepted
- else _mixed_rejection_reason(candidate_score, baseline_score)
+ else _mixed_rejection_reason(proposal.score, baseline_score)
),
baseline_width=baseline_score.width,
baseline_height=baseline_score.height,
- proposed_width=candidate_score.width,
- proposed_height=candidate_score.height,
+ proposed_width=proposal.score.width,
+ proposed_height=proposal.score.height,
baseline_crossings=baseline_score.crossings,
- proposed_crossings=candidate_score.crossings,
+ proposed_crossings=proposal.score.crossings,
baseline_rtl=baseline_score.right_to_left_links,
- proposed_rtl=candidate_score.right_to_left_links,
+ proposed_rtl=proposal.score.right_to_left_links,
)
+def _phase5_candidate_variants(
+ baseline: Workflow,
+ settings: LayoutSettings,
+ baseline_score: EngineV2Score,
+) -> list[_Phase5Candidate]:
+ """Build scored physical variants without changing the mixed dependency graph."""
+ candidates: list[_Phase5Candidate] = []
+ for order_mode in ("weighted", "stable"):
+ for vertical_gap in _phase5_vertical_gaps(settings):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ vertical_gap=vertical_gap,
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ candidates.append(
+ _Phase5Candidate(
+ name=f"{order_mode}-gap-{vertical_gap:g}",
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ )
+ )
+ return candidates
+
+
+def _phase5_vertical_gaps(settings: LayoutSettings) -> list[float]:
+ """Return conservative spacing variants derived from existing layout settings."""
+ standard = max(settings.group_v_gap, settings.node_v_gap)
+ node_gap = settings.node_v_gap
+ compact = max(
+ MIXED_GLOBAL_MIN_VERTICAL_GAP,
+ min(standard, node_gap) * MIXED_GLOBAL_COMPACT_GAP_RATIO,
+ )
+ result: list[float] = []
+ for value in (standard, node_gap, compact):
+ if not any(math.isclose(value, existing) for existing in result):
+ result.append(value)
+ return result
+
+
+def _select_accepted_phase5_candidate(
+ candidates: list[_Phase5Candidate],
+ baseline: EngineV2Score,
+) -> _Phase5Candidate | None:
+ accepted = [
+ candidate
+ for candidate in candidates
+ if _mixed_candidate_is_better(candidate.score, baseline)
+ ]
+ if not accepted:
+ return None
+ return min(accepted, key=_phase5_candidate_quality_key)
+
+
+def _phase5_candidate_quality_key(
+ candidate: _Phase5Candidate,
+) -> tuple[int, int, int, float, float, float, str]:
+ score = candidate.score
+ return (
+ score.movable_overlaps,
+ score.crossings,
+ score.right_to_left_links,
+ score.width * score.height,
+ score.width,
+ score.height,
+ candidate.name,
+ )
+
+
+def _best_diagnostic_candidate(
+ candidates: list[_Phase5Candidate],
+ baseline: EngineV2Score,
+) -> _Phase5Candidate | None:
+ if not candidates:
+ return None
+
+ reason_rank = {
+ "accepted": 0,
+ "area_regression": 1,
+ "insufficient_final_width_reduction": 2,
+ "rtl_regression": 3,
+ "crossing_regression": 4,
+ "movable_overlap_regression": 5,
+ "invalid_baseline_width": 6,
+ }
+
+ def key(candidate: _Phase5Candidate) -> tuple[int, int, int, float, float, str]:
+ reason = _mixed_rejection_reason(candidate.score, baseline)
+ return (
+ reason_rank.get(reason, 99),
+ max(0, candidate.score.crossings - baseline.crossings),
+ max(
+ 0,
+ candidate.score.right_to_left_links
+ - baseline.right_to_left_links,
+ ),
+ candidate.score.width * candidate.score.height,
+ candidate.score.width,
+ candidate.name,
+ )
+
+ return min(candidates, key=key)
+
+
def _mixed_precheck_reason(
workflow: Workflow,
baseline_score: EngineV2Score,
@@ -339,6 +468,9 @@ def _place_mixed_global_flow(
workflow: Workflow,
settings: LayoutSettings,
workflow_width: float,
+ *,
+ order_mode: str = "weighted",
+ vertical_gap: float | None = None,
) -> bool:
"""Place mixed-flow layers in monotonic left-to-right columns."""
del workflow_width
@@ -357,12 +489,17 @@ def _place_mixed_global_flow(
if len(set(layers.values())) <= 1:
return False
- order = _weighted_mixed_order(
- workflow,
- spec_by_vertex,
- layers,
- edge_weights,
- )
+ if order_mode == "weighted":
+ order = _weighted_mixed_order(
+ workflow,
+ spec_by_vertex,
+ layers,
+ edge_weights,
+ )
+ elif order_mode == "stable":
+ order = _stable_mixed_order(workflow, spec_by_vertex, layers)
+ else:
+ raise ValueError(f"Unknown Phase 5 order mode: {order_mode}")
layer_to_real = {
layer: [
vertex
@@ -382,7 +519,11 @@ def _place_mixed_global_flow(
groups_by_id = {group.id: group for group in workflow.groups}
nodes_by_id = {node.id: node for node in workflow.nodes.values()}
layer_sizes: dict[int, tuple[float, float]] = {}
- vertical_gap = max(settings.group_v_gap, settings.node_v_gap)
+ placement_vertical_gap = (
+ max(settings.group_v_gap, settings.node_v_gap)
+ if vertical_gap is None
+ else max(MIXED_GLOBAL_MIN_VERTICAL_GAP, vertical_gap)
+ )
horizontal_gap = max(settings.group_h_gap, settings.node_h_gap)
for layer, vertices in layer_to_real.items():
@@ -398,7 +539,7 @@ def _place_mixed_global_flow(
heights.append(height)
layer_sizes[layer] = (
max(widths),
- sum(heights) + vertical_gap * max(0, len(heights) - 1),
+ sum(heights) + placement_vertical_gap * max(0, len(heights) - 1),
)
real_specs = [
@@ -447,7 +588,7 @@ def _place_mixed_global_flow(
groups_by_id,
nodes_by_id,
)
- current_y += height + vertical_gap
+ current_y += height + placement_vertical_gap
return changed
@@ -524,6 +665,32 @@ def _move_mixed_vertex(
node.y = y
+def _stable_mixed_order(
+ workflow: Workflow,
+ spec_by_vertex: dict[int, MixedSpec],
+ layers: dict[int, int],
+) -> dict[int, list[MixedOrderVertex]]:
+ """Preserve baseline vertical order inside each mixed dependency layer."""
+ groups_by_id = {group.id: group for group in workflow.groups}
+ nodes_by_id = {node.id: node for node in workflow.nodes.values()}
+ layer_to_vertices: dict[int, list[MixedOrderVertex]] = {}
+ for vertex, layer in layers.items():
+ layer_to_vertices.setdefault(layer, []).append(vertex)
+
+ for vertices in layer_to_vertices.values():
+ vertices.sort(
+ key=lambda vertex: (
+ _mixed_vertex_center_y(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ ),
+ _mixed_order_key(vertex),
+ )
+ )
+ return layer_to_vertices
+
+
def _weighted_mixed_order(
workflow: Workflow,
spec_by_vertex: dict[int, MixedSpec],
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index f36dbc9..b8c8dc6 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -6,6 +6,7 @@
_build_mixed_graph,
_mixed_candidate_is_better,
_mixed_rejection_reason,
+ _phase5_vertical_gaps,
_place_mixed_global_flow,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -178,3 +179,39 @@ def test_phase5_candidate_requires_full_strict_gate():
== "insufficient_final_width_reduction"
)
assert _mixed_rejection_reason(area_regression, baseline) == "area_regression"
+
+
+
+def test_phase5_vertical_gap_profiles_use_existing_spacing_controls():
+ settings = LayoutSettings(
+ node_x_distance=100,
+ node_y_distance=80,
+ wrap_columns=True,
+ )
+
+ assert _phase5_vertical_gaps(settings) == [100, 80, 40]
+
+
+def test_stable_order_supports_compact_vertical_gap():
+ workflow = _mixed_chain_workflow()
+ extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
+ workflow.nodes[extra.id] = extra
+ workflow.ungrouped_nodes.append(extra)
+ _attach_link(
+ workflow,
+ Link(id=4, source=11, source_port=0, target=12, target_port=0, type="DATA"),
+ )
+
+ changed = _place_mixed_global_flow(
+ workflow,
+ LayoutSettings(node_x_distance=100, node_y_distance=80),
+ workflow_width=7_000,
+ order_mode="stable",
+ vertical_gap=40,
+ )
+
+ assert changed
+ assert workflow.groups[0].bounding[0] < workflow.nodes[10].x
+ assert workflow.nodes[10].x < workflow.groups[1].bounding[0]
+ assert workflow.groups[1].bounding[0] < workflow.nodes[11].x
+ assert workflow.nodes[11].x < extra.x
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 70778fd..558cb30 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -59,11 +59,13 @@ def main() -> int:
f"groups={diag.group_vertices} "
f"ungrouped={diag.ungrouped_vertices} "
f"edges={diag.graph_edges} "
- f"layers={diag.layers}"
+ f"layers={diag.layers} "
+ f"candidates={diag.candidate_count}"
)
if diag.attempted:
print(
" proposal: "
+ f"variant={diag.proposal_variant}; "
f"{diag.baseline_width:.0f}x{diag.baseline_height:.0f} -> "
f"{diag.proposed_width:.0f}x{diag.proposed_height:.0f}; "
f"crossings={diag.baseline_crossings}->{diag.proposed_crossings}; "
From eacf0b9bf71c7e32bd4c9be315d32d7eaccd3055 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:55:38 +0200
Subject: [PATCH 066/105] Fix Phase 5 stable-order typing
---
flowforge/layout_engine_v2_phase5.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index dafd639..22e89ec 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -669,11 +669,11 @@ def _stable_mixed_order(
workflow: Workflow,
spec_by_vertex: dict[int, MixedSpec],
layers: dict[int, int],
-) -> dict[int, list[MixedOrderVertex]]:
+) -> dict[int, list[int]]:
"""Preserve baseline vertical order inside each mixed dependency layer."""
groups_by_id = {group.id: group for group in workflow.groups}
nodes_by_id = {node.id: node for node in workflow.nodes.values()}
- layer_to_vertices: dict[int, list[MixedOrderVertex]] = {}
+ layer_to_vertices: dict[int, list[int]] = {}
for vertex, layer in layers.items():
layer_to_vertices.setdefault(layer, []).append(vertex)
From 6072607e399da5fecca53fd78ac2dcc5f504e196 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:56:32 +0200
Subject: [PATCH 067/105] Normalize Phase 5 order variant typing
---
flowforge/layout_engine_v2_phase5.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 22e89ec..a73cabc 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -489,6 +489,7 @@ def _place_mixed_global_flow(
if len(set(layers.values())) <= 1:
return False
+ order: dict[int, list[MixedOrderVertex]]
if order_mode == "weighted":
order = _weighted_mixed_order(
workflow,
@@ -497,7 +498,10 @@ def _place_mixed_global_flow(
edge_weights,
)
elif order_mode == "stable":
- order = _stable_mixed_order(workflow, spec_by_vertex, layers)
+ stable_order = _stable_mixed_order(workflow, spec_by_vertex, layers)
+ order = {layer: [] for layer in stable_order}
+ for layer, vertices in stable_order.items():
+ order[layer].extend(vertices)
else:
raise ValueError(f"Unknown Phase 5 order mode: {order_mode}")
layer_to_real = {
From 1307bc61e26ae569e60cdc64cda4982f41c16f82 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 09:05:47 +0200
Subject: [PATCH 068/105] Document first accepted Phase 5 layout
---
docs/LAYOUT_ENGINE_V2.md | 31 ++++++++++++++++++++++++-------
1 file changed, 24 insertions(+), 7 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 2e8b9b7..5e42ebe 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -519,6 +519,24 @@ With default spacing this yields 100, 80, and 40 px gap variants. The strict
acceptance gate is unchanged; no candidate can trade crossings, RTL, overlaps,
width, or area outside the existing limits.
+The targeted multi-variant rerun produced the first accepted Phase 5 result:
+
+- Jibs: the best diagnostic proposal was `stable-gap-100`,
+ `5,977 x 12,448 -> 8,747 x 8,895`, crossings `756 -> 493`, RTL
+ `41 -> 29`. It was correctly rejected because width increased instead of
+ falling by at least 8%.
+- Flux2 VTON 6.0.2: the best diagnostic proposal was `stable-gap-40`,
+ `6,723 x 4,861 -> 6,107 x 4,869`, crossings `31 -> 40`, RTL
+ `6 -> 4`. It was correctly rejected for crossing regression.
+- Flux2 VTON 6.0.2.7: `weighted-gap-40` was accepted,
+ `9,162 x 3,510 -> 7,746 x 4,032`, crossings `44 -> 43`, RTL
+ `11 -> 8`. Width fell by about 15.5%, workflow area also fell, and all
+ strict graph-quality constraints were preserved.
+
+This is the first evidence that shared mixed-flow placement can improve a real
+wide corpus workflow without relaxing the safety gate. No further placement
+heuristics should be added before measuring the complete corpus.
+
A group always moves as one rectangle with every member node, preserving its
internal geometry. The established finalizer then re-applies control, text
preview, virtual Set/Get, group-overlap, and pinned-geometry contracts.
@@ -553,13 +571,12 @@ baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
text-preview, virtual-hub, decorative, and pin-specific contracts are still
authoritative after mixed placement.
-Phase 5 is still experimental. Its next gate is a targeted rerun of the
-Jibs/Flux2 diagnostics using the multi-variant monotonic placement search. The
-reporter now prints the number of evaluated candidates and the selected proposal
-variant. Only if at least one targeted workflow satisfies the full strict gate
-should the full 81-workflow Optimize + Layout corpus be run. The aggregate
-structural reference remains 4,127 crossings / 339 RTL until corpus evidence
-justifies updating it.
+Phase 5 is still experimental. The targeted gate has now passed because
+Flux2 VTON 6.0.2.7 satisfies the complete strict acceptance contract with the
+`weighted-gap-40` variant. The next gate is the full 81-workflow Optimize +
+Layout corpus. The aggregate structural reference remains 4,127 crossings /
+339 RTL until that corpus measurement is reviewed. No additional Phase 5
+heuristics should be introduced before the corpus result is known.
## Deferred TODOs
From d3ff5cdc8cd616ca06826840f2921127f26205d8 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 09:22:17 +0200
Subject: [PATCH 069/105] Protect Phase 5 corpus crossing metrics
---
docs/LAYOUT_ENGINE_V2.md | 60 ++++++---
flowforge/layout_engine_v2_phase5.py | 185 +++++++++++++++++++++++---
tests/test_layout_engine_v2_phase5.py | 71 ++++++++++
tools/report_phase5_diagnostics.py | 9 +-
4 files changed, 283 insertions(+), 42 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 5e42ebe..2a50bc2 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -519,23 +519,33 @@ With default spacing this yields 100, 80, and 40 px gap variants. The strict
acceptance gate is unchanged; no candidate can trade crossings, RTL, overlaps,
width, or area outside the existing limits.
-The targeted multi-variant rerun produced the first accepted Phase 5 result:
+The targeted multi-variant rerun produced the first result accepted by the
+original Phase 5 gate:
- Jibs: the best diagnostic proposal was `stable-gap-100`,
- `5,977 x 12,448 -> 8,747 x 8,895`, crossings `756 -> 493`, RTL
+ `5,977 x 12,448 -> 8,747 x 8,895`, port crossings `756 -> 493`, port RTL
`41 -> 29`. It was correctly rejected because width increased instead of
falling by at least 8%.
- Flux2 VTON 6.0.2: the best diagnostic proposal was `stable-gap-40`,
- `6,723 x 4,861 -> 6,107 x 4,869`, crossings `31 -> 40`, RTL
- `6 -> 4`. It was correctly rejected for crossing regression.
-- Flux2 VTON 6.0.2.7: `weighted-gap-40` was accepted,
- `9,162 x 3,510 -> 7,746 x 4,032`, crossings `44 -> 43`, RTL
- `11 -> 8`. Width fell by about 15.5%, workflow area also fell, and all
- strict graph-quality constraints were preserved.
-
-This is the first evidence that shared mixed-flow placement can improve a real
-wide corpus workflow without relaxing the safety gate. No further placement
-heuristics should be added before measuring the complete corpus.
+ `6,723 x 4,861 -> 6,107 x 4,869`, port crossings `31 -> 40`, port RTL
+ `6 -> 4`. It was correctly rejected for port-aware crossing regression.
+- Flux2 VTON 6.0.2.7: `weighted-gap-40` was accepted by the port-aware gate,
+ `9,162 x 3,510 -> 7,746 x 4,032`, port crossings `44 -> 43`, port RTL
+ `11 -> 8`.
+
+The subsequent full 81-workflow Optimize + Layout corpus exposed an important
+metric mismatch. The historical corpus reference uses straight center-to-center
+node segments, while the v2 engine score uses actual output/input port
+positions. The Phase 5 run measured `4,138` center crossings and `331` center
+RTL links versus the Phase 4 reference of `4,127 / 339`. In other words, the
+port-aware gate allowed an aggregate `+11` regression in the benchmark crossing
+metric even though port-aware crossings were locally protected.
+
+Phase 5 therefore protects both geometries. A candidate must not increase
+port-aware crossings or RTL links and must also not increase the node-center
+crossings or RTL links used by the corpus reporter. The width and area gates are
+unchanged. This keeps the engine's more realistic port-aware metric while making
+the historical corpus reference an explicit safety constraint.
A group always moves as one rectangle with every member node, preserving its
internal geometry. The established finalizer then re-applies control, text
@@ -548,12 +558,15 @@ hard constraints and excludes Amazing Z-Image from automatic reorganization.
Phase 5 uses a strict acceptance gate:
- movable overlaps may not increase;
-- crossings may not increase;
-- RTL links may not increase;
+- port-aware crossings may not increase;
+- node-center corpus crossings may not increase;
+- port-aware RTL links may not increase;
+- node-center corpus RTL links may not increase;
- workflow width must fall by at least 8%;
- workflow area may not increase.
-Crossing or RTL improvements do not bypass the width/area requirements.
+Crossing or RTL improvements in either metric do not bypass the width/area
+requirements.
Targeted diagnostics are available with:
@@ -571,12 +584,17 @@ baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
text-preview, virtual-hub, decorative, and pin-specific contracts are still
authoritative after mixed placement.
-Phase 5 is still experimental. The targeted gate has now passed because
-Flux2 VTON 6.0.2.7 satisfies the complete strict acceptance contract with the
-`weighted-gap-40` variant. The next gate is the full 81-workflow Optimize +
-Layout corpus. The aggregate structural reference remains 4,127 crossings /
-339 RTL until that corpus measurement is reviewed. No additional Phase 5
-heuristics should be introduced before the corpus result is known.
+Phase 5 is still experimental. The first full 81-workflow corpus run measured
+4,138 center crossings / 331 center RTL links, which missed the Phase 4 crossing
+reference of 4,127 / 339 by 11 crossings while improving RTL by 8. That run
+revealed the port-aware versus center-metric acceptance mismatch described
+above.
+
+The next gate is to rerun the targeted Jibs/Flux2 diagnostics with both metrics
+visible, then rerun the full 81-workflow Optimize + Layout corpus. The Phase 4
+reference remains 4,127 center crossings / 339 center RTL links. No additional
+placement heuristics should be introduced until the dual-metric gate is
+validated against that corpus.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index a73cabc..2ccbfb2 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -17,8 +17,9 @@
conservative vertical-gap profiles derived from the existing layout settings.
The acceptance gate is deliberately strict: no additional movable overlaps,
-crossings, or right-to-left links; at least 8% workflow-width reduction; and no
-workflow-area growth.
+crossings, or right-to-left links in either the port-aware engine metric or the
+node-center corpus metric; at least 8% workflow-width reduction; and no workflow
+area growth.
"""
from __future__ import annotations
@@ -44,6 +45,7 @@
_assign_scc_longest_path_layers,
_finalize_refinement,
_score_engine_v2,
+ _segment_crossing_count,
)
from .layout_engine_v2_phase4 import (
_eligible_ungrouped_nodes,
@@ -91,6 +93,18 @@ class Phase5Diagnostics:
proposed_crossings: int | None
baseline_rtl: int
proposed_rtl: int | None
+ baseline_center_crossings: int
+ proposed_center_crossings: int | None
+ baseline_center_rtl: int
+ proposed_center_rtl: int | None
+
+
+@dataclass(frozen=True)
+class _CenterFlowMetrics:
+ """Corpus-compatible node-center crossing and RTL metrics."""
+
+ crossings: int
+ right_to_left_links: int
@dataclass
@@ -100,6 +114,7 @@ class _Phase5Candidate:
name: str
workflow: Workflow
score: EngineV2Score
+ center_metrics: _CenterFlowMetrics
def apply_best_layout(
@@ -111,15 +126,21 @@ def apply_best_layout(
settings = settings or LayoutSettings()
baseline = _apply_phase4_best_layout(workflow, settings, candidate_count)
baseline_score = _score_engine_v2(baseline)
+ baseline_center = _center_flow_metrics(baseline)
if baseline_score.width < MIXED_GLOBAL_MIN_WIDTH:
return baseline
candidates = _phase5_candidate_variants(baseline, settings, baseline_score)
- selected = _select_accepted_phase5_candidate(candidates, baseline_score)
+ selected = _select_accepted_phase5_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ )
if selected is not None:
logger.info(
"Phase 5 mixed global-flow accepted (%s): "
- "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s",
+ "size %.0fx%.0f -> %.0fx%.0f, port crossings %s -> %s, "
+ "center crossings %s -> %s, port rtl %s -> %s, center rtl %s -> %s",
selected.name,
baseline_score.width,
baseline_score.height,
@@ -127,16 +148,25 @@ def apply_best_layout(
selected.score.height,
baseline_score.crossings,
selected.score.crossings,
+ baseline_center.crossings,
+ selected.center_metrics.crossings,
baseline_score.right_to_left_links,
selected.score.right_to_left_links,
+ baseline_center.right_to_left_links,
+ selected.center_metrics.right_to_left_links,
)
return selected.workflow
- rejected = _best_diagnostic_candidate(candidates, baseline_score)
+ rejected = _best_diagnostic_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ )
if rejected is not None:
logger.info(
"Phase 5 mixed global-flow rejected (%s): "
- "size %.0fx%.0f -> %.0fx%.0f, crossings %s -> %s, rtl %s -> %s; "
+ "size %.0fx%.0f -> %.0fx%.0f, port crossings %s -> %s, "
+ "center crossings %s -> %s, port rtl %s -> %s, center rtl %s -> %s; "
"reason=%s",
rejected.name,
baseline_score.width,
@@ -145,9 +175,18 @@ def apply_best_layout(
rejected.score.height,
baseline_score.crossings,
rejected.score.crossings,
+ baseline_center.crossings,
+ rejected.center_metrics.crossings,
baseline_score.right_to_left_links,
rejected.score.right_to_left_links,
- _mixed_rejection_reason(rejected.score, baseline_score),
+ baseline_center.right_to_left_links,
+ rejected.center_metrics.right_to_left_links,
+ _mixed_rejection_reason(
+ rejected.score,
+ baseline_score,
+ rejected.center_metrics,
+ baseline_center,
+ ),
)
return baseline
@@ -159,6 +198,7 @@ def diagnose_phase5(
"""Return a read-only explanation of the Phase 5 decision."""
settings = settings or LayoutSettings()
baseline_score = _score_engine_v2(workflow)
+ baseline_center = _center_flow_metrics(workflow)
specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
group_count = sum(1 for kind, _value in specs if kind == "group")
ungrouped_count = sum(1 for kind, _value in specs if kind == "node")
@@ -196,11 +236,23 @@ def diagnose_phase5(
proposed_crossings=None,
baseline_rtl=baseline_score.right_to_left_links,
proposed_rtl=None,
+ baseline_center_crossings=baseline_center.crossings,
+ proposed_center_crossings=None,
+ baseline_center_rtl=baseline_center.right_to_left_links,
+ proposed_center_rtl=None,
)
candidates = _phase5_candidate_variants(workflow, settings, baseline_score)
- selected = _select_accepted_phase5_candidate(candidates, baseline_score)
- proposal = selected or _best_diagnostic_candidate(candidates, baseline_score)
+ selected = _select_accepted_phase5_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ )
+ proposal = selected or _best_diagnostic_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ )
if proposal is None:
return Phase5Diagnostics(
group_vertices=group_count,
@@ -220,6 +272,10 @@ def diagnose_phase5(
proposed_crossings=None,
baseline_rtl=baseline_score.right_to_left_links,
proposed_rtl=None,
+ baseline_center_crossings=baseline_center.crossings,
+ proposed_center_crossings=None,
+ baseline_center_rtl=baseline_center.right_to_left_links,
+ proposed_center_rtl=None,
)
accepted = selected is not None
@@ -235,7 +291,12 @@ def diagnose_phase5(
rejection_reason=(
"accepted"
if accepted
- else _mixed_rejection_reason(proposal.score, baseline_score)
+ else _mixed_rejection_reason(
+ proposal.score,
+ baseline_score,
+ proposal.center_metrics,
+ baseline_center,
+ )
),
baseline_width=baseline_score.width,
baseline_height=baseline_score.height,
@@ -245,6 +306,10 @@ def diagnose_phase5(
proposed_crossings=proposal.score.crossings,
baseline_rtl=baseline_score.right_to_left_links,
proposed_rtl=proposal.score.right_to_left_links,
+ baseline_center_crossings=baseline_center.crossings,
+ proposed_center_crossings=proposal.center_metrics.crossings,
+ baseline_center_rtl=baseline_center.right_to_left_links,
+ proposed_center_rtl=proposal.center_metrics.right_to_left_links,
)
@@ -272,6 +337,7 @@ def _phase5_candidate_variants(
name=f"{order_mode}-gap-{vertical_gap:g}",
workflow=candidate,
score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
)
)
return candidates
@@ -295,11 +361,17 @@ def _phase5_vertical_gaps(settings: LayoutSettings) -> list[float]:
def _select_accepted_phase5_candidate(
candidates: list[_Phase5Candidate],
baseline: EngineV2Score,
+ baseline_center: _CenterFlowMetrics,
) -> _Phase5Candidate | None:
accepted = [
candidate
for candidate in candidates
- if _mixed_candidate_is_better(candidate.score, baseline)
+ if _mixed_candidate_is_better(
+ candidate.score,
+ baseline,
+ candidate.center_metrics,
+ baseline_center,
+ )
]
if not accepted:
return None
@@ -308,12 +380,15 @@ def _select_accepted_phase5_candidate(
def _phase5_candidate_quality_key(
candidate: _Phase5Candidate,
-) -> tuple[int, int, int, float, float, float, str]:
+) -> tuple[int, int, int, int, int, float, float, float, str]:
score = candidate.score
+ center = candidate.center_metrics
return (
score.movable_overlaps,
score.crossings,
+ center.crossings,
score.right_to_left_links,
+ center.right_to_left_links,
score.width * score.height,
score.width,
score.height,
@@ -324,6 +399,7 @@ def _phase5_candidate_quality_key(
def _best_diagnostic_candidate(
candidates: list[_Phase5Candidate],
baseline: EngineV2Score,
+ baseline_center: _CenterFlowMetrics,
) -> _Phase5Candidate | None:
if not candidates:
return None
@@ -332,21 +408,40 @@ def _best_diagnostic_candidate(
"accepted": 0,
"area_regression": 1,
"insufficient_final_width_reduction": 2,
- "rtl_regression": 3,
- "crossing_regression": 4,
- "movable_overlap_regression": 5,
- "invalid_baseline_width": 6,
+ "center_rtl_regression": 3,
+ "rtl_regression": 4,
+ "center_crossing_regression": 5,
+ "crossing_regression": 6,
+ "movable_overlap_regression": 7,
+ "invalid_baseline_width": 8,
}
- def key(candidate: _Phase5Candidate) -> tuple[int, int, int, float, float, str]:
- reason = _mixed_rejection_reason(candidate.score, baseline)
+ def key(
+ candidate: _Phase5Candidate,
+ ) -> tuple[int, int, int, int, float, float, str]:
+ reason = _mixed_rejection_reason(
+ candidate.score,
+ baseline,
+ candidate.center_metrics,
+ baseline_center,
+ )
return (
reason_rank.get(reason, 99),
max(0, candidate.score.crossings - baseline.crossings),
+ max(
+ 0,
+ candidate.center_metrics.crossings
+ - baseline_center.crossings,
+ ),
max(
0,
candidate.score.right_to_left_links
- baseline.right_to_left_links,
+ )
+ + max(
+ 0,
+ candidate.center_metrics.right_to_left_links
+ - baseline_center.right_to_left_links,
),
candidate.score.width * candidate.score.height,
candidate.score.width,
@@ -356,6 +451,33 @@ def key(candidate: _Phase5Candidate) -> tuple[int, int, int, float, float, str]:
return min(candidates, key=key)
+def _center_flow_metrics(workflow: Workflow) -> _CenterFlowMetrics:
+ """Measure the same center-to-center geometry used by corpus reporting."""
+ centers = {
+ node.id: (
+ node.x + (float(node.size[0]) if len(node.size) >= 1 else 0.0) / 2.0,
+ node.y + (float(node.size[1]) if len(node.size) >= 2 else 0.0) / 2.0,
+ )
+ for node in workflow.nodes.values()
+ }
+ segments: list[
+ tuple[int, int, tuple[float, float], tuple[float, float]]
+ ] = []
+ right_to_left = 0
+ for link in workflow.links.values():
+ start = centers.get(link.source)
+ end = centers.get(link.target)
+ if start is None or end is None:
+ continue
+ segments.append((link.source, link.target, start, end))
+ if end[0] < start[0]:
+ right_to_left += 1
+ return _CenterFlowMetrics(
+ crossings=_segment_crossing_count(segments),
+ right_to_left_links=right_to_left,
+ )
+
+
def _mixed_precheck_reason(
workflow: Workflow,
baseline_score: EngineV2Score,
@@ -925,21 +1047,46 @@ def _mixed_order_key(vertex: MixedOrderVertex) -> str:
def _mixed_candidate_is_better(
candidate: EngineV2Score,
baseline: EngineV2Score,
+ candidate_center: _CenterFlowMetrics | None = None,
+ baseline_center: _CenterFlowMetrics | None = None,
) -> bool:
"""Require strict graph-quality preservation plus real width/area gain."""
- return _mixed_rejection_reason(candidate, baseline) == "accepted"
+ return (
+ _mixed_rejection_reason(
+ candidate,
+ baseline,
+ candidate_center,
+ baseline_center,
+ )
+ == "accepted"
+ )
def _mixed_rejection_reason(
candidate: EngineV2Score,
baseline: EngineV2Score,
+ candidate_center: _CenterFlowMetrics | None = None,
+ baseline_center: _CenterFlowMetrics | None = None,
) -> str:
if candidate.movable_overlaps > baseline.movable_overlaps:
return "movable_overlap_regression"
if candidate.crossings > baseline.crossings:
return "crossing_regression"
+ if (
+ candidate_center is not None
+ and baseline_center is not None
+ and candidate_center.crossings > baseline_center.crossings
+ ):
+ return "center_crossing_regression"
if candidate.right_to_left_links > baseline.right_to_left_links:
return "rtl_regression"
+ if (
+ candidate_center is not None
+ and baseline_center is not None
+ and candidate_center.right_to_left_links
+ > baseline_center.right_to_left_links
+ ):
+ return "center_rtl_regression"
if baseline.width <= 0:
return "invalid_baseline_width"
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index b8c8dc6..411ed55 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -3,7 +3,9 @@
from flowforge.layout import LayoutSettings
from flowforge.layout_engine_v2 import EngineV2Score, _assign_scc_longest_path_layers
from flowforge.layout_engine_v2_phase5 import (
+ _CenterFlowMetrics,
_build_mixed_graph,
+ _center_flow_metrics,
_mixed_candidate_is_better,
_mixed_rejection_reason,
_phase5_vertical_gaps,
@@ -181,6 +183,75 @@ def test_phase5_candidate_requires_full_strict_gate():
assert _mixed_rejection_reason(area_regression, baseline) == "area_regression"
+def test_phase5_candidate_rejects_corpus_center_metric_regressions():
+ baseline = _score(crossings=100, rtl=20, width=6_000, height=4_000)
+ candidate = _score(crossings=90, rtl=18, width=5_000, height=4_100)
+ baseline_center = _CenterFlowMetrics(crossings=44, right_to_left_links=11)
+
+ crossing_regression = _CenterFlowMetrics(
+ crossings=45,
+ right_to_left_links=8,
+ )
+ rtl_regression = _CenterFlowMetrics(
+ crossings=43,
+ right_to_left_links=12,
+ )
+ safe_center = _CenterFlowMetrics(
+ crossings=43,
+ right_to_left_links=8,
+ )
+
+ assert (
+ _mixed_rejection_reason(
+ candidate,
+ baseline,
+ crossing_regression,
+ baseline_center,
+ )
+ == "center_crossing_regression"
+ )
+ assert (
+ _mixed_rejection_reason(
+ candidate,
+ baseline,
+ rtl_regression,
+ baseline_center,
+ )
+ == "center_rtl_regression"
+ )
+ assert _mixed_candidate_is_better(
+ candidate,
+ baseline,
+ safe_center,
+ baseline_center,
+ )
+
+
+def test_center_flow_metrics_match_center_segment_geometry():
+ workflow = Workflow()
+ nodes = [
+ Node(id=1, x=0, y=0, size=[100, 100]),
+ Node(id=2, x=0, y=200, size=[100, 100]),
+ Node(id=3, x=400, y=0, size=[100, 100]),
+ Node(id=4, x=400, y=200, size=[100, 100]),
+ ]
+ workflow.nodes = {node.id: node for node in nodes}
+
+ _attach_link(
+ workflow,
+ Link(id=1, source=1, source_port=0, target=4, target_port=0),
+ )
+ _attach_link(
+ workflow,
+ Link(id=2, source=2, source_port=0, target=3, target_port=0),
+ )
+
+ metrics = _center_flow_metrics(workflow)
+
+ assert metrics.crossings == 1
+ assert metrics.right_to_left_links == 0
+
+
def test_phase5_vertical_gap_profiles_use_existing_spacing_controls():
settings = LayoutSettings(
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 558cb30..61399bb 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -68,8 +68,13 @@ def main() -> int:
f"variant={diag.proposal_variant}; "
f"{diag.baseline_width:.0f}x{diag.baseline_height:.0f} -> "
f"{diag.proposed_width:.0f}x{diag.proposed_height:.0f}; "
- f"crossings={diag.baseline_crossings}->{diag.proposed_crossings}; "
- f"rtl={diag.baseline_rtl}->{diag.proposed_rtl}; "
+ f"port-crossings={diag.baseline_crossings}"
+ f"->{diag.proposed_crossings}; "
+ f"center-crossings={diag.baseline_center_crossings}"
+ f"->{diag.proposed_center_crossings}; "
+ f"port-rtl={diag.baseline_rtl}->{diag.proposed_rtl}; "
+ f"center-rtl={diag.baseline_center_rtl}"
+ f"->{diag.proposed_center_rtl}; "
f"accepted={diag.accepted}; reason={diag.rejection_reason}"
)
else:
From 5d729b1cbc5868e6c0ce5a1f80c8b42852fa839b Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:25:33 +0200
Subject: [PATCH 070/105] Add Phase 5 accepted-corpus diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 28 +++++++++++++++++++++++-----
tools/report_phase5_diagnostics.py | 30 ++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 5 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 2a50bc2..f3c939b 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -575,6 +575,9 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--match "Jibs_Ultimate" `
--match "6.0.2.7" `
--match "6.0.2 (Codex)"
+
+# Corpus review: print only Phase 5 candidates that pass the complete gate.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize --accepted-only
```
## Current Scope Boundary
@@ -590,11 +593,26 @@ reference of 4,127 / 339 by 11 crossings while improving RTL by 8. That run
revealed the port-aware versus center-metric acceptance mismatch described
above.
-The next gate is to rerun the targeted Jibs/Flux2 diagnostics with both metrics
-visible, then rerun the full 81-workflow Optimize + Layout corpus. The Phase 4
-reference remains 4,127 center crossings / 339 center RTL links. No additional
-placement heuristics should be introduced until the dual-metric gate is
-validated against that corpus.
+The dual-metric rerun validated the safety contract:
+
+- Jibs remained rejected for insufficient width reduction even though both
+ crossing metrics and both RTL metrics improved.
+- Flux2 VTON 6.0.2 remained rejected for port-aware crossing regression; its
+ center crossings also regressed from 44 to 52.
+- Flux2 VTON 6.0.2.7 was rejected by the new center-crossing gate:
+ port crossings improved from 44 to 43 and port RTL from 11 to 8, but center
+ crossings regressed from 46 to 60.
+
+The full 81-workflow Optimize + Layout corpus then measured 4,124 center
+crossings / 335 center RTL links. This improves the Phase 4 reference of
+4,127 / 339 by 3 crossings and 4 RTL links, so the dual-metric Phase 5 gate
+passes the corpus safety check.
+
+Before changing placement heuristics again, the next review step is to identify
+the small set of workflows actually accepted by Phase 5 and inspect those
+layouts visually. The Phase 5 diagnostic reporter supports `--accepted-only`
+and prints corpus acceptance counts so this review does not require scanning all
+81 workflow blocks.
## Deferred TODOs
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 61399bb..68d70d6 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -29,6 +29,11 @@ def main() -> int:
action="store_true",
help="Run FlowForge Optimize before the Phase 4 baseline.",
)
+ parser.add_argument(
+ "--accepted-only",
+ action="store_true",
+ help="Print only workflows whose Phase 5 candidate passes the full gate.",
+ )
args = parser.parse_args()
files = discover_workflow_files(args.root)
@@ -44,6 +49,11 @@ def main() -> int:
print("No matching workflow files found.")
return 1
+ attempted_count = 0
+ accepted_count = 0
+ rejected_count = 0
+ skipped_count = 0
+
for path in selected:
relative = path.relative_to(args.root)
data = json.loads(path.read_text(encoding="utf-8"))
@@ -53,6 +63,18 @@ def main() -> int:
baseline = apply_phase4_layout(workflow)
diag = diagnose_phase5(baseline)
+ if diag.attempted:
+ attempted_count += 1
+ if diag.accepted:
+ accepted_count += 1
+ else:
+ rejected_count += 1
+ else:
+ skipped_count += 1
+
+ if args.accepted_only and not diag.accepted:
+ continue
+
print(relative)
print(
" mixed-graph: "
@@ -83,6 +105,14 @@ def main() -> int:
f"reason={diag.rejection_reason}"
)
+ print(
+ "Summary: "
+ f"selected={len(selected)} "
+ f"attempted={attempted_count} "
+ f"accepted={accepted_count} "
+ f"rejected={rejected_count} "
+ f"skipped={skipped_count}"
+ )
return 0
From b289febe4b116b16a42bb534fea7e8db4e1f3944 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:24:42 +0200
Subject: [PATCH 071/105] Quiet accepted Phase 5 diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 6 +++---
tools/report_phase5_diagnostics.py | 9 ++++++++-
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index f3c939b..474630c 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -610,9 +610,9 @@ passes the corpus safety check.
Before changing placement heuristics again, the next review step is to identify
the small set of workflows actually accepted by Phase 5 and inspect those
-layouts visually. The Phase 5 diagnostic reporter supports `--accepted-only`
-and prints corpus acceptance counts so this review does not require scanning all
-81 workflow blocks.
+layouts visually. The Phase 5 diagnostic reporter supports `--accepted-only`,
+suppresses routine INFO logging in that mode, and prints corpus acceptance
+counts so this review does not require scanning all 81 workflow blocks.
## Deferred TODOs
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 68d70d6..277f9a4 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -4,6 +4,7 @@
import argparse
import json
+import logging
from pathlib import Path
from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
@@ -32,10 +33,16 @@ def main() -> int:
parser.add_argument(
"--accepted-only",
action="store_true",
- help="Print only workflows whose Phase 5 candidate passes the full gate.",
+ help=(
+ "Print only workflows whose Phase 5 candidate passes the full gate "
+ "and suppress routine INFO logging."
+ ),
)
args = parser.parse_args()
+ if args.accepted_only:
+ logging.disable(logging.INFO)
+
files = discover_workflow_files(args.root)
matches = [value.casefold() for value in args.match]
selected = [
From 402559b5c13a61fc3303f04d868b024b62d6cae0 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 13:15:19 +0200
Subject: [PATCH 072/105] Add Phase 5 fragmentation diagnostics
---
docs/LAYOUT_ENGINE_V2.md | 27 ++++-
tools/report_phase5_diagnostics.py | 171 ++++++++++++++++++++++++++++-
2 files changed, 192 insertions(+), 6 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 474630c..9956f93 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -578,6 +578,10 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
# Corpus review: print only Phase 5 candidates that pass the complete gate.
uv run python tools/report_phase5_diagnostics.py example-workflows --optimize --accepted-only
+
+# Geometry review for a selected workflow/proposal.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Virtual Try-On 6.0 (Codex)" --geometry
```
## Current Scope Boundary
@@ -608,11 +612,24 @@ crossings / 335 center RTL links. This improves the Phase 4 reference of
4,127 / 339 by 3 crossings and 4 RTL links, so the dual-metric Phase 5 gate
passes the corpus safety check.
-Before changing placement heuristics again, the next review step is to identify
-the small set of workflows actually accepted by Phase 5 and inspect those
-layouts visually. The Phase 5 diagnostic reporter supports `--accepted-only`,
-suppresses routine INFO logging in that mode, and prints corpus acceptance
-counts so this review does not require scanning all 81 workflow blocks.
+The accepted-only corpus review found 37 attempted Phase 5 workflows but only
+one accepted result: `Flux.1 Kontext dev Virtual Try-On 6.0 (Codex)` with
+`stable-gap-80`. Its measured geometry improved width from 6,350 to 5,810 px,
+port crossings from 43 to 38, center crossings from 53 to 50, port RTL from 10
+to 6, and center RTL from 5 to 1.
+
+Visual inspection still rejected that result. The main flow was numerically
+cleaner, but the canvas was visibly fragmented by isolated top-level blocks,
+large empty vertical bands, and long cross-canvas wires. This is a false
+positive for the current numeric gate: width, area, crossings, and RTL are not
+sufficient to protect overall visual cohesion.
+
+No new acceptance threshold is added from the screenshot alone. The Phase 5
+diagnostic reporter now has an optional `--geometry` mode that compares the
+Phase 4 baseline with the exact Phase 5 proposal. It reports node-bounds,
+approximate node-density, largest empty X/Y bands, empty groups, and the
+top-level groups/ungrouped nodes that actually moved. That measurement should
+identify the fragmentation mechanism before the production gate changes.
## Deferred TODOs
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 277f9a4..c844190 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -7,8 +7,17 @@
import logging
from pathlib import Path
+from flowforge.layout import LayoutSettings, _node_visual_height, _node_visual_width
+from flowforge.layout_engine_v2 import _score_engine_v2
from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
-from flowforge.layout_engine_v2_phase5 import diagnose_phase5
+from flowforge.layout_engine_v2_phase5 import (
+ _best_diagnostic_candidate,
+ _center_flow_metrics,
+ _phase5_candidate_variants,
+ _select_accepted_phase5_candidate,
+ diagnose_phase5,
+)
+from flowforge.model import Workflow
from flowforge.optimizer import optimize as optimize_workflow
from flowforge.parser import parse_comfyui_workflow
from flowforge.workflow_validation import discover_workflow_files
@@ -38,6 +47,14 @@ def main() -> int:
"and suppress routine INFO logging."
),
)
+ parser.add_argument(
+ "--geometry",
+ action="store_true",
+ help=(
+ "Compare compact baseline/proposal geometry diagnostics, including "
+ "empty groups, node gaps, density, and moved top-level items."
+ ),
+ )
args = parser.parse_args()
if args.accepted_only:
@@ -112,6 +129,9 @@ def main() -> int:
f"reason={diag.rejection_reason}"
)
+ if args.geometry and diag.attempted:
+ _print_geometry_diagnostics(baseline)
+
print(
"Summary: "
f"selected={len(selected)} "
@@ -123,5 +143,154 @@ def main() -> int:
return 0
+def _print_geometry_diagnostics(baseline: Workflow) -> None:
+ baseline_score = _score_engine_v2(baseline)
+ baseline_center = _center_flow_metrics(baseline)
+ candidates = _phase5_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ proposal = _select_accepted_phase5_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ ) or _best_diagnostic_candidate(
+ candidates,
+ baseline_score,
+ baseline_center,
+ )
+ if proposal is None:
+ print(" geometry: no physical proposal available")
+ return
+
+ before = _node_geometry_summary(baseline)
+ after = _node_geometry_summary(proposal.workflow)
+ print(
+ " geometry: "
+ f"node-bounds={before['width']:.0f}x{before['height']:.0f}"
+ f"->{after['width']:.0f}x{after['height']:.0f}; "
+ f"density={before['density']:.3f}->{after['density']:.3f}; "
+ f"max-x-gap={before['max_x_gap']:.0f}->{after['max_x_gap']:.0f}; "
+ f"max-y-gap={before['max_y_gap']:.0f}->{after['max_y_gap']:.0f}"
+ )
+
+ empty_groups = [
+ group
+ for group in baseline.groups
+ if not group.nodes and len(group.bounding) >= 4
+ ]
+ if empty_groups:
+ names = ", ".join(
+ f"{group.name or f'group-{group.id}'}#{group.id}"
+ for group in empty_groups
+ )
+ print(f" empty-groups: {len(empty_groups)} [{names}]")
+ else:
+ print(" empty-groups: 0")
+
+ before_groups = {group.id: group for group in baseline.groups}
+ moved_groups: list[tuple[float, str]] = []
+ for group in proposal.workflow.groups:
+ previous = before_groups.get(group.id)
+ if previous is None or len(group.bounding) < 2 or len(previous.bounding) < 2:
+ continue
+ dx = group.bounding[0] - previous.bounding[0]
+ dy = group.bounding[1] - previous.bounding[1]
+ if abs(dx) < 0.5 and abs(dy) < 0.5:
+ continue
+ label = (
+ f"{group.name or f'group-{group.id}'}#{group.id} "
+ f"dx={dx:+.0f} dy={dy:+.0f}"
+ )
+ moved_groups.append((abs(dx) + abs(dy), label))
+
+ if moved_groups:
+ print(" moved-groups:")
+ for _distance, label in sorted(moved_groups, reverse=True):
+ print(f" - {label}")
+ else:
+ print(" moved-groups: none")
+
+ before_ungrouped = {node.id: node for node in baseline.ungrouped_nodes}
+ moved_nodes: list[tuple[float, str]] = []
+ for node in proposal.workflow.ungrouped_nodes:
+ previous = before_ungrouped.get(node.id)
+ if previous is None:
+ continue
+ dx = node.x - previous.x
+ dy = node.y - previous.y
+ if abs(dx) < 0.5 and abs(dy) < 0.5:
+ continue
+ label = f"{node.type}#{node.id} dx={dx:+.0f} dy={dy:+.0f}"
+ moved_nodes.append((abs(dx) + abs(dy), label))
+
+ if moved_nodes:
+ print(" moved-ungrouped:")
+ for _distance, label in sorted(moved_nodes, reverse=True)[:16]:
+ print(f" - {label}")
+ if len(moved_nodes) > 16:
+ print(f" - ... {len(moved_nodes) - 16} more")
+ else:
+ print(" moved-ungrouped: none")
+
+
+def _node_geometry_summary(workflow: Workflow) -> dict[str, float]:
+ rectangles = [
+ (
+ node.x,
+ node.y,
+ node.x + _node_visual_width(node),
+ node.y + _node_visual_height(node),
+ )
+ for node in workflow.nodes.values()
+ ]
+ if not rectangles:
+ return {
+ "width": 0.0,
+ "height": 0.0,
+ "density": 0.0,
+ "max_x_gap": 0.0,
+ "max_y_gap": 0.0,
+ }
+
+ left = min(rect[0] for rect in rectangles)
+ top = min(rect[1] for rect in rectangles)
+ right = max(rect[2] for rect in rectangles)
+ bottom = max(rect[3] for rect in rectangles)
+ width = max(0.0, right - left)
+ height = max(0.0, bottom - top)
+ occupied_area = sum(
+ max(0.0, rect[2] - rect[0]) * max(0.0, rect[3] - rect[1])
+ for rect in rectangles
+ )
+ bounds_area = width * height
+ return {
+ "width": width,
+ "height": height,
+ "density": occupied_area / bounds_area if bounds_area > 0 else 0.0,
+ "max_x_gap": _largest_axis_gap((rect[0], rect[2]) for rect in rectangles),
+ "max_y_gap": _largest_axis_gap((rect[1], rect[3]) for rect in rectangles),
+ }
+
+
+def _largest_axis_gap(intervals) -> float:
+ ordered = sorted(
+ (float(start), float(end))
+ for start, end in intervals
+ if end > start
+ )
+ if len(ordered) < 2:
+ return 0.0
+
+ max_gap = 0.0
+ current_end = ordered[0][1]
+ for start, end in ordered[1:]:
+ if start > current_end:
+ max_gap = max(max_gap, start - current_end)
+ current_end = max(current_end, end)
+ return max_gap
+
+
if __name__ == "__main__":
raise SystemExit(main())
From c2fe510ffee29325b41c409227c85da1447b8357 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 13:28:54 +0200
Subject: [PATCH 073/105] Protect Phase 5 empty group geometry
---
docs/LAYOUT_ENGINE_V2.md | 33 ++++++++++++++++++++-------
flowforge/layout_engine_v2_phase5.py | 18 +++++++++++++--
tests/test_layout_engine_v2_phase5.py | 26 +++++++++++++++++++++
3 files changed, 67 insertions(+), 10 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 9956f93..d0daaeb 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -552,8 +552,11 @@ internal geometry. The established finalizer then re-applies control, text
preview, virtual Set/Get, group-overlap, and pinned-geometry contracts.
The first Phase 5 experiment deliberately skips any workflow that contains
-pinned group geometry or pinned ungrouped nodes. This keeps authored pins as
-hard constraints and excludes Amazing Z-Image from automatic reorganization.
+pinned group geometry or pinned ungrouped nodes. It also skips authored
+positive-size groups with no assigned nodes because those rectangles are not yet
+represented by the mixed graph. This keeps authored pins and unmodeled group
+surfaces as hard constraints and excludes Amazing Z-Image from automatic
+reorganization.
Phase 5 uses a strict acceptance gate:
@@ -624,12 +627,26 @@ large empty vertical bands, and long cross-canvas wires. This is a false
positive for the current numeric gate: width, area, crossings, and RTL are not
sufficient to protect overall visual cohesion.
-No new acceptance threshold is added from the screenshot alone. The Phase 5
-diagnostic reporter now has an optional `--geometry` mode that compares the
-Phase 4 baseline with the exact Phase 5 proposal. It reports node-bounds,
-approximate node-density, largest empty X/Y bands, empty groups, and the
-top-level groups/ungrouped nodes that actually moved. That measurement should
-identify the fragmentation mechanism before the production gate changes.
+The geometry rerun isolated the false positive more precisely. Node geometry
+actually improved: node bounds fell from 6,300 x 3,236 to 5,810 x 2,568,
+approximate node density rose from 0.136 to 0.186, and the largest node-only
+horizontal gap fell from 460 to 300 px. The visible fragmentation instead came
+from three authored positive-size groups with no assigned member nodes:
+`Prompt`, `Sampler`, and `Reference Image 1`. The Phase 5 mixed graph did
+not represent those empty rectangles, but the normal overlap finalizer moved
+them downward by 1,128, 608, and 282 px respectively after the modeled flow was
+repositioned.
+
+Phase 5 therefore now treats positive-size empty groups as an explicit scope
+constraint, just like pinned geometry. Such workflows fall back to the complete
+Phase 4 result until mixed placement models those authored group rectangles as
+real obstacles/vertices. This is deliberately not a visual-density threshold or
+height heuristic: Phase 5 simply does not reorganize geometry that its graph
+cannot represent.
+
+The `--geometry` diagnostic remains available for future false positives. It
+reports node bounds, approximate node density, largest empty X/Y bands, empty
+groups, and the top-level groups/ungrouped nodes that actually moved.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 2ccbfb2..076c4a0 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -7,8 +7,11 @@
Pinned geometry remains a hard constraint. Workflows with pinned groups or
pinned ungrouped nodes are skipped entirely by this first Phase 5 experiment.
-Established local-placement special cases remain outside the mixed graph and
-are re-applied by the normal refinement finalizer.
+Authored positive-size groups with no assigned member nodes are also skipped:
+they are real canvas geometry but are not represented by the mixed dependency
+graph, so allowing the normal overlap finalizer to relocate them can fragment
+the visible workflow. Established local-placement special cases remain outside
+the mixed graph and are re-applied by the normal refinement finalizer.
Physical placement keeps dependency layers monotonic from left to right. This
is intentionally separate from graph ordering: row wrapping is not used because
@@ -490,6 +493,8 @@ def _mixed_precheck_reason(
return "workflow_below_min_width"
if _has_phase5_pin_constraint(workflow):
return "pinned_geometry_constraint"
+ if _has_phase5_unmodeled_group_geometry(workflow):
+ return "unmodeled_empty_group_geometry"
if group_count < MIXED_GLOBAL_MIN_GROUPS:
return "too_few_groups"
if ungrouped_count < MIXED_GLOBAL_MIN_UNGROUPED:
@@ -514,6 +519,14 @@ def _has_phase5_pin_constraint(workflow: Workflow) -> bool:
)
+def _has_phase5_unmodeled_group_geometry(workflow: Workflow) -> bool:
+ """Return True for authored group rectangles that the mixed graph cannot model."""
+ return any(
+ not group.nodes and _has_positive_bounding(group)
+ for group in workflow.groups
+ )
+
+
def _build_mixed_graph(
workflow: Workflow,
) -> tuple[
@@ -601,6 +614,7 @@ def _place_mixed_global_flow(
ungrouped_count = sum(1 for kind, _value in specs if kind == "node")
if (
_has_phase5_pin_constraint(workflow)
+ or _has_phase5_unmodeled_group_geometry(workflow)
or group_count < MIXED_GLOBAL_MIN_GROUPS
or ungrouped_count < MIXED_GLOBAL_MIN_UNGROUPED
or not edge_weights
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 411ed55..aa4d514 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -6,6 +6,7 @@
_CenterFlowMetrics,
_build_mixed_graph,
_center_flow_metrics,
+ _has_phase5_unmodeled_group_geometry,
_mixed_candidate_is_better,
_mixed_rejection_reason,
_phase5_vertical_gaps,
@@ -163,6 +164,31 @@ def test_phase5_skips_pinned_group_geometry():
assert not changed
+def test_phase5_skips_unmodeled_empty_group_geometry():
+ workflow = _mixed_chain_workflow()
+ extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
+ workflow.nodes[extra.id] = extra
+ workflow.ungrouped_nodes.append(extra)
+ workflow.groups.append(
+ Group(
+ id=300,
+ name="Authored Empty Panel",
+ nodes=[],
+ bounding=[900, 900, 1_200, 800],
+ )
+ )
+
+ assert _has_phase5_unmodeled_group_geometry(workflow)
+
+ changed = _place_mixed_global_flow(
+ workflow,
+ LayoutSettings(),
+ workflow_width=7_000,
+ )
+
+ assert not changed
+
+
def test_phase5_candidate_requires_full_strict_gate():
baseline = _score(crossings=100, rtl=20, width=6_000, height=4_000)
accepted = _score(crossings=90, rtl=18, width=5_000, height=4_100)
From fbc7683fa0a3027528911371fb656f99ef6ea47c Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 15:53:57 +0200
Subject: [PATCH 074/105] Make diagnostic output UTF-8 safe
---
docs/LAYOUT_ENGINE_V2.md | 6 ++++++
tools/report_layout_quality.py | 9 +++++++++
tools/report_phase5_diagnostics.py | 10 ++++++++++
3 files changed, 25 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index d0daaeb..c5c5d39 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -648,6 +648,12 @@ The `--geometry` diagnostic remains available for future false positives. It
reports node bounds, approximate node density, largest empty X/Y bands, empty
groups, and the top-level groups/ungrouped nodes that actually moved.
+The corpus and Phase 5 diagnostic reporters explicitly configure redirected
+stdout/stderr as UTF-8 so Windows PowerShell pipelines do not fall back to
+cp1252 when workflow or group names contain Unicode symbols or emoji. Long
+local report captures should be written below `logs/`, which is already
+gitignored.
+
## Deferred TODOs
- Add user-facing Undo/Redo for destructive workflow transformations such as
diff --git a/tools/report_layout_quality.py b/tools/report_layout_quality.py
index a67fd39..63cd249 100644
--- a/tools/report_layout_quality.py
+++ b/tools/report_layout_quality.py
@@ -14,7 +14,16 @@
DEFAULT_WORKFLOW_ROOT = Path("example-workflows")
+def _configure_utf8_output() -> None:
+ """Keep redirected Windows output UTF-8 safe for workflow names and labels."""
+ for stream in (sys.stdout, sys.stderr):
+ reconfigure = getattr(stream, "reconfigure", None)
+ if callable(reconfigure):
+ reconfigure(encoding="utf-8", errors="replace")
+
+
def main() -> int:
+ _configure_utf8_output()
parser = argparse.ArgumentParser(
description="Report read-only FlowForge layout quality metrics for ComfyUI UI workflows.",
)
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index c844190..2bf7a27 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -5,6 +5,7 @@
import argparse
import json
import logging
+import sys
from pathlib import Path
from flowforge.layout import LayoutSettings, _node_visual_height, _node_visual_width
@@ -23,7 +24,16 @@
from flowforge.workflow_validation import discover_workflow_files
+def _configure_utf8_output() -> None:
+ """Keep redirected Windows output UTF-8 safe for workflow names and labels."""
+ for stream in (sys.stdout, sys.stderr):
+ reconfigure = getattr(stream, "reconfigure", None)
+ if callable(reconfigure):
+ reconfigure(encoding="utf-8", errors="replace")
+
+
def main() -> int:
+ _configure_utf8_output()
parser = argparse.ArgumentParser(
description="Explain why Phase 5 accepts or rejects selected workflows."
)
From 975414ec2f7deb2c6dc909d3e1b9541d9ec129a4 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 15:58:49 +0200
Subject: [PATCH 075/105] Document final Phase 5 corpus gate
---
docs/LAYOUT_ENGINE_V2.md | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index c5c5d39..c0752da 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -654,6 +654,28 @@ cp1252 when workflow or group names contain Unicode symbols or emoji. Long
local report captures should be written below `logs/`, which is already
gitignored.
+### Final Phase 5 safety validation
+
+After the empty-group scope guard, the accepted-only corpus diagnostic reported:
+
+- selected: 81;
+- attempted: 14;
+- accepted: 0;
+- rejected: 14;
+- skipped: 67.
+
+The final full 81-workflow Optimize + Layout corpus then measured exactly
+`4,127` center crossings and `339` center RTL links, with 0 failures. This is
+identical to the Phase 4 reference. Phase 5 is therefore currently a safe
+no-op on the corpus: it introduces no structural regression, but it also has no
+accepted corpus improvement after the visually invalid candidate was excluded.
+
+Further Phase 5 work should therefore focus on expanding what the mixed graph
+can model—most notably authored empty group rectangles—or on improving physical
+realization for the remaining 14 attempted workflows. The existing safety gates
+should remain unchanged until a candidate produces both measurable and visually
+acceptable improvement.
+
## Deferred TODOs
- Add user-facing Undo/Redo for destructive workflow transformations such as
From 09692f7c3147b3c0dc358c1650d2c892ccca597d Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 16:20:19 +0200
Subject: [PATCH 076/105] Summarize Phase 5 rejection reasons
---
docs/LAYOUT_ENGINE_V2.md | 8 ++++++-
tools/report_phase5_diagnostics.py | 38 +++++++++++++++++++++++++++++-
2 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index c0752da..179250d 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -582,6 +582,9 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
# Corpus review: print only Phase 5 candidates that pass the complete gate.
uv run python tools/report_phase5_diagnostics.py example-workflows --optimize --accepted-only
+# Architecture review: print only workflows where Phase 5 built physical candidates.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize --attempted-only
+
# Geometry review for a selected workflow/proposal.
uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--match "Virtual Try-On 6.0 (Codex)" --geometry
@@ -672,7 +675,10 @@ accepted corpus improvement after the visually invalid candidate was excluded.
Further Phase 5 work should therefore focus on expanding what the mixed graph
can model—most notably authored empty group rectangles—or on improving physical
-realization for the remaining 14 attempted workflows. The existing safety gates
+realization for the remaining 14 attempted workflows. The diagnostic reporter
+supports `--attempted-only` and prints aggregate attempted-rejection and skipped
+reason counts so the next architecture decision can be based on the dominant
+failure mode rather than another global heuristic. The existing safety gates
should remain unchanged until a candidate produces both measurable and visually
acceptable improvement.
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 2bf7a27..a9a3a81 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -57,6 +57,14 @@ def main() -> int:
"and suppress routine INFO logging."
),
)
+ parser.add_argument(
+ "--attempted-only",
+ action="store_true",
+ help=(
+ "Print only workflows for which Phase 5 built physical candidates "
+ "and suppress routine INFO logging."
+ ),
+ )
parser.add_argument(
"--geometry",
action="store_true",
@@ -67,7 +75,10 @@ def main() -> int:
)
args = parser.parse_args()
- if args.accepted_only:
+ if args.accepted_only and args.attempted_only:
+ parser.error("--accepted-only and --attempted-only are mutually exclusive")
+
+ if args.accepted_only or args.attempted_only:
logging.disable(logging.INFO)
files = discover_workflow_files(args.root)
@@ -87,6 +98,8 @@ def main() -> int:
accepted_count = 0
rejected_count = 0
skipped_count = 0
+ attempted_reasons: dict[str, int] = {}
+ skipped_reasons: dict[str, int] = {}
for path in selected:
relative = path.relative_to(args.root)
@@ -103,11 +116,19 @@ def main() -> int:
accepted_count += 1
else:
rejected_count += 1
+ attempted_reasons[diag.rejection_reason] = (
+ attempted_reasons.get(diag.rejection_reason, 0) + 1
+ )
else:
skipped_count += 1
+ skipped_reasons[diag.rejection_reason] = (
+ skipped_reasons.get(diag.rejection_reason, 0) + 1
+ )
if args.accepted_only and not diag.accepted:
continue
+ if args.attempted_only and not diag.attempted:
+ continue
print(relative)
print(
@@ -150,9 +171,24 @@ def main() -> int:
f"rejected={rejected_count} "
f"skipped={skipped_count}"
)
+ _print_reason_summary("Attempted rejection reasons", attempted_reasons)
+ _print_reason_summary("Skipped reasons", skipped_reasons)
return 0
+def _print_reason_summary(title: str, reasons: dict[str, int]) -> None:
+ if not reasons:
+ return
+ values = " ".join(
+ f"{reason}={count}"
+ for reason, count in sorted(
+ reasons.items(),
+ key=lambda item: (-item[1], item[0]),
+ )
+ )
+ print(f"{title}: {values}")
+
+
def _print_geometry_diagnostics(baseline: Workflow) -> None:
baseline_score = _score_engine_v2(baseline)
baseline_center = _center_flow_metrics(baseline)
From ea4859f68f4ca99122589354afa142e873b6fe0a Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 16:32:14 +0200
Subject: [PATCH 077/105] Add Phase 5 per-vertex X compaction
---
docs/LAYOUT_ENGINE_V2.md | 32 ++++-
flowforge/layout_engine_v2_phase5.py | 178 +++++++++++++++++++++-----
tests/test_layout_engine_v2_phase5.py | 84 ++++++++++++
3 files changed, 256 insertions(+), 38 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 179250d..6748c88 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -675,12 +675,36 @@ accepted corpus improvement after the visually invalid candidate was excluded.
Further Phase 5 work should therefore focus on expanding what the mixed graph
can model—most notably authored empty group rectangles—or on improving physical
-realization for the remaining 14 attempted workflows. The diagnostic reporter
+realization for the remaining attempted workflows. The diagnostic reporter
supports `--attempted-only` and prints aggregate attempted-rejection and skipped
reason counts so the next architecture decision can be based on the dominant
-failure mode rather than another global heuristic. The existing safety gates
-should remain unchanged until a candidate produces both measurable and visually
-acceptable improvement.
+failure mode rather than another global heuristic.
+
+The first attempted-only rerun after the empty-group guard produced 14 physical
+candidate workflows:
+
+- 8 rejected for port-aware crossing regression;
+- 6 rejected for insufficient final width reduction.
+
+Several width-rejected workflows already improved crossings and RTL materially,
+including Qwen VTON v11/v12/v15 and ZIT multi-style. Inspection of the physical
+realizer showed a concrete width mechanism: every real vertex in one mixed
+dependency layer shared the same X coordinate, and the next layer advanced by
+the widest rectangle in the entire previous layer. A single wide group could
+therefore push an unrelated narrow parallel chain to the right.
+
+Phase 5 now evaluates an additional `compactx` physical realization for every
+existing weighted/stable order and vertical-gap variant. It preserves the same
+layer assignment and vertical order, but assigns X per real vertex. Each vertex
+is placed only as far right as required by already placed forward predecessors
+and vertically overlapping geometry. Unrelated vertical lanes may therefore use
+different X positions instead of inheriting the widest rectangle in their
+dependency layer. Direct forward dependencies remain left-to-right, and the
+complete existing Phase 5 acceptance gate remains authoritative. The original
+shared-layer X realization is retained unchanged as a baseline variant.
+
+The existing safety gates should remain unchanged until a candidate produces
+both measurable and visually acceptable improvement.
## Deferred TODOs
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 076c4a0..dd039a8 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -324,25 +324,32 @@ def _phase5_candidate_variants(
"""Build scored physical variants without changing the mixed dependency graph."""
candidates: list[_Phase5Candidate] = []
for order_mode in ("weighted", "stable"):
- for vertical_gap in _phase5_vertical_gaps(settings):
- candidate = deepcopy(baseline)
- if not _place_mixed_global_flow(
- candidate,
- settings,
- baseline_score.width,
- order_mode=order_mode,
- vertical_gap=vertical_gap,
- ):
- continue
- _finalize_refinement(candidate, settings)
- candidates.append(
- _Phase5Candidate(
- name=f"{order_mode}-gap-{vertical_gap:g}",
- workflow=candidate,
- score=_score_engine_v2(candidate),
- center_metrics=_center_flow_metrics(candidate),
+ for horizontal_mode in ("layer", "compact"):
+ for vertical_gap in _phase5_vertical_gaps(settings):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode=horizontal_mode,
+ vertical_gap=vertical_gap,
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ variant_name = (
+ f"{order_mode}-gap-{vertical_gap:g}"
+ if horizontal_mode == "layer"
+ else f"{order_mode}-compactx-gap-{vertical_gap:g}"
+ )
+ candidates.append(
+ _Phase5Candidate(
+ name=variant_name,
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
)
- )
return candidates
@@ -605,9 +612,10 @@ def _place_mixed_global_flow(
workflow_width: float,
*,
order_mode: str = "weighted",
+ horizontal_mode: str = "layer",
vertical_gap: float | None = None,
) -> bool:
- """Place mixed-flow layers in monotonic left-to-right columns."""
+ """Place mixed-flow layers with conservative left-to-right geometry."""
del workflow_width
specs, spec_by_vertex, adjacency, edge_weights = _build_mixed_graph(workflow)
group_count = sum(1 for kind, _value in specs if kind == "group")
@@ -701,34 +709,61 @@ def _place_mixed_global_flow(
),
default=50.0,
)
- layer_positions = _monotonic_layer_positions(
- layer_sizes,
- base_x,
- base_y,
- horizontal_gap,
- )
+ vertex_y: dict[int, float] = {}
+ for layer in sorted(layer_to_real):
+ current_y = base_y
+ for vertex in layer_to_real[layer]:
+ vertex_y[vertex] = current_y
+ _width, height = _mixed_vertex_size(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )
+ current_y += height + placement_vertical_gap
+
+ if horizontal_mode == "layer":
+ layer_positions = _monotonic_layer_positions(
+ layer_sizes,
+ base_x,
+ base_y,
+ horizontal_gap,
+ )
+ vertex_x = {
+ vertex: layer_positions[layer][0]
+ for layer, vertices in layer_to_real.items()
+ for vertex in vertices
+ }
+ elif horizontal_mode == "compact":
+ vertex_x = _compact_mixed_vertex_x_positions(
+ layer_to_real,
+ layers,
+ edge_weights,
+ spec_by_vertex,
+ groups_by_id,
+ nodes_by_id,
+ vertex_y,
+ base_x,
+ horizontal_gap,
+ )
+ else:
+ raise ValueError(f"Unknown Phase 5 horizontal mode: {horizontal_mode}")
changed = False
for layer in sorted(layer_to_real):
- x, current_y = layer_positions[layer]
for vertex in layer_to_real[layer]:
spec = spec_by_vertex[vertex]
+ x = vertex_x[vertex]
+ y = vertex_y[vertex]
old_x, old_y = _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)
- if abs(old_x - x) > 1e-9 or abs(old_y - current_y) > 1e-9:
+ if abs(old_x - x) > 1e-9 or abs(old_y - y) > 1e-9:
changed = True
_move_mixed_vertex(
spec,
groups_by_id,
nodes_by_id,
x,
- current_y,
- )
- _width, height = _mixed_vertex_size(
- spec,
- groups_by_id,
- nodes_by_id,
+ y,
)
- current_y += height + placement_vertical_gap
return changed
@@ -747,6 +782,81 @@ def _monotonic_layer_positions(
return positions
+def _compact_mixed_vertex_x_positions(
+ layer_to_real: dict[int, list[int]],
+ layers: dict[int, int],
+ edge_weights: dict[tuple[int, int], int],
+ spec_by_vertex: dict[int, MixedSpec],
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+ vertex_y: dict[int, float],
+ base_x: float,
+ horizontal_gap: float,
+) -> dict[int, float]:
+ """Compact independent flow lanes without weakening edge direction.
+
+ The legacy Phase 5 realization gives every real vertex in one dependency
+ layer the same X coordinate and advances the next layer by the widest
+ rectangle in the layer. That is simple and safe, but one wide group can
+ needlessly push unrelated narrow chains to the right.
+
+ This variant keeps the same layer assignment and vertical order. Each vertex
+ starts at the leftmost X allowed by its already placed predecessors, then
+ moves right only as needed to clear already placed geometry whose vertical
+ span overlaps its own. Direct forward dependencies therefore remain
+ left-to-right while unrelated vertical lanes may use different X positions.
+ """
+ predecessors: dict[int, list[int]] = {}
+ for source, target in edge_weights:
+ if layers.get(source, 0) >= layers.get(target, 0):
+ continue
+ predecessors.setdefault(target, []).append(source)
+
+ positions: dict[int, float] = {}
+ placed_rects: list[tuple[float, float, float, float]] = []
+
+ for layer in sorted(layer_to_real):
+ for vertex in layer_to_real[layer]:
+ spec = spec_by_vertex[vertex]
+ width, height = _mixed_vertex_size(
+ spec,
+ groups_by_id,
+ nodes_by_id,
+ )
+ y = vertex_y[vertex]
+ x = base_x
+
+ for source in predecessors.get(vertex, []):
+ source_x = positions.get(source)
+ if source_x is None:
+ continue
+ source_width, _source_height = _mixed_vertex_size(
+ spec_by_vertex[source],
+ groups_by_id,
+ nodes_by_id,
+ )
+ x = max(x, source_x + source_width + horizontal_gap)
+
+ while True:
+ next_x = x
+ for left, top, right, bottom in placed_rects:
+ if y >= bottom or top >= y + height:
+ continue
+ if x >= right + horizontal_gap:
+ continue
+ if left >= x + width + horizontal_gap:
+ continue
+ next_x = max(next_x, right + horizontal_gap)
+ if next_x <= x + 1e-9:
+ break
+ x = next_x
+
+ positions[vertex] = x
+ placed_rects.append((x, y, x + width, y + height))
+
+ return positions
+
+
def _mixed_vertex_size(
spec: MixedSpec,
groups_by_id: dict[int, Group],
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index aa4d514..b37cfe9 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -148,6 +148,90 @@ def test_mixed_global_placement_preserves_group_internal_geometry():
assert workflow.nodes[11].x < extra.x
+def _parallel_mixed_workflow() -> Workflow:
+ workflow = Workflow()
+ wide_source = Node(id=1, type="WideSource", x=150, y=125, size=[200, 50])
+ narrow_source = Node(id=2, type="NarrowSource", x=150, y=265, size=[200, 50])
+ top_mid = Node(id=10, type="TopMid", x=3_000, y=125, size=[200, 100])
+ bottom_mid = Node(id=11, type="BottomMid", x=3_000, y=265, size=[200, 100])
+ top_last = Node(id=13, type="TopLast", x=4_500, y=125, size=[20, 100])
+ bottom_last = Node(id=12, type="BottomLast", x=4_500, y=265, size=[200, 100])
+
+ workflow.nodes = {
+ wide_source.id: wide_source,
+ narrow_source.id: narrow_source,
+ top_mid.id: top_mid,
+ bottom_mid.id: bottom_mid,
+ top_last.id: top_last,
+ bottom_last.id: bottom_last,
+ }
+ workflow.groups = [
+ Group(
+ id=100,
+ name="Wide",
+ nodes=[wide_source],
+ bounding=[100, 100, 1_000, 100],
+ ),
+ Group(
+ id=200,
+ name="Narrow",
+ nodes=[narrow_source],
+ bounding=[100, 240, 300, 100],
+ ),
+ ]
+ workflow.ungrouped_nodes = [top_mid, bottom_mid, top_last, bottom_last]
+
+ _attach_link(
+ workflow,
+ Link(id=1, source=1, source_port=0, target=10, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=2, source=2, source_port=0, target=11, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=3, source=10, source_port=0, target=13, target_port=0, type="DATA"),
+ )
+ _attach_link(
+ workflow,
+ Link(id=4, source=11, source_port=0, target=12, target_port=0, type="DATA"),
+ )
+ return workflow
+
+
+def test_compact_horizontal_mode_does_not_inherit_widest_parallel_layer():
+ layer_workflow = _parallel_mixed_workflow()
+ compact_workflow = _parallel_mixed_workflow()
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ assert _place_mixed_global_flow(
+ layer_workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="layer",
+ vertical_gap=40,
+ )
+ assert _place_mixed_global_flow(
+ compact_workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="compact",
+ vertical_gap=40,
+ )
+
+ assert compact_workflow.nodes[11].x < layer_workflow.nodes[11].x
+ assert compact_workflow.nodes[12].x < layer_workflow.nodes[12].x
+ assert compact_workflow.nodes[10].x == layer_workflow.nodes[10].x
+
+ for link in compact_workflow.links.values():
+ source = compact_workflow.nodes[link.source]
+ target = compact_workflow.nodes[link.target]
+ assert source.x + source.size[0] < target.x
+
+
def test_phase5_skips_pinned_group_geometry():
workflow = _mixed_chain_workflow()
extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
From 081e9b9085d57d4b1c900033102f39728c301008 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 18:00:59 +0200
Subject: [PATCH 078/105] Measure Phase 5 link-span cohesion
---
docs/LAYOUT_ENGINE_V2.md | 17 +++++++++++++++++
tools/report_phase5_diagnostics.py | 20 ++++++++++++++++++--
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 6748c88..e5e879b 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -703,6 +703,23 @@ dependency layer. Direct forward dependencies remain left-to-right, and the
complete existing Phase 5 acceptance gate remains authoritative. The original
shared-layer X realization is retained unchanged as a baseline variant.
+The first `compactx` corpus rerun produced one numerically accepted candidate:
+`Flux.2 klein 9B Reference Identity 1MP + 2x` with
+`weighted-compactx-gap-40`. It improved width from 5,810 to 4,850 px, port
+crossings from 82 to 78, center crossings from 93 to 92, port RTL from 12 to 10,
+and center RTL from 6 to 4 while keeping total workflow area slightly smaller.
+Visual inspection still rejected the result because the workflow became
+vertically dispersed: FAST MODE, QUALITY MODE, and output regions formed distant
+islands connected by long cross-canvas wires. This is a second, distinct false
+positive: all represented geometry is valid, but the current gate does not yet
+protect visual cohesion or link-span growth.
+
+The optional `--geometry` diagnostic now also reports total Manhattan
+port-to-port link length and the longest individual port link before/after the
+proposal. These measurements are diagnostic only; no new acceptance threshold
+is introduced until the corpus shows how strongly they separate visually valid
+and invalid candidates.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index a9a3a81..d3d5415 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -9,7 +9,7 @@
from pathlib import Path
from flowforge.layout import LayoutSettings, _node_visual_height, _node_visual_width
-from flowforge.layout_engine_v2 import _score_engine_v2
+from flowforge.layout_engine_v2 import _port_segments, _score_engine_v2
from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
from flowforge.layout_engine_v2_phase5 import (
_best_diagnostic_candidate,
@@ -212,13 +212,18 @@ def _print_geometry_diagnostics(baseline: Workflow) -> None:
before = _node_geometry_summary(baseline)
after = _node_geometry_summary(proposal.workflow)
+ before_max_link = _max_port_link_length(baseline)
+ after_max_link = _max_port_link_length(proposal.workflow)
print(
" geometry: "
f"node-bounds={before['width']:.0f}x{before['height']:.0f}"
f"->{after['width']:.0f}x{after['height']:.0f}; "
f"density={before['density']:.3f}->{after['density']:.3f}; "
f"max-x-gap={before['max_x_gap']:.0f}->{after['max_x_gap']:.0f}; "
- f"max-y-gap={before['max_y_gap']:.0f}->{after['max_y_gap']:.0f}"
+ f"max-y-gap={before['max_y_gap']:.0f}->{after['max_y_gap']:.0f}; "
+ f"link-length={baseline_score.link_length:.0f}"
+ f"->{proposal.score.link_length:.0f}; "
+ f"max-link={before_max_link:.0f}->{after_max_link:.0f}"
)
empty_groups = [
@@ -281,6 +286,17 @@ def _print_geometry_diagnostics(baseline: Workflow) -> None:
print(" moved-ungrouped: none")
+def _max_port_link_length(workflow: Workflow) -> float:
+ """Return the longest Manhattan port-to-port link in the workflow."""
+ return max(
+ (
+ abs(end[0] - start[0]) + abs(end[1] - start[1])
+ for _source, _target, start, end in _port_segments(workflow)
+ ),
+ default=0.0,
+ )
+
+
def _node_geometry_summary(workflow: Workflow) -> dict[str, float]:
rectangles = [
(
From b1eed0f774ba756b377894ce75f02200a272492f Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 18:05:24 +0200
Subject: [PATCH 079/105] Measure Phase 5 mixed-graph cohesion
---
docs/LAYOUT_ENGINE_V2.md | 14 +++--
tools/report_phase5_diagnostics.py | 83 ++++++++++++++++++++++++++++++
2 files changed, 94 insertions(+), 3 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index e5e879b..ced2116 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -716,9 +716,17 @@ protect visual cohesion or link-span growth.
The optional `--geometry` diagnostic now also reports total Manhattan
port-to-port link length and the longest individual port link before/after the
-proposal. These measurements are diagnostic only; no new acceptance threshold
-is introduced until the corpus shows how strongly they separate visually valid
-and invalid candidates.
+proposal. On the visually rejected `Flux.2 klein 9B Reference Identity 1MP +
+2x` candidate, however, those all-link metrics increased by only about 1.8%
+and 1.1% respectively, so they do not explain the severe visual islanding well.
+
+The diagnostic therefore also measures the geometry at the exact abstraction
+level Phase 5 moves: real mixed-graph vertices and mixed edges. It reports
+mixed-vertex bounds, approximate mixed-vertex density, largest free X/Y bands,
+weighted mixed-edge Manhattan length, and the longest mixed edge. This avoids
+diluting top-level dispersion with many internal links inside large groups.
+These measurements remain diagnostic only; no new acceptance threshold is
+introduced until they are compared across the attempted corpus.
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index d3d5415..cfa3628 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -13,7 +13,10 @@
from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
from flowforge.layout_engine_v2_phase5 import (
_best_diagnostic_candidate,
+ _build_mixed_graph,
_center_flow_metrics,
+ _mixed_vertex_size,
+ _mixed_vertex_xy,
_phase5_candidate_variants,
_select_accepted_phase5_candidate,
diagnose_phase5,
@@ -226,6 +229,23 @@ def _print_geometry_diagnostics(baseline: Workflow) -> None:
f"max-link={before_max_link:.0f}->{after_max_link:.0f}"
)
+ before_mixed = _mixed_geometry_summary(baseline)
+ after_mixed = _mixed_geometry_summary(proposal.workflow)
+ print(
+ " mixed-geometry: "
+ f"bounds={before_mixed['width']:.0f}x{before_mixed['height']:.0f}"
+ f"->{after_mixed['width']:.0f}x{after_mixed['height']:.0f}; "
+ f"density={before_mixed['density']:.3f}->{after_mixed['density']:.3f}; "
+ f"max-x-gap={before_mixed['max_x_gap']:.0f}"
+ f"->{after_mixed['max_x_gap']:.0f}; "
+ f"max-y-gap={before_mixed['max_y_gap']:.0f}"
+ f"->{after_mixed['max_y_gap']:.0f}; "
+ f"edge-length={before_mixed['edge_length']:.0f}"
+ f"->{after_mixed['edge_length']:.0f}; "
+ f"max-edge={before_mixed['max_edge']:.0f}"
+ f"->{after_mixed['max_edge']:.0f}"
+ )
+
empty_groups = [
group
for group in baseline.groups
@@ -286,6 +306,69 @@ def _print_geometry_diagnostics(baseline: Workflow) -> None:
print(" moved-ungrouped: none")
+def _mixed_geometry_summary(workflow: Workflow) -> dict[str, float]:
+ """Measure cohesion of the real vertices represented by the mixed graph."""
+ _specs, spec_by_vertex, _adjacency, edge_weights = _build_mixed_graph(workflow)
+ groups_by_id = {group.id: group for group in workflow.groups}
+ nodes_by_id = {node.id: node for node in workflow.nodes.values()}
+
+ rectangles: dict[int, tuple[float, float, float, float]] = {}
+ centers: dict[int, tuple[float, float]] = {}
+ for vertex, spec in spec_by_vertex.items():
+ x, y = _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)
+ width, height = _mixed_vertex_size(spec, groups_by_id, nodes_by_id)
+ rectangles[vertex] = (x, y, x + width, y + height)
+ centers[vertex] = (x + width / 2.0, y + height / 2.0)
+
+ if not rectangles:
+ return {
+ "width": 0.0,
+ "height": 0.0,
+ "density": 0.0,
+ "max_x_gap": 0.0,
+ "max_y_gap": 0.0,
+ "edge_length": 0.0,
+ "max_edge": 0.0,
+ }
+
+ left = min(rect[0] for rect in rectangles.values())
+ top = min(rect[1] for rect in rectangles.values())
+ right = max(rect[2] for rect in rectangles.values())
+ bottom = max(rect[3] for rect in rectangles.values())
+ width = max(0.0, right - left)
+ height = max(0.0, bottom - top)
+ occupied_area = sum(
+ max(0.0, rect[2] - rect[0]) * max(0.0, rect[3] - rect[1])
+ for rect in rectangles.values()
+ )
+ bounds_area = width * height
+
+ weighted_edge_length = 0.0
+ max_edge = 0.0
+ for (source, target), weight in edge_weights.items():
+ start = centers.get(source)
+ end = centers.get(target)
+ if start is None or end is None:
+ continue
+ distance = abs(end[0] - start[0]) + abs(end[1] - start[1])
+ weighted_edge_length += distance * max(1, weight)
+ max_edge = max(max_edge, distance)
+
+ return {
+ "width": width,
+ "height": height,
+ "density": occupied_area / bounds_area if bounds_area > 0 else 0.0,
+ "max_x_gap": _largest_axis_gap(
+ (rect[0], rect[2]) for rect in rectangles.values()
+ ),
+ "max_y_gap": _largest_axis_gap(
+ (rect[1], rect[3]) for rect in rectangles.values()
+ ),
+ "edge_length": weighted_edge_length,
+ "max_edge": max_edge,
+ }
+
+
def _max_port_link_length(workflow: Workflow) -> float:
"""Return the longest Manhattan port-to-port link in the workflow."""
return max(
From d63c0023d6df3d205984b7dc5566f38cd139bb08 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:43:17 +0200
Subject: [PATCH 080/105] Compare Phase 5 physical variants
---
docs/LAYOUT_ENGINE_V2.md | 11 +++++++
tools/report_phase5_diagnostics.py | 48 ++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index ced2116..0a5c884 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -588,6 +588,10 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize --
# Geometry review for a selected workflow/proposal.
uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--match "Virtual Try-On 6.0 (Codex)" --geometry
+
+# Compare every physical Phase 5 variant for one selected workflow.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Reference Identity 1MP + 2x" --variants
```
## Current Scope Boundary
@@ -728,6 +732,13 @@ diluting top-level dispersion with many internal links inside large groups.
These measurements remain diagnostic only; no new acceptance threshold is
introduced until they are compared across the attempted corpus.
+The reporter also supports `--variants` for one targeted workflow. It prints
+all physical Phase 5 candidates, their existing gate result, and their mixed
+edge-length/max-edge metrics. This is intended to distinguish a bad candidate
+selection from a missing safety gate: if another already-generated variant
+passes the current gate with materially better cohesion, candidate ranking
+should be fixed before adding a new rejection rule.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index cfa3628..4dc4f1b 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -15,6 +15,7 @@
_best_diagnostic_candidate,
_build_mixed_graph,
_center_flow_metrics,
+ _mixed_rejection_reason,
_mixed_vertex_size,
_mixed_vertex_xy,
_phase5_candidate_variants,
@@ -76,6 +77,14 @@ def main() -> int:
"empty groups, node gaps, density, and moved top-level items."
),
)
+ parser.add_argument(
+ "--variants",
+ action="store_true",
+ help=(
+ "Print every Phase 5 physical candidate with gate result and "
+ "mixed-graph edge-length diagnostics."
+ ),
+ )
args = parser.parse_args()
if args.accepted_only and args.attempted_only:
@@ -165,6 +174,8 @@ def main() -> int:
if args.geometry and diag.attempted:
_print_geometry_diagnostics(baseline)
+ if args.variants and diag.attempted:
+ _print_variant_diagnostics(baseline)
print(
"Summary: "
@@ -192,6 +203,43 @@ def _print_reason_summary(title: str, reasons: dict[str, int]) -> None:
print(f"{title}: {values}")
+def _print_variant_diagnostics(baseline: Workflow) -> None:
+ """Print every physical Phase 5 proposal against the same Phase 4 baseline."""
+ baseline_score = _score_engine_v2(baseline)
+ baseline_center = _center_flow_metrics(baseline)
+ baseline_mixed = _mixed_geometry_summary(baseline)
+ candidates = _phase5_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ print(" variants:")
+ for candidate in sorted(candidates, key=lambda item: item.name):
+ reason = _mixed_rejection_reason(
+ candidate.score,
+ baseline_score,
+ candidate.center_metrics,
+ baseline_center,
+ )
+ mixed = _mixed_geometry_summary(candidate.workflow)
+ edge_delta = (
+ (mixed["edge_length"] / baseline_mixed["edge_length"] - 1.0) * 100.0
+ if baseline_mixed["edge_length"] > 0
+ else 0.0
+ )
+ print(
+ f" - {candidate.name}: "
+ f"{candidate.score.width:.0f}x{candidate.score.height:.0f}; "
+ f"port-crossings={candidate.score.crossings}; "
+ f"center-crossings={candidate.center_metrics.crossings}; "
+ f"port-rtl={candidate.score.right_to_left_links}; "
+ f"center-rtl={candidate.center_metrics.right_to_left_links}; "
+ f"mixed-edge={mixed['edge_length']:.0f} ({edge_delta:+.1f}%); "
+ f"max-mixed-edge={mixed['max_edge']:.0f}; "
+ f"reason={reason}"
+ )
+
+
def _print_geometry_diagnostics(baseline: Workflow) -> None:
baseline_score = _score_engine_v2(baseline)
baseline_center = _center_flow_metrics(baseline)
From 133aedc48d221084271d2ffdb1f0a84b3ddb8689 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:47:50 +0200
Subject: [PATCH 081/105] Add Phase 5 anchored X compaction
---
docs/LAYOUT_ENGINE_V2.md | 18 ++++
flowforge/layout_engine_v2_phase5.py | 137 ++++++++++++++++++++++++--
tests/test_layout_engine_v2_phase5.py | 51 ++++++++++
3 files changed, 200 insertions(+), 6 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 0a5c884..61be71c 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -739,6 +739,24 @@ selection from a missing safety gate: if another already-generated variant
passes the current gate with materially better cohesion, candidate ranking
should be fixed before adding a new rejection rule.
+For the visually rejected Flux 9B candidate, the 12-way comparison ruled out a
+simple ranking bug. Every shared-layer X variant kept weighted mixed-edge length
+roughly flat or slightly lower (`-2.9% .. -0.1%`) but failed the crossing/RTL
+safety gate. Every compact-X variant reduced width/RTL more aggressively but
+increased weighted mixed-edge length by about `+10% .. +13%`; only
+`weighted-compactx-gap-40` passed the current gate. The problem is therefore
+the physical X realization itself, not selection among equivalent accepted
+candidates.
+
+Phase 5 now evaluates an additional `anchoredx` realization. It starts from
+the exact earliest-feasible Compact-X schedule, keeps the same compact right
+boundary, direct forward-edge constraints, and horizontal ordering of vertically
+overlapping rectangles, then uses available horizontal slack to move vertices
+back toward their Phase 4 X positions. This targets weakly constrained branches
+that Compact-X otherwise drags all the way to the left while preserving the
+compact width envelope. The existing `layer` and `compactx` realizations remain
+unchanged for comparison.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index dd039a8..6e3d73b 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -324,7 +324,7 @@ def _phase5_candidate_variants(
"""Build scored physical variants without changing the mixed dependency graph."""
candidates: list[_Phase5Candidate] = []
for order_mode in ("weighted", "stable"):
- for horizontal_mode in ("layer", "compact"):
+ for horizontal_mode in ("layer", "compact", "anchored"):
for vertical_gap in _phase5_vertical_gaps(settings):
candidate = deepcopy(baseline)
if not _place_mixed_global_flow(
@@ -337,11 +337,12 @@ def _phase5_candidate_variants(
):
continue
_finalize_refinement(candidate, settings)
- variant_name = (
- f"{order_mode}-gap-{vertical_gap:g}"
- if horizontal_mode == "layer"
- else f"{order_mode}-compactx-gap-{vertical_gap:g}"
- )
+ if horizontal_mode == "layer":
+ variant_name = f"{order_mode}-gap-{vertical_gap:g}"
+ elif horizontal_mode == "compact":
+ variant_name = f"{order_mode}-compactx-gap-{vertical_gap:g}"
+ else:
+ variant_name = f"{order_mode}-anchoredx-gap-{vertical_gap:g}"
candidates.append(
_Phase5Candidate(
name=variant_name,
@@ -745,6 +746,18 @@ def _place_mixed_global_flow(
base_x,
horizontal_gap,
)
+ elif horizontal_mode == "anchored":
+ vertex_x = _anchored_compact_mixed_vertex_x_positions(
+ layer_to_real,
+ layers,
+ edge_weights,
+ spec_by_vertex,
+ groups_by_id,
+ nodes_by_id,
+ vertex_y,
+ base_x,
+ horizontal_gap,
+ )
else:
raise ValueError(f"Unknown Phase 5 horizontal mode: {horizontal_mode}")
@@ -857,6 +870,118 @@ def _compact_mixed_vertex_x_positions(
return positions
+def _anchored_compact_mixed_vertex_x_positions(
+ layer_to_real: dict[int, list[int]],
+ layers: dict[int, int],
+ edge_weights: dict[tuple[int, int], int],
+ spec_by_vertex: dict[int, MixedSpec],
+ groups_by_id: dict[int, Group],
+ nodes_by_id: dict[int, Node],
+ vertex_y: dict[int, float],
+ base_x: float,
+ horizontal_gap: float,
+) -> dict[int, float]:
+ """Use compact width while retaining as much baseline X placement as possible.
+
+ The normal compact realization is an earliest-feasible schedule: every real
+ vertex is pushed as far left as dependency and overlap constraints allow.
+ That can over-compact weakly constrained branches into distant islands.
+
+ Anchored compaction starts from the exact compact schedule, keeps its right
+ boundary, direct forward-edge constraints, and horizontal ordering for
+ vertically overlapping rectangles, then shifts vertices right within their
+ available slack toward their Phase 4 X positions. It can therefore preserve
+ authored/top-level locality without giving back the compact width envelope.
+ """
+ earliest = _compact_mixed_vertex_x_positions(
+ layer_to_real,
+ layers,
+ edge_weights,
+ spec_by_vertex,
+ groups_by_id,
+ nodes_by_id,
+ vertex_y,
+ base_x,
+ horizontal_gap,
+ )
+ if not earliest:
+ return {}
+
+ widths: dict[int, float] = {}
+ heights: dict[int, float] = {}
+ baseline_x: dict[int, float] = {}
+ for vertex in earliest:
+ spec = spec_by_vertex[vertex]
+ width, height = _mixed_vertex_size(spec, groups_by_id, nodes_by_id)
+ widths[vertex] = width
+ heights[vertex] = height
+ baseline_x[vertex] = _mixed_vertex_xy(
+ spec,
+ groups_by_id,
+ nodes_by_id,
+ )[0]
+
+ right_boundary = max(
+ earliest[vertex] + widths[vertex]
+ for vertex in earliest
+ )
+
+ successors: dict[int, list[int]] = {}
+ for source, target in edge_weights:
+ if (
+ source in earliest
+ and target in earliest
+ and layers.get(source, 0) < layers.get(target, 0)
+ ):
+ successors.setdefault(source, []).append(target)
+
+ right_neighbours: dict[int, list[int]] = {}
+ vertices = list(earliest)
+ for index, first in enumerate(vertices):
+ first_top = vertex_y[first]
+ first_bottom = first_top + heights[first]
+ for second in vertices[index + 1 :]:
+ second_top = vertex_y[second]
+ second_bottom = second_top + heights[second]
+ if first_top >= second_bottom or second_top >= first_bottom:
+ continue
+
+ first_right = earliest[first] + widths[first]
+ second_right = earliest[second] + widths[second]
+ if first_right + horizontal_gap <= earliest[second] + 1e-9:
+ right_neighbours.setdefault(first, []).append(second)
+ elif second_right + horizontal_gap <= earliest[first] + 1e-9:
+ right_neighbours.setdefault(second, []).append(first)
+
+ positions = dict(earliest)
+ for vertex in sorted(
+ positions,
+ key=lambda value: (earliest[value], layers.get(value, 0), value),
+ reverse=True,
+ ):
+ width = widths[vertex]
+ upper_bound = right_boundary - width
+
+ for target in successors.get(vertex, []):
+ upper_bound = min(
+ upper_bound,
+ positions[target] - width - horizontal_gap,
+ )
+ for neighbour in right_neighbours.get(vertex, []):
+ upper_bound = min(
+ upper_bound,
+ positions[neighbour] - width - horizontal_gap,
+ )
+
+ desired = baseline_x[vertex]
+ positions[vertex] = max(
+ earliest[vertex],
+ min(desired, upper_bound),
+ )
+
+ return positions
+
+
def _mixed_vertex_size(
spec: MixedSpec,
groups_by_id: dict[int, Group],
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index b37cfe9..4b278ca 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -232,6 +232,57 @@ def test_compact_horizontal_mode_does_not_inherit_widest_parallel_layer():
assert source.x + source.size[0] < target.x
+def test_anchored_horizontal_mode_uses_compact_width_with_baseline_slack():
+ compact_workflow = _parallel_mixed_workflow()
+ anchored_workflow = _parallel_mixed_workflow()
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ assert _place_mixed_global_flow(
+ compact_workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="compact",
+ vertical_gap=40,
+ )
+ assert _place_mixed_global_flow(
+ anchored_workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="anchored",
+ vertical_gap=40,
+ )
+
+ assert anchored_workflow.nodes[11].x > compact_workflow.nodes[11].x
+ assert anchored_workflow.nodes[12].x >= compact_workflow.nodes[12].x
+
+ compact_right = max(
+ [
+ *(group.bounding[0] + group.bounding[2] for group in compact_workflow.groups),
+ *(
+ node.x + node.size[0]
+ for node in compact_workflow.ungrouped_nodes
+ ),
+ ]
+ )
+ anchored_right = max(
+ [
+ *(group.bounding[0] + group.bounding[2] for group in anchored_workflow.groups),
+ *(
+ node.x + node.size[0]
+ for node in anchored_workflow.ungrouped_nodes
+ ),
+ ]
+ )
+ assert anchored_right <= compact_right + 1e-9
+
+ for link in anchored_workflow.links.values():
+ source = anchored_workflow.nodes[link.source]
+ target = anchored_workflow.nodes[link.target]
+ assert source.x + source.size[0] < target.x
+
+
def test_phase5_skips_pinned_group_geometry():
workflow = _mixed_chain_workflow()
extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
From d3efa868ad296d5f886a82d707a916dd97c25d75 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:51:08 +0200
Subject: [PATCH 082/105] Export exact Phase 5 diagnostic variants
---
docs/LAYOUT_ENGINE_V2.md | 14 ++++++++
tools/report_phase5_diagnostics.py | 53 ++++++++++++++++++++++++++++++
2 files changed, 67 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 61be71c..28db359 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -592,6 +592,12 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
# Compare every physical Phase 5 variant for one selected workflow.
uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--match "Reference Identity 1MP + 2x" --variants
+
+# Export one exact diagnostic candidate without changing production selection.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Reference Identity 1MP + 2x" `
+ --export-variant "weighted-anchoredx-gap-40" `
+ --output "phase5-review/Flux2-9B-anchoredx.json"
```
## Current Scope Boundary
@@ -757,6 +763,14 @@ that Compact-X otherwise drags all the way to the left while preserving the
compact width envelope. The existing `layer` and `compactx` realizations remain
unchanged for comparison.
+On the Flux 9B review case, `weighted-anchoredx-gap-40` also passes the full
+existing gate at the same 4,850 x 3,308 size. It keeps center crossings at 92,
+has 81 port crossings, and reduces weighted mixed-edge growth from the Compact-X
+candidate's +10.3% to only +1.7%. Production selection still prefers Compact-X
+because its port crossing/RTL metrics are numerically lower. The diagnostic
+reporter can therefore export an exact named variant with `--export-variant`
+and `--output` for visual review without changing production candidate ranking.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 4dc4f1b..48a6e7a 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -8,6 +8,7 @@
import sys
from pathlib import Path
+from flowforge.api import _workflow_to_comfyui_json
from flowforge.layout import LayoutSettings, _node_visual_height, _node_visual_width
from flowforge.layout_engine_v2 import _port_segments, _score_engine_v2
from flowforge.layout_engine_v2_phase4 import apply_best_layout as apply_phase4_layout
@@ -85,10 +86,23 @@ def main() -> int:
"mixed-graph edge-length diagnostics."
),
)
+ parser.add_argument(
+ "--export-variant",
+ help=(
+ "Export one exact Phase 5 variant name for a single matched workflow."
+ ),
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="Output JSON path used with --export-variant.",
+ )
args = parser.parse_args()
if args.accepted_only and args.attempted_only:
parser.error("--accepted-only and --attempted-only are mutually exclusive")
+ if bool(args.export_variant) != bool(args.output):
+ parser.error("--export-variant and --output must be used together")
if args.accepted_only or args.attempted_only:
logging.disable(logging.INFO)
@@ -105,6 +119,8 @@ def main() -> int:
if not selected:
print("No matching workflow files found.")
return 1
+ if args.export_variant and len(selected) != 1:
+ parser.error("--export-variant requires exactly one selected workflow")
attempted_count = 0
accepted_count = 0
@@ -176,6 +192,12 @@ def main() -> int:
_print_geometry_diagnostics(baseline)
if args.variants and diag.attempted:
_print_variant_diagnostics(baseline)
+ if args.export_variant:
+ _export_variant(
+ baseline,
+ args.export_variant,
+ args.output,
+ )
print(
"Summary: "
@@ -190,6 +212,37 @@ def main() -> int:
return 0
+def _export_variant(
+ baseline: Workflow,
+ variant_name: str,
+ output_path: Path,
+) -> None:
+ """Export one exact Phase 5 physical candidate without changing selection."""
+ baseline_score = _score_engine_v2(baseline)
+ candidates = _phase5_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ candidate = next(
+ (item for item in candidates if item.name == variant_name),
+ None,
+ )
+ if candidate is None:
+ available = ", ".join(sorted(item.name for item in candidates))
+ raise SystemExit(
+ f"Unknown Phase 5 variant {variant_name!r}. Available: {available}"
+ )
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ payload = _workflow_to_comfyui_json(candidate.workflow)
+ output_path.write_text(
+ json.dumps(payload, indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ print(f" exported: {candidate.name} -> {output_path}")
+
+
def _print_reason_summary(title: str, reasons: dict[str, int]) -> None:
if not reasons:
return
From d2401edf67fd9085133644666dc69934990f9b33 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:58:53 +0200
Subject: [PATCH 083/105] Add diagnostic Phase 4 Y anchoring
---
docs/LAYOUT_ENGINE_V2.md | 22 +++++++++
flowforge/layout_engine_v2_phase5.py | 66 +++++++++++++++++++++++----
tests/test_layout_engine_v2_phase5.py | 25 ++++++++++
tools/report_phase5_diagnostics.py | 22 ++++++++-
4 files changed, 123 insertions(+), 12 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 28db359..ed6550a 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -598,6 +598,14 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--match "Reference Identity 1MP + 2x" `
--export-variant "weighted-anchoredx-gap-40" `
--output "phase5-review/Flux2-9B-anchoredx.json"
+
+# Experimental Anchored-X + original Phase 4 Y diagnostic.
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Reference Identity 1MP + 2x" --variants
+uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
+ --match "Reference Identity 1MP + 2x" `
+ --export-variant "weighted-anchoredxy" `
+ --output "phase5-review/Flux2-9B-anchoredxy.json"
```
## Current Scope Boundary
@@ -771,6 +779,20 @@ because its port crossing/RTL metrics are numerically lower. The diagnostic
reporter can therefore export an exact named variant with `--export-variant`
and `--output` for visual review without changing production candidate ranking.
+Visual review of that anchored-X export showed a substantial horizontal
+improvement, but still failed overall: QUALITY MODE remained far below the main
+workflow, FAST MODE remained below the central region, and long vertical/diagonal
+links crossed large empty bands. This isolates the remaining failure to the
+vertical realization. Phase 5 still rebuilds Y from a shared `base_y` and
+stacks every real vertex in each dependency layer, even when the Phase 4
+vertical positions were already coherent.
+
+For diagnosis only, the reporter now includes two experimental
+`*-anchoredxy` variants. They use the same Anchored-X realization but preserve
+the Phase 4 Y coordinate of every real mixed vertex before the normal finalizer.
+These variants are deliberately excluded from production Phase 5 selection
+until their metrics and visual output are reviewed.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 6e3d73b..f9e5a7f 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -354,6 +354,40 @@ def _phase5_candidate_variants(
return candidates
+def _phase5_baseline_y_candidate_variants(
+ baseline: Workflow,
+ settings: LayoutSettings,
+ baseline_score: EngineV2Score,
+) -> list[_Phase5Candidate]:
+ """Build diagnostic Anchored-X candidates that preserve Phase 4 Y positions.
+
+ These variants are intentionally excluded from normal Phase 5 selection
+ until visual review confirms that baseline-Y anchoring improves cohesion.
+ """
+ candidates: list[_Phase5Candidate] = []
+ for order_mode in ("weighted", "stable"):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode="anchored",
+ vertical_mode="baseline",
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ candidates.append(
+ _Phase5Candidate(
+ name=f"{order_mode}-anchoredxy",
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
+ )
+ return candidates
+
+
def _phase5_vertical_gaps(settings: LayoutSettings) -> list[float]:
"""Return conservative spacing variants derived from existing layout settings."""
standard = max(settings.group_v_gap, settings.node_v_gap)
@@ -615,6 +649,7 @@ def _place_mixed_global_flow(
order_mode: str = "weighted",
horizontal_mode: str = "layer",
vertical_gap: float | None = None,
+ vertical_mode: str = "stacked",
) -> bool:
"""Place mixed-flow layers with conservative left-to-right geometry."""
del workflow_width
@@ -711,16 +746,27 @@ def _place_mixed_global_flow(
default=50.0,
)
vertex_y: dict[int, float] = {}
- for layer in sorted(layer_to_real):
- current_y = base_y
- for vertex in layer_to_real[layer]:
- vertex_y[vertex] = current_y
- _width, height = _mixed_vertex_size(
- spec_by_vertex[vertex],
- groups_by_id,
- nodes_by_id,
- )
- current_y += height + placement_vertical_gap
+ if vertical_mode == "stacked":
+ for layer in sorted(layer_to_real):
+ current_y = base_y
+ for vertex in layer_to_real[layer]:
+ vertex_y[vertex] = current_y
+ _width, height = _mixed_vertex_size(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )
+ current_y += height + placement_vertical_gap
+ elif vertical_mode == "baseline":
+ for vertices in layer_to_real.values():
+ for vertex in vertices:
+ vertex_y[vertex] = _mixed_vertex_xy(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )[1]
+ else:
+ raise ValueError(f"Unknown Phase 5 vertical mode: {vertical_mode}")
if horizontal_mode == "layer":
layer_positions = _monotonic_layer_positions(
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 4b278ca..7bad83a 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -283,6 +283,31 @@ def test_anchored_horizontal_mode_uses_compact_width_with_baseline_slack():
assert source.x + source.size[0] < target.x
+def test_baseline_vertical_mode_preserves_phase4_y_positions():
+ workflow = _parallel_mixed_workflow()
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+ original_group_y = {group.id: group.bounding[1] for group in workflow.groups}
+ original_node_y = {node.id: node.y for node in workflow.ungrouped_nodes}
+
+ assert _place_mixed_global_flow(
+ workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="weighted",
+ horizontal_mode="anchored",
+ vertical_mode="baseline",
+ )
+
+ assert {
+ group.id: group.bounding[1]
+ for group in workflow.groups
+ } == original_group_y
+ assert {
+ node.id: node.y
+ for node in workflow.ungrouped_nodes
+ } == original_node_y
+
+
def test_phase5_skips_pinned_group_geometry():
workflow = _mixed_chain_workflow()
extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 48a6e7a..15b4e17 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -15,6 +15,7 @@
from flowforge.layout_engine_v2_phase5 import (
_best_diagnostic_candidate,
_build_mixed_graph,
+ _phase5_baseline_y_candidate_variants,
_center_flow_metrics,
_mixed_rejection_reason,
_mixed_vertex_size,
@@ -224,6 +225,13 @@ def _export_variant(
settings=LayoutSettings(),
baseline_score=baseline_score,
)
+ candidates.extend(
+ _phase5_baseline_y_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ )
candidate = next(
(item for item in candidates if item.name == variant_name),
None,
@@ -261,12 +269,22 @@ def _print_variant_diagnostics(baseline: Workflow) -> None:
baseline_score = _score_engine_v2(baseline)
baseline_center = _center_flow_metrics(baseline)
baseline_mixed = _mixed_geometry_summary(baseline)
- candidates = _phase5_candidate_variants(
+ standard_candidates = _phase5_candidate_variants(
baseline,
settings=LayoutSettings(),
baseline_score=baseline_score,
)
- print(" variants:")
+ experimental_candidates = _phase5_baseline_y_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ candidates = [*standard_candidates, *experimental_candidates]
+ print(
+ " variants: "
+ f"standard={len(standard_candidates)} "
+ f"experimental-baseline-y={len(experimental_candidates)}"
+ )
for candidate in sorted(candidates, key=lambda item: item.name):
reason = _mixed_rejection_reason(
candidate.score,
From 45c331b7cad5ddc5d06693ddb244087621786146 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 20:07:07 +0200
Subject: [PATCH 084/105] Add diagnostic Phase 5 Y-band anchoring
---
docs/LAYOUT_ENGINE_V2.md | 17 ++++++--
flowforge/layout_engine_v2_phase5.py | 63 +++++++++++++++++++++++++++
tests/test_layout_engine_v2_phase5.py | 21 +++++++++
tools/report_phase5_diagnostics.py | 24 ++++++++--
4 files changed, 119 insertions(+), 6 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index ed6550a..3b35c50 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -787,11 +787,22 @@ vertical realization. Phase 5 still rebuilds Y from a shared `base_y` and
stacks every real vertex in each dependency layer, even when the Phase 4
vertical positions were already coherent.
-For diagnosis only, the reporter now includes two experimental
+For diagnosis only, the reporter includes two experimental
`*-anchoredxy` variants. They use the same Anchored-X realization but preserve
the Phase 4 Y coordinate of every real mixed vertex before the normal finalizer.
-These variants are deliberately excluded from production Phase 5 selection
-until their metrics and visual output are reviewed.
+On the Flux 9B case this exact-Y experiment failed strongly: the weighted
+variant expanded to 6,730 x 2,774, raised port crossings from 82 to 105 and
+center crossings from 93 to 113, and increased weighted mixed-edge length by
+19%. Preserving every original Y coordinate therefore creates too many vertical
+overlaps for compact X placement and simply pushes geometry back out horizontally.
+
+A second diagnostic-only Y experiment, `*-anchoredx-yband-gap-*`, preserves
+the Phase 5 vertical order and spacing inside each dependency layer but shifts
+the entire stacked layer toward its Phase 4 vertical band using the median
+per-vertex Y offset. This is a middle ground between rebuilding every layer from
+one global `base_y` and restoring every individual Phase 4 Y coordinate. The
+Y-band candidates are also excluded from production Phase 5 selection until
+their metrics and visual output are reviewed.
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index f9e5a7f..e524066 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -388,6 +388,38 @@ def _phase5_baseline_y_candidate_variants(
return candidates
+def _phase5_layer_anchor_candidate_variants(
+ baseline: Workflow,
+ settings: LayoutSettings,
+ baseline_score: EngineV2Score,
+) -> list[_Phase5Candidate]:
+ """Build diagnostic Anchored-X candidates with layer-level Y anchoring."""
+ candidates: list[_Phase5Candidate] = []
+ for order_mode in ("weighted", "stable"):
+ for vertical_gap in _phase5_vertical_gaps(settings):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode="anchored",
+ vertical_gap=vertical_gap,
+ vertical_mode="layer_anchor",
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ candidates.append(
+ _Phase5Candidate(
+ name=f"{order_mode}-anchoredx-yband-gap-{vertical_gap:g}",
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
+ )
+ return candidates
+
+
def _phase5_vertical_gaps(settings: LayoutSettings) -> list[float]:
"""Return conservative spacing variants derived from existing layout settings."""
standard = max(settings.group_v_gap, settings.node_v_gap)
@@ -765,6 +797,37 @@ def _place_mixed_global_flow(
groups_by_id,
nodes_by_id,
)[1]
+ elif vertical_mode == "layer_anchor":
+ for layer in sorted(layer_to_real):
+ current_y = base_y
+ stacked_y: dict[int, float] = {}
+ offsets: list[float] = []
+ for vertex in layer_to_real[layer]:
+ stacked_y[vertex] = current_y
+ original_y = _mixed_vertex_xy(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )[1]
+ offsets.append(original_y - current_y)
+ _width, height = _mixed_vertex_size(
+ spec_by_vertex[vertex],
+ groups_by_id,
+ nodes_by_id,
+ )
+ current_y += height + placement_vertical_gap
+
+ ordered_offsets = sorted(offsets)
+ middle = len(ordered_offsets) // 2
+ if len(ordered_offsets) % 2:
+ layer_shift = ordered_offsets[middle]
+ else:
+ layer_shift = (
+ ordered_offsets[middle - 1] + ordered_offsets[middle]
+ ) / 2.0
+
+ for vertex, stacked in stacked_y.items():
+ vertex_y[vertex] = stacked + layer_shift
else:
raise ValueError(f"Unknown Phase 5 vertical mode: {vertical_mode}")
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 7bad83a..0af8002 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -308,6 +308,27 @@ def test_baseline_vertical_mode_preserves_phase4_y_positions():
} == original_node_y
+def test_layer_anchor_vertical_mode_preserves_layer_spacing_near_baseline_band():
+ workflow = _parallel_mixed_workflow()
+ workflow.nodes[11].y = 600
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ assert _place_mixed_global_flow(
+ workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="anchored",
+ vertical_gap=40,
+ vertical_mode="layer_anchor",
+ )
+
+ top_mid = workflow.nodes[10]
+ bottom_mid = workflow.nodes[11]
+ assert bottom_mid.y - top_mid.y == 140
+ assert (top_mid.y + bottom_mid.y) / 2.0 == 362.5
+
+
def test_phase5_skips_pinned_group_geometry():
workflow = _mixed_chain_workflow()
extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 15b4e17..6ffdc32 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -16,6 +16,7 @@
_best_diagnostic_candidate,
_build_mixed_graph,
_phase5_baseline_y_candidate_variants,
+ _phase5_layer_anchor_candidate_variants,
_center_flow_metrics,
_mixed_rejection_reason,
_mixed_vertex_size,
@@ -232,6 +233,13 @@ def _export_variant(
baseline_score=baseline_score,
)
)
+ candidates.extend(
+ _phase5_layer_anchor_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ )
candidate = next(
(item for item in candidates if item.name == variant_name),
None,
@@ -274,16 +282,26 @@ def _print_variant_diagnostics(baseline: Workflow) -> None:
settings=LayoutSettings(),
baseline_score=baseline_score,
)
- experimental_candidates = _phase5_baseline_y_candidate_variants(
+ baseline_y_candidates = _phase5_baseline_y_candidate_variants(
baseline,
settings=LayoutSettings(),
baseline_score=baseline_score,
)
- candidates = [*standard_candidates, *experimental_candidates]
+ layer_anchor_candidates = _phase5_layer_anchor_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ candidates = [
+ *standard_candidates,
+ *baseline_y_candidates,
+ *layer_anchor_candidates,
+ ]
print(
" variants: "
f"standard={len(standard_candidates)} "
- f"experimental-baseline-y={len(experimental_candidates)}"
+ f"experimental-baseline-y={len(baseline_y_candidates)} "
+ f"experimental-yband={len(layer_anchor_candidates)}"
)
for candidate in sorted(candidates, key=lambda item: item.name):
reason = _mixed_rejection_reason(
From 3706aebeebc8cd753c159860b15b5c40f2f1eeb6 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 20:23:02 +0200
Subject: [PATCH 085/105] Add diagnostic Phase 5 Y-band compression
---
docs/LAYOUT_ENGINE_V2.md | 4 +++
flowforge/layout_engine_v2_phase5.py | 46 ++++++++++++++++++++++++++-
tests/test_layout_engine_v2_phase5.py | 22 +++++++++++++
tools/report_phase5_diagnostics.py | 17 +++++++++-
4 files changed, 87 insertions(+), 2 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 3b35c50..0be7db4 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -804,6 +804,10 @@ one global `base_y` and restoring every individual Phase 4 Y coordinate. The
Y-band candidates are also excluded from production Phase 5 selection until
their metrics and visual output are reviewed.
+The full Y-band review on Flux 9B produced the best visual result so far at `weighted-anchoredx-yband-gap-40`: width remained 4,850 px, port crossings improved from 82 to 78, center crossings from 93 to 88, and weighted mixed-edge growth stayed at +1.5%. Its remaining failure is vertical extent: 4,850 x 3,731 grows total area by about 12.3%.
+
+To measure that trade-off instead of inventing another Y heuristic, diagnostics now include `yband25`, `yband50`, and `yband75` at the compact 40 px internal gap. They apply 25%, 50%, or 75% of the layer-level Phase 4 Y shift. A strength of 0% is the existing stacked Anchored-X placement; 100% is the full Y-band placement. These compressed Y-band candidates remain diagnostic-only.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index e524066..47c258e 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -420,6 +420,44 @@ def _phase5_layer_anchor_candidate_variants(
return candidates
+def _phase5_compressed_yband_candidate_variants(
+ baseline: Workflow,
+ settings: LayoutSettings,
+ baseline_score: EngineV2Score,
+) -> list[_Phase5Candidate]:
+ """Build diagnostic partial Y-band anchor candidates at compact spacing."""
+ candidates: list[_Phase5Candidate] = []
+ compact_gap = _phase5_vertical_gaps(settings)[-1]
+ for order_mode in ("weighted", "stable"):
+ for anchor_strength in (0.25, 0.5, 0.75):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode="anchored",
+ vertical_gap=compact_gap,
+ vertical_mode="layer_anchor",
+ vertical_anchor_strength=anchor_strength,
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ percent = int(round(anchor_strength * 100))
+ candidates.append(
+ _Phase5Candidate(
+ name=(
+ f"{order_mode}-anchoredx-yband{percent}"
+ f"-gap-{compact_gap:g}"
+ ),
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
+ )
+ return candidates
+
+
def _phase5_vertical_gaps(settings: LayoutSettings) -> list[float]:
"""Return conservative spacing variants derived from existing layout settings."""
standard = max(settings.group_v_gap, settings.node_v_gap)
@@ -682,6 +720,7 @@ def _place_mixed_global_flow(
horizontal_mode: str = "layer",
vertical_gap: float | None = None,
vertical_mode: str = "stacked",
+ vertical_anchor_strength: float = 1.0,
) -> bool:
"""Place mixed-flow layers with conservative left-to-right geometry."""
del workflow_width
@@ -740,6 +779,10 @@ def _place_mixed_global_flow(
if vertical_gap is None
else max(MIXED_GLOBAL_MIN_VERTICAL_GAP, vertical_gap)
)
+ if not 0.0 <= vertical_anchor_strength <= 1.0:
+ raise ValueError(
+ "Phase 5 vertical anchor strength must be between 0 and 1"
+ )
horizontal_gap = max(settings.group_h_gap, settings.node_h_gap)
for layer, vertices in layer_to_real.items():
@@ -826,8 +869,9 @@ def _place_mixed_global_flow(
ordered_offsets[middle - 1] + ordered_offsets[middle]
) / 2.0
+ applied_shift = layer_shift * vertical_anchor_strength
for vertex, stacked in stacked_y.items():
- vertex_y[vertex] = stacked + layer_shift
+ vertex_y[vertex] = stacked + applied_shift
else:
raise ValueError(f"Unknown Phase 5 vertical mode: {vertical_mode}")
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 0af8002..d1d9d57 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -329,6 +329,28 @@ def test_layer_anchor_vertical_mode_preserves_layer_spacing_near_baseline_band()
assert (top_mid.y + bottom_mid.y) / 2.0 == 362.5
+def test_layer_anchor_strength_interpolates_toward_phase4_band():
+ workflow = _parallel_mixed_workflow()
+ workflow.nodes[11].y = 600
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ assert _place_mixed_global_flow(
+ workflow,
+ settings,
+ workflow_width=7_000,
+ order_mode="stable",
+ horizontal_mode="anchored",
+ vertical_gap=40,
+ vertical_mode="layer_anchor",
+ vertical_anchor_strength=0.5,
+ )
+
+ top_mid = workflow.nodes[10]
+ bottom_mid = workflow.nodes[11]
+ assert bottom_mid.y - top_mid.y == 140
+ assert (top_mid.y + bottom_mid.y) / 2.0 == 266.25
+
+
def test_phase5_skips_pinned_group_geometry():
workflow = _mixed_chain_workflow()
extra = Node(id=12, type="Extra", x=6_500, y=450, size=[200, 100])
diff --git a/tools/report_phase5_diagnostics.py b/tools/report_phase5_diagnostics.py
index 6ffdc32..d4a53fb 100644
--- a/tools/report_phase5_diagnostics.py
+++ b/tools/report_phase5_diagnostics.py
@@ -16,6 +16,7 @@
_best_diagnostic_candidate,
_build_mixed_graph,
_phase5_baseline_y_candidate_variants,
+ _phase5_compressed_yband_candidate_variants,
_phase5_layer_anchor_candidate_variants,
_center_flow_metrics,
_mixed_rejection_reason,
@@ -240,6 +241,13 @@ def _export_variant(
baseline_score=baseline_score,
)
)
+ candidates.extend(
+ _phase5_compressed_yband_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
+ )
candidate = next(
(item for item in candidates if item.name == variant_name),
None,
@@ -292,16 +300,23 @@ def _print_variant_diagnostics(baseline: Workflow) -> None:
settings=LayoutSettings(),
baseline_score=baseline_score,
)
+ compressed_yband_candidates = _phase5_compressed_yband_candidate_variants(
+ baseline,
+ settings=LayoutSettings(),
+ baseline_score=baseline_score,
+ )
candidates = [
*standard_candidates,
*baseline_y_candidates,
*layer_anchor_candidates,
+ *compressed_yband_candidates,
]
print(
" variants: "
f"standard={len(standard_candidates)} "
f"experimental-baseline-y={len(baseline_y_candidates)} "
- f"experimental-yband={len(layer_anchor_candidates)}"
+ f"experimental-yband={len(layer_anchor_candidates)} "
+ f"experimental-yband-compressed={len(compressed_yband_candidates)}"
)
for candidate in sorted(candidates, key=lambda item: item.name):
reason = _mixed_rejection_reason(
From c872ed4800826d63efcf6feb78553409939cc659 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 20:27:11 +0200
Subject: [PATCH 086/105] Test minimum-gap Y-band compression
---
docs/LAYOUT_ENGINE_V2.md | 11 ++++++
flowforge/layout_engine_v2_phase5.py | 57 +++++++++++++++------------
tests/test_layout_engine_v2_phase5.py | 25 +++++++++++-
3 files changed, 66 insertions(+), 27 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 0be7db4..8efe581 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -808,6 +808,17 @@ The full Y-band review on Flux 9B produced the best visual result so far at `wei
To measure that trade-off instead of inventing another Y heuristic, diagnostics now include `yband25`, `yband50`, and `yband75` at the compact 40 px internal gap. They apply 25%, 50%, or 75% of the layer-level Phase 4 Y shift. A strength of 0% is the existing stacked Anchored-X placement; 100% is the full Y-band placement. These compressed Y-band candidates remain diagnostic-only.
+The first 25/50/75% sweep showed that `weighted-anchoredx-yband25-gap-40`
+is closest to the strict gate: 4,850 x 3,414, port crossings 81, center
+crossings 91, and weighted mixed-edge growth +2.4%. Its only rejection is area,
+which is just about +2.7% above the Phase 4 baseline. The 50% and 75% variants
+increase area by about +5.9% and +9.1% respectively.
+
+Before considering any area-gate relaxation, diagnostics now also evaluate the
+same 25/50/75% anchor strengths at the existing hard minimum Phase 5 vertical
+gap of 12 px. This tests whether the remaining area excess can be recovered by
+tighter within-layer spacing while preserving the Y-band locality signal.
+
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index 47c258e..dedfee5 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -425,36 +425,41 @@ def _phase5_compressed_yband_candidate_variants(
settings: LayoutSettings,
baseline_score: EngineV2Score,
) -> list[_Phase5Candidate]:
- """Build diagnostic partial Y-band anchor candidates at compact spacing."""
+ """Build diagnostic partial Y-band anchors at compact and minimum spacing."""
candidates: list[_Phase5Candidate] = []
compact_gap = _phase5_vertical_gaps(settings)[-1]
+ vertical_gaps = [compact_gap]
+ if not math.isclose(compact_gap, MIXED_GLOBAL_MIN_VERTICAL_GAP):
+ vertical_gaps.append(MIXED_GLOBAL_MIN_VERTICAL_GAP)
+
for order_mode in ("weighted", "stable"):
- for anchor_strength in (0.25, 0.5, 0.75):
- candidate = deepcopy(baseline)
- if not _place_mixed_global_flow(
- candidate,
- settings,
- baseline_score.width,
- order_mode=order_mode,
- horizontal_mode="anchored",
- vertical_gap=compact_gap,
- vertical_mode="layer_anchor",
- vertical_anchor_strength=anchor_strength,
- ):
- continue
- _finalize_refinement(candidate, settings)
- percent = int(round(anchor_strength * 100))
- candidates.append(
- _Phase5Candidate(
- name=(
- f"{order_mode}-anchoredx-yband{percent}"
- f"-gap-{compact_gap:g}"
- ),
- workflow=candidate,
- score=_score_engine_v2(candidate),
- center_metrics=_center_flow_metrics(candidate),
+ for vertical_gap in vertical_gaps:
+ for anchor_strength in (0.25, 0.5, 0.75):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode="anchored",
+ vertical_gap=vertical_gap,
+ vertical_mode="layer_anchor",
+ vertical_anchor_strength=anchor_strength,
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ percent = int(round(anchor_strength * 100))
+ candidates.append(
+ _Phase5Candidate(
+ name=(
+ f"{order_mode}-anchoredx-yband{percent}"
+ f"-gap-{vertical_gap:g}"
+ ),
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
)
- )
return candidates
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index d1d9d57..57f81eb 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -1,7 +1,11 @@
"""Regression tests for Phase 5 mixed global-flow refinement."""
from flowforge.layout import LayoutSettings
-from flowforge.layout_engine_v2 import EngineV2Score, _assign_scc_longest_path_layers
+from flowforge.layout_engine_v2 import (
+ EngineV2Score,
+ _assign_scc_longest_path_layers,
+ _score_engine_v2,
+)
from flowforge.layout_engine_v2_phase5 import (
_CenterFlowMetrics,
_build_mixed_graph,
@@ -9,6 +13,7 @@
_has_phase5_unmodeled_group_geometry,
_mixed_candidate_is_better,
_mixed_rejection_reason,
+ _phase5_compressed_yband_candidate_variants,
_phase5_vertical_gaps,
_place_mixed_global_flow,
)
@@ -482,6 +487,24 @@ def test_center_flow_metrics_match_center_segment_geometry():
+def test_compressed_yband_variants_include_minimum_gap():
+ workflow = _parallel_mixed_workflow()
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ candidates = _phase5_compressed_yband_candidate_variants(
+ workflow,
+ settings,
+ _score_engine_v2(workflow),
+ )
+ names = {candidate.name for candidate in candidates}
+
+ assert len(candidates) == 12
+ assert "weighted-anchoredx-yband25-gap-40" in names
+ assert "weighted-anchoredx-yband25-gap-12" in names
+ assert "stable-anchoredx-yband75-gap-40" in names
+ assert "stable-anchoredx-yband75-gap-12" in names
+
+
def test_phase5_vertical_gap_profiles_use_existing_spacing_controls():
settings = LayoutSettings(
node_x_distance=100,
From 77ffdc8f119f0725d61c87866e52edb740406a0f Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 20:30:12 +0200
Subject: [PATCH 087/105] Refine minimum-gap Y-band sweep
---
docs/LAYOUT_ENGINE_V2.md | 16 ++++++++++++----
flowforge/layout_engine_v2_phase5.py | 5 ++++-
tests/test_layout_engine_v2_phase5.py | 6 +++++-
3 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 8efe581..972feb5 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -814,10 +814,18 @@ crossings 91, and weighted mixed-edge growth +2.4%. Its only rejection is area,
which is just about +2.7% above the Phase 4 baseline. The 50% and 75% variants
increase area by about +5.9% and +9.1% respectively.
-Before considering any area-gate relaxation, diagnostics now also evaluate the
-same 25/50/75% anchor strengths at the existing hard minimum Phase 5 vertical
-gap of 12 px. This tests whether the remaining area excess can be recovered by
-tighter within-layer spacing while preserving the Y-band locality signal.
+At the existing hard minimum Phase 5 vertical gap of 12 px,
+`weighted-anchoredx-yband25-gap-12` comes even closer: 4,850 x 3,351,
+port crossings 81, center crossings 91, and weighted mixed-edge growth +1.1%.
+Its only rejection is area, now just about +0.84% above the Phase 4 baseline.
+At the same width, no-area-growth would require a height of roughly 3,323 px,
+so the candidate misses the gate by only about 28 px.
+
+Before considering any area-gate relaxation or spacing below the established
+12 px floor, diagnostics now refine the minimum-gap sweep with 10%, 15%, and
+20% Y-band anchor strengths in addition to 25/50/75%. These finer points target
+the narrow interval where the visual locality signal may survive while the
+strict area gate still passes.
The existing safety gates should remain unchanged until a candidate produces
both measurable and visually acceptable improvement.
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index dedfee5..a87634b 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -434,7 +434,10 @@ def _phase5_compressed_yband_candidate_variants(
for order_mode in ("weighted", "stable"):
for vertical_gap in vertical_gaps:
- for anchor_strength in (0.25, 0.5, 0.75):
+ anchor_strengths = [0.25, 0.5, 0.75]
+ if math.isclose(vertical_gap, MIXED_GLOBAL_MIN_VERTICAL_GAP):
+ anchor_strengths = [0.10, 0.15, 0.20, *anchor_strengths]
+ for anchor_strength in anchor_strengths:
candidate = deepcopy(baseline)
if not _place_mixed_global_flow(
candidate,
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 57f81eb..9857a19 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -498,11 +498,15 @@ def test_compressed_yband_variants_include_minimum_gap():
)
names = {candidate.name for candidate in candidates}
- assert len(candidates) == 12
+ assert len(candidates) == 18
assert "weighted-anchoredx-yband25-gap-40" in names
+ assert "weighted-anchoredx-yband10-gap-12" in names
+ assert "weighted-anchoredx-yband15-gap-12" in names
+ assert "weighted-anchoredx-yband20-gap-12" in names
assert "weighted-anchoredx-yband25-gap-12" in names
assert "stable-anchoredx-yband75-gap-40" in names
assert "stable-anchoredx-yband75-gap-12" in names
+ assert "weighted-anchoredx-yband10-gap-40" not in names
def test_phase5_vertical_gap_profiles_use_existing_spacing_controls():
From fa1332bab41e7986cbb87788c72240b57e44f2e4 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 20:47:55 +0200
Subject: [PATCH 088/105] Promote validated Phase 5 Y-band candidates
---
docs/LAYOUT_ENGINE_V2.md | 36 +++++++++---
flowforge/layout_engine_v2_phase5.py | 81 +++++++++++++++++++++++++--
tests/test_layout_engine_v2_phase5.py | 70 +++++++++++++++++++++--
3 files changed, 170 insertions(+), 17 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 972feb5..9aa8551 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -821,14 +821,32 @@ Its only rejection is area, now just about +0.84% above the Phase 4 baseline.
At the same width, no-area-growth would require a height of roughly 3,323 px,
so the candidate misses the gate by only about 28 px.
-Before considering any area-gate relaxation or spacing below the established
-12 px floor, diagnostics now refine the minimum-gap sweep with 10%, 15%, and
-20% Y-band anchor strengths in addition to 25/50/75%. These finer points target
-the narrow interval where the visual locality signal may survive while the
-strict area gate still passes.
-
-The existing safety gates should remain unchanged until a candidate produces
-both measurable and visually acceptable improvement.
+The minimum-gap refinement found two fully gate-compliant candidates:
+`weighted-anchoredx-yband10-gap-12` at 4,850 x 3,292 and
+`weighted-anchoredx-yband15-gap-12` at 4,850 x 3,311. The 10% variant reaches
+81 port crossings, 92 center crossings, and about +0.7% weighted mixed-edge
+length. The 15% variant reaches 81 port crossings, 90 center crossings, and
+about +0.8% weighted mixed-edge length. Both preserve the strict overlap,
+crossing/RTL, width-reduction, and no-area-growth gates.
+
+Visual review found both minimum-gap variants coherent and compact. The 15%
+variant was preferred by a small margin: its extra 19 px of height is not
+visually significant, while the center-crossing metric improves from 92 to 90.
+The previously selected Compact-X candidate remains narrower by no additional
+amount, but its top-level mixed-edge length grew by about +10.3% and it failed
+visual review because weakly constrained branches formed detached-looking
+islands.
+
+The validated 10% and 15% minimum-gap Y-band variants are therefore promoted
+into normal Phase 5 candidate generation. Unvalidated Y-band strengths remain
+diagnostic only. The hard acceptance gate is unchanged.
+
+When multiple candidates pass that gate, selection now ranks the
+corpus-compatible center-crossing metric first, then weighted mixed-graph edge
+length as a top-level cohesion tie-breaker, followed by port-aware crossings,
+RTL metrics, and size. This prevents a visually fragmented candidate from
+winning solely because it removes a few more port crossings while preserving
+the corpus safety floor.
## Deferred TODOs
@@ -865,6 +883,8 @@ Regression coverage includes:
- shared Group/Ungrouped dependency layering;
- preservation of group-internal geometry during mixed placement;
- strict Phase 5 width/area and crossing/RTL acceptance gates;
+- production minimum-gap 10%/15% Y-band candidate generation;
+- center-first accepted-candidate ranking with mixed-graph cohesion tie-break;
- Phase 5 pin-constraint blocking.
## Documentation Status
diff --git a/flowforge/layout_engine_v2_phase5.py b/flowforge/layout_engine_v2_phase5.py
index a87634b..a60c07b 100644
--- a/flowforge/layout_engine_v2_phase5.py
+++ b/flowforge/layout_engine_v2_phase5.py
@@ -351,6 +351,51 @@ def _phase5_candidate_variants(
center_metrics=_center_flow_metrics(candidate),
)
)
+ candidates.extend(
+ _phase5_production_yband_candidate_variants(
+ baseline,
+ settings,
+ baseline_score,
+ )
+ )
+ return candidates
+
+
+def _phase5_production_yband_candidate_variants(
+ baseline: Workflow,
+ settings: LayoutSettings,
+ baseline_score: EngineV2Score,
+) -> list[_Phase5Candidate]:
+ """Build the visually validated minimum-gap partial Y-band candidates."""
+ candidates: list[_Phase5Candidate] = []
+ vertical_gap = MIXED_GLOBAL_MIN_VERTICAL_GAP
+ for order_mode in ("weighted", "stable"):
+ for anchor_strength in (0.10, 0.15):
+ candidate = deepcopy(baseline)
+ if not _place_mixed_global_flow(
+ candidate,
+ settings,
+ baseline_score.width,
+ order_mode=order_mode,
+ horizontal_mode="anchored",
+ vertical_gap=vertical_gap,
+ vertical_mode="layer_anchor",
+ vertical_anchor_strength=anchor_strength,
+ ):
+ continue
+ _finalize_refinement(candidate, settings)
+ percent = int(round(anchor_strength * 100))
+ candidates.append(
+ _Phase5Candidate(
+ name=(
+ f"{order_mode}-anchoredx-yband{percent}"
+ f"-gap-{vertical_gap:g}"
+ ),
+ workflow=candidate,
+ score=_score_engine_v2(candidate),
+ center_metrics=_center_flow_metrics(candidate),
+ )
+ )
return candidates
@@ -436,7 +481,7 @@ def _phase5_compressed_yband_candidate_variants(
for vertical_gap in vertical_gaps:
anchor_strengths = [0.25, 0.5, 0.75]
if math.isclose(vertical_gap, MIXED_GLOBAL_MIN_VERTICAL_GAP):
- anchor_strengths = [0.10, 0.15, 0.20, *anchor_strengths]
+ anchor_strengths = [0.20, *anchor_strengths]
for anchor_strength in anchor_strengths:
candidate = deepcopy(baseline)
if not _place_mixed_global_flow(
@@ -503,15 +548,17 @@ def _select_accepted_phase5_candidate(
def _phase5_candidate_quality_key(
candidate: _Phase5Candidate,
-) -> tuple[int, int, int, int, int, float, float, float, str]:
+) -> tuple[int, int, float, int, int, int, float, float, float, str]:
+ """Rank already-safe candidates by corpus geometry and top-level cohesion."""
score = candidate.score
center = candidate.center_metrics
return (
score.movable_overlaps,
- score.crossings,
center.crossings,
- score.right_to_left_links,
+ _weighted_mixed_edge_length(candidate.workflow),
+ score.crossings,
center.right_to_left_links,
+ score.right_to_left_links,
score.width * score.height,
score.width,
score.height,
@@ -1143,6 +1190,32 @@ def _anchored_compact_mixed_vertex_x_positions(
return positions
+def _weighted_mixed_edge_length(workflow: Workflow) -> float:
+ """Return weighted Manhattan length between real mixed-graph vertex centers."""
+ _specs, spec_by_vertex, _adjacency, edge_weights = _build_mixed_graph(workflow)
+ if not edge_weights:
+ return 0.0
+
+ groups_by_id = {group.id: group for group in workflow.groups}
+ nodes_by_id = {node.id: node for node in workflow.nodes.values()}
+ centers: dict[int, tuple[float, float]] = {}
+ for vertex, spec in spec_by_vertex.items():
+ x, y = _mixed_vertex_xy(spec, groups_by_id, nodes_by_id)
+ width, height = _mixed_vertex_size(spec, groups_by_id, nodes_by_id)
+ centers[vertex] = (x + width / 2.0, y + height / 2.0)
+
+ total = 0.0
+ for (source, target), weight in edge_weights.items():
+ start = centers.get(source)
+ end = centers.get(target)
+ if start is None or end is None:
+ continue
+ total += (
+ abs(end[0] - start[0]) + abs(end[1] - start[1])
+ ) * max(1, weight)
+ return total
+
+
def _mixed_vertex_size(
spec: MixedSpec,
groups_by_id: dict[int, Group],
diff --git a/tests/test_layout_engine_v2_phase5.py b/tests/test_layout_engine_v2_phase5.py
index 9857a19..b442e61 100644
--- a/tests/test_layout_engine_v2_phase5.py
+++ b/tests/test_layout_engine_v2_phase5.py
@@ -8,14 +8,18 @@
)
from flowforge.layout_engine_v2_phase5 import (
_CenterFlowMetrics,
+ _Phase5Candidate,
_build_mixed_graph,
_center_flow_metrics,
_has_phase5_unmodeled_group_geometry,
_mixed_candidate_is_better,
_mixed_rejection_reason,
+ _phase5_candidate_variants,
_phase5_compressed_yband_candidate_variants,
_phase5_vertical_gaps,
_place_mixed_global_flow,
+ _select_accepted_phase5_candidate,
+ _weighted_mixed_edge_length,
)
from flowforge.model import Group, Link, Node, Workflow
@@ -487,7 +491,7 @@ def test_center_flow_metrics_match_center_segment_geometry():
-def test_compressed_yband_variants_include_minimum_gap():
+def test_compressed_yband_variants_keep_unvalidated_strengths_diagnostic():
workflow = _parallel_mixed_workflow()
settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
@@ -498,15 +502,71 @@ def test_compressed_yband_variants_include_minimum_gap():
)
names = {candidate.name for candidate in candidates}
- assert len(candidates) == 18
+ assert len(candidates) == 14
assert "weighted-anchoredx-yband25-gap-40" in names
- assert "weighted-anchoredx-yband10-gap-12" in names
- assert "weighted-anchoredx-yband15-gap-12" in names
assert "weighted-anchoredx-yband20-gap-12" in names
assert "weighted-anchoredx-yband25-gap-12" in names
assert "stable-anchoredx-yband75-gap-40" in names
assert "stable-anchoredx-yband75-gap-12" in names
- assert "weighted-anchoredx-yband10-gap-40" not in names
+ assert "weighted-anchoredx-yband10-gap-12" not in names
+ assert "weighted-anchoredx-yband15-gap-12" not in names
+
+
+def test_phase5_production_candidates_include_validated_minimum_gap_ybands():
+ workflow = _parallel_mixed_workflow()
+ settings = LayoutSettings(node_x_distance=100, node_y_distance=80)
+
+ candidates = _phase5_candidate_variants(
+ workflow,
+ settings,
+ _score_engine_v2(workflow),
+ )
+ names = {candidate.name for candidate in candidates}
+
+ assert "weighted-anchoredx-yband10-gap-12" in names
+ assert "weighted-anchoredx-yband15-gap-12" in names
+ assert "stable-anchoredx-yband10-gap-12" in names
+ assert "stable-anchoredx-yband15-gap-12" in names
+
+
+def test_phase5_selection_prefers_center_geometry_and_mixed_cohesion():
+ baseline = _score(crossings=82, rtl=12, width=5_810, height=2_774)
+ baseline_center = _CenterFlowMetrics(crossings=93, right_to_left_links=6)
+
+ compact_workflow = _parallel_mixed_workflow()
+ compact_workflow.nodes[11].x += 1_000
+ compact_workflow.nodes[12].x += 1_000
+ yband_workflow = _parallel_mixed_workflow()
+
+ compact = _Phase5Candidate(
+ name="weighted-compactx-gap-40",
+ workflow=compact_workflow,
+ score=_score(crossings=78, rtl=10, width=4_850, height=3_308),
+ center_metrics=_CenterFlowMetrics(
+ crossings=92,
+ right_to_left_links=4,
+ ),
+ )
+ yband = _Phase5Candidate(
+ name="weighted-anchoredx-yband15-gap-12",
+ workflow=yband_workflow,
+ score=_score(crossings=81, rtl=12, width=4_850, height=3_311),
+ center_metrics=_CenterFlowMetrics(
+ crossings=90,
+ right_to_left_links=6,
+ ),
+ )
+
+ assert _weighted_mixed_edge_length(yband.workflow) < _weighted_mixed_edge_length(
+ compact.workflow
+ )
+ selected = _select_accepted_phase5_candidate(
+ [compact, yband],
+ baseline,
+ baseline_center,
+ )
+
+ assert selected is yband
def test_phase5_vertical_gap_profiles_use_existing_spacing_controls():
From d1d13f1d052ab396624b5a8944dd9ae77afe675b Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 21:00:20 +0200
Subject: [PATCH 089/105] Stop automatic module log-file creation
---
docs/LAYOUT_ENGINE_V2.md | 10 +++++
flowforge/logger.py | 91 ++++++++++++++++++++++++++--------------
tests/test_logger.py | 71 +++++++++++++++++++++++++++++++
3 files changed, 140 insertions(+), 32 deletions(-)
create mode 100644 tests/test_logger.py
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 9aa8551..7b14444 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -608,6 +608,16 @@ uv run python tools/report_phase5_diagnostics.py example-workflows --optimize `
--output "phase5-review/Flux2-9B-anchoredxy.json"
```
+### Diagnostic logging
+
+FlowForge module loggers write to the console by default and do not create
+per-module files during ordinary CLI, API, or diagnostic runs. File logging is
+opt-in through `setup_logger(..., log_dir=...)`; explicit file handlers are
+delayed until the first record is emitted, so unused handlers do not leave empty
+log files behind. Long corpus/report captures should continue to be redirected
+explicitly into the ignored `logs/` directory with PowerShell redirection or
+`Tee-Object`.
+
## Current Scope Boundary
The active runtime now reaches Phase 5. Phase 4 remains the complete fallback
diff --git a/flowforge/logger.py b/flowforge/logger.py
index 47a7317..2cd7e7c 100644
--- a/flowforge/logger.py
+++ b/flowforge/logger.py
@@ -1,39 +1,66 @@
-import logging
+"""Project logging helpers."""
+
+from __future__ import annotations
+
from datetime import datetime
+import logging
from pathlib import Path
-def setup_logger(name: str, log_dir: str = "logs") -> logging.Logger:
- """
- Set up a logger that writes to a timestamped file in the log directory.
- Filename format: YYYY-MM-DD_HH-mm-ss_Fehlerhaftes {name}.log
+
+_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+
+
+def setup_logger(
+ name: str,
+ log_dir: str | Path | None = None,
+) -> logging.Logger:
+ """Return a FlowForge logger.
+
+ Console logging is enabled by default. File logging is opt-in via
+ ``log_dir`` so ordinary CLI, API, and diagnostic runs do not create one
+ timestamped file per imported module.
+
+ Explicit file handlers use ``delay=True``: the path is not created until
+ the logger actually emits a file-level record.
"""
- # Create log directory if it doesn't exist
- log_path = Path(log_dir)
- log_path.mkdir(exist_ok=True)
-
- # Generate timestamp
- timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- # Ensure name is safe for filename
- safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).rstrip()
- filename = f"{timestamp}_Fehlerhaftes {safe_name}.log"
- log_file = log_path / filename
-
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
-
- # Avoid adding multiple handlers
- if not logger.handlers:
- # File handler
- fh = logging.FileHandler(log_file, encoding='utf-8')
- fh.setLevel(logging.DEBUG)
- # Console handler
- ch = logging.StreamHandler()
- ch.setLevel(logging.INFO)
- # Formatter
- formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
- fh.setFormatter(formatter)
- ch.setFormatter(formatter)
- logger.addHandler(fh)
- logger.addHandler(ch)
-
+ logger.propagate = False
+
+ formatter = logging.Formatter(_FORMAT)
+
+ if not any(
+ isinstance(handler, logging.StreamHandler)
+ and not isinstance(handler, logging.FileHandler)
+ for handler in logger.handlers
+ ):
+ console = logging.StreamHandler()
+ console.setLevel(logging.INFO)
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ if log_dir is not None:
+ path = Path(log_dir)
+ path.mkdir(parents=True, exist_ok=True)
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ safe_name = "".join(
+ character
+ if character.isalnum() or character in ("_", "-")
+ else "_"
+ for character in name
+ ).strip("_")
+ log_file = path / f"{timestamp}_{safe_name or 'flowforge'}.log"
+ if not any(
+ isinstance(handler, logging.FileHandler)
+ for handler in logger.handlers
+ ):
+ file_handler = logging.FileHandler(
+ log_file,
+ encoding="utf-8",
+ delay=True,
+ )
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+
return logger
diff --git a/tests/test_logger.py b/tests/test_logger.py
new file mode 100644
index 0000000..e0a9be9
--- /dev/null
+++ b/tests/test_logger.py
@@ -0,0 +1,71 @@
+"""Tests for FlowForge logging configuration."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from flowforge.logger import setup_logger
+
+
+def _close_logger(name: str) -> None:
+ logger = logging.getLogger(name)
+ for handler in list(logger.handlers):
+ handler.close()
+ logger.removeHandler(handler)
+
+
+def test_default_logger_does_not_create_log_files(tmp_path: Path, monkeypatch) -> None:
+ name = "flowforge.test.default_no_file"
+ _close_logger(name)
+ monkeypatch.chdir(tmp_path)
+
+ logger = setup_logger(name)
+ logger.info("console only")
+
+ assert not (tmp_path / "logs").exists()
+ assert not any(
+ isinstance(handler, logging.FileHandler)
+ for handler in logger.handlers
+ )
+ _close_logger(name)
+
+
+def test_explicit_file_logging_is_delayed_until_first_record(tmp_path: Path) -> None:
+ name = "flowforge.test.explicit_file"
+ _close_logger(name)
+ log_dir = tmp_path / "logs"
+
+ logger = setup_logger(name, log_dir=log_dir)
+
+ assert log_dir.is_dir()
+ assert list(log_dir.iterdir()) == []
+
+ logger.debug("written on demand")
+ files = list(log_dir.iterdir())
+
+ assert len(files) == 1
+ assert "Fehlerhaftes" not in files[0].name
+ assert "flowforge_test_explicit_file" in files[0].name
+ assert "written on demand" in files[0].read_text(encoding="utf-8")
+ _close_logger(name)
+
+
+def test_repeated_setup_does_not_duplicate_handlers(tmp_path: Path) -> None:
+ name = "flowforge.test.no_duplicates"
+ _close_logger(name)
+
+ first = setup_logger(name, log_dir=tmp_path / "logs")
+ second = setup_logger(name, log_dir=tmp_path / "logs")
+
+ assert first is second
+ assert sum(
+ isinstance(handler, logging.FileHandler)
+ for handler in first.handlers
+ ) == 1
+ assert sum(
+ isinstance(handler, logging.StreamHandler)
+ and not isinstance(handler, logging.FileHandler)
+ for handler in first.handlers
+ ) == 1
+ _close_logger(name)
From 2f9d0b6899cb8cd69054a062047691940257671a Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 21:01:30 +0200
Subject: [PATCH 090/105] Document production Phase 5 corpus gate
---
docs/LAYOUT_ENGINE_V2.md | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index 7b14444..c9a30a3 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -858,6 +858,41 @@ RTL metrics, and size. This prevents a visually fragmented candidate from
winning solely because it removes a few more port crossings while preserving
the corpus safety floor.
+
+### Production Phase 5 corpus gate
+
+After promoting the visually validated minimum-gap Y-band candidates and the
+center-first/cohesion-aware accepted-candidate ranking, the production corpus
+gate passes:
+
+- discovered workflows: 81;
+- checked UI workflows: 81;
+- failures: 0;
+- center crossings: 5,723 -> 4,124;
+- center RTL links: 568 -> 339.
+
+The Phase 4 structural reference remains 4,127 center crossings / 339 center RTL
+links. Production Phase 5 therefore improves the corpus by 3 crossings while
+holding RTL exactly at the reference floor.
+
+The production Phase 5 diagnostic attempts 14 workflows and accepts exactly one:
+`Flux.2 klein 9B Reference Identity 1MP + 2x`, using
+`weighted-anchoredx-yband15-gap-12`. That candidate reduces size from
+5,810 x 2,774 to 4,850 x 3,311, port crossings from 82 to 81, and center
+crossings from 93 to 90 while keeping port/center RTL at 12 / 6. Visual review
+confirmed the result is coherent and compact enough to accept.
+
+The remaining 13 attempted workflows are still rejected by the unchanged safety
+gate: 6 for insufficient final width reduction, 5 for crossing regression, 1
+for area regression, and 1 for center-crossing regression. The remaining 67
+workflows are skipped by established scope constraints (minimum width, unmodeled
+empty-group geometry, pinned geometry, or too few eligible ungrouped nodes).
+
+This is the first Phase 5 corpus state that is both structurally non-regressing
+and backed by a visually accepted production candidate. Phase 5 can therefore
+be treated as complete for the current modeled scope; future work should expand
+scope or improve currently rejected workflows without weakening these gates.
+
## Deferred TODOs
- Add user-facing Undo/Redo for destructive workflow transformations such as
From b343ff0abc2f4fe91405e9638d7796e83c40dc07 Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 21:06:58 +0200
Subject: [PATCH 091/105] Clarify current and historical Phase 5 status
---
docs/LAYOUT_ENGINE_V2.md | 36 ++++++++++++++++++++++++------------
1 file changed, 24 insertions(+), 12 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index c9a30a3..cb193b3 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -625,7 +625,20 @@ baseline, and Phase 3 remains underneath it. Bridge, external-source, control,
text-preview, virtual-hub, decorative, and pin-specific contracts are still
authoritative after mixed placement.
-Phase 5 is still experimental. The first full 81-workflow corpus run measured
+For the currently modeled scope, Phase 5 is complete and production-safe on the
+81-workflow corpus: 0 failures, 4,124 center crossings, and 339 center RTL links.
+It accepts exactly one workflow through the unchanged safety gate, using the
+visually validated `weighted-anchoredx-yband15-gap-12` realization. The branch
+and v2 rollout remain experimental overall, so release-facing documentation is
+still deferred.
+
+### Historical Phase 5 development record
+
+The following results document the iterations that led to the current
+production-safe Phase 5 behavior. They are retained as engineering history and
+should not be read as the current runtime state.
+
+The first full 81-workflow corpus run measured
4,138 center crossings / 331 center RTL links, which missed the Phase 4 crossing
reference of 4,127 / 339 by 11 crossings while improving RTL by 8. That run
revealed the port-aware versus center-metric acceptance mismatch described
@@ -685,7 +698,7 @@ cp1252 when workflow or group names contain Unicode symbols or emoji. Long
local report captures should be written below `logs/`, which is already
gitignored.
-### Final Phase 5 safety validation
+### Historical safe-no-op checkpoint
After the empty-group scope guard, the accepted-only corpus diagnostic reported:
@@ -697,16 +710,15 @@ After the empty-group scope guard, the accepted-only corpus diagnostic reported:
The final full 81-workflow Optimize + Layout corpus then measured exactly
`4,127` center crossings and `339` center RTL links, with 0 failures. This is
-identical to the Phase 4 reference. Phase 5 is therefore currently a safe
-no-op on the corpus: it introduces no structural regression, but it also has no
-accepted corpus improvement after the visually invalid candidate was excluded.
-
-Further Phase 5 work should therefore focus on expanding what the mixed graph
-can model—most notably authored empty group rectangles—or on improving physical
-realization for the remaining attempted workflows. The diagnostic reporter
-supports `--attempted-only` and prints aggregate attempted-rejection and skipped
-reason counts so the next architecture decision can be based on the dominant
-failure mode rather than another global heuristic.
+identical to the Phase 4 reference. At that checkpoint, Phase 5 was therefore a safe no-op on the corpus: it
+introduced no structural regression, but it also had no accepted corpus
+improvement after the visually invalid candidate was excluded.
+
+At that point, further Phase 5 work focused on improving physical realization
+for the remaining attempted workflows rather than weakening the safety gate.
+The diagnostic reporter's `--attempted-only` summary was used to choose the next
+architecture step from the dominant rejection modes rather than from another
+global heuristic.
The first attempted-only rerun after the empty-group guard produced 14 physical
candidate workflows:
From 2933743c749cc33e966d80bbae57f9388a77b1cf Mon Sep 17 00:00:00 2001
From: Delcado <85063283+Delcado19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 21:11:41 +0200
Subject: [PATCH 092/105] Add workflow transformation undo redo
---
docs/LAYOUT_ENGINE_V2.md | 26 +++--
frontend/README.md | 4 +
frontend/src/App.vue | 131 ++++++++++++++++++++----
frontend/src/stores/useWorkflowStore.ts | 78 +++++++++++++-
4 files changed, 208 insertions(+), 31 deletions(-)
diff --git a/docs/LAYOUT_ENGINE_V2.md b/docs/LAYOUT_ENGINE_V2.md
index cb193b3..017f72d 100644
--- a/docs/LAYOUT_ENGINE_V2.md
+++ b/docs/LAYOUT_ENGINE_V2.md
@@ -905,13 +905,25 @@ and backed by a visually accepted production candidate. Phase 5 can therefore
be treated as complete for the current modeled scope; future work should expand
scope or improve currently rejected workflows without weakening these gates.
-## Deferred TODOs
-
-- Add user-facing Undo/Redo for destructive workflow transformations such as
- Layout and Optimize. The implementation should restore the complete workflow
- state from before the action rather than trying to reverse individual geometry
- mutations. This is intentionally deferred until the v2 layout engine and its
- frontend integration are stable.
+### Frontend transformation history
+
+With Phase 5 stable for the current modeled scope, the previously deferred
+frontend Undo/Redo work is now implemented for destructive backend
+transformations. Successful `Layout Only`, live spacing-layout, and
+`Optimize + Layout` runs record complete ComfyUI workflow JSON snapshots
+instead of attempting to reverse individual geometry mutations.
+
+`Optimize + Layout` is one atomic history step even though it performs two
+backend requests. Undo/Redo invalidates pending/debounced layout work so a late
+response cannot overwrite a restored workflow. Loading a new workflow clears
+history, and a new transformation after Undo clears the Redo stack.
+
+The history is intentionally limited to the 10 most recent full snapshots per
+direction to bound memory usage on large workflows. Manual canvas edits (node
+drag/resize, group edits, pins) are not separate history entries in this first
+implementation. Toolbar buttons and `Ctrl/Cmd+Z`,
+`Ctrl/Cmd+Shift+Z`, and `Ctrl/Cmd+Y` expose the transformation history;
+keyboard shortcuts do not intercept editable form controls.
## Validation
diff --git a/frontend/README.md b/frontend/README.md
index f190702..b8895e8 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -33,6 +33,7 @@ Python package builds can stage the built frontend into `flowforge/frontend_dist
- **Pin Controls** - Node and group title bars include pin buttons, and the canvas toolbar can clear all pinned nodes and groups
- **Toolbar Group Actions** - Button to clear all groups in the workflow; drag-based group creation starts from the canvas overlay controls
- **Before/After Layout Comparison** - After a layout run, the canvas can show the previous node positions as ghost outlines behind the current layout
+- **Transformation Undo/Redo** - Successful `Layout Only`, live spacing-layout, and `Optimize + Layout` runs store bounded full-workflow snapshots. Undo/Redo restores the exact ComfyUI JSON state before/after the transformation; loading a new file resets history and starting a new transformation after Undo clears Redo.
## Usage
@@ -40,6 +41,7 @@ Python package builds can stage the built frontend into `flowforge/frontend_dist
- **Optimize + Layout** - POST the full workflow JSON to `/optimize`, then `/layout`; this is the normal cleanup flow for high-fanout workflows
- **Layout Only** - POST the full workflow JSON to `/layout` without adding Set/Get nodes
- **Before/After** - Toggle the previous node positions after a layout run
+- **Undo / Redo** - Restore the previous/next successful transformation snapshot. Keyboard shortcuts: `Ctrl/Cmd+Z` for Undo, `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` for Redo.
- **Save** - Download the current full workflow JSON
Canvas navigation:
@@ -60,6 +62,8 @@ Canvas navigation:
- Image load/save nodes, notes, Markdown notes, and labels keep their saved ComfyUI rectangle instead of being compacted to the generic node minimum.
- Optimize + Layout does not insert Set/Get hubs into pinned node or group geometry.
- Set/Get hub nodes follow their physical endpoint during layout and do not expose pin controls in the canvas.
+- Transformation history is intentionally scoped to Layout/Optimize actions. Manual node/group edits are not individual history entries; Undo restores the complete workflow snapshot from before the most recent recorded transformation.
+- The history keeps at most 10 full workflow snapshots in each direction to bound memory use for large ComfyUI JSON files.
The frontend derives display connections from the workflow `links` array. It does not maintain a separate custom connection format.
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 1f1c99f..d6052a8 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,5 +1,5 @@
@@ -239,6 +314,20 @@ watch([nodeXDistance, nodeYDistance], () => {
+
+