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 @@