Skip to content

fix(diagram): route mermaid feedback edges around nodes, harden cycle handling - #72

Merged
bahdotsh merged 17 commits into
mainfrom
fix/feedback-edge-routing
Sep 10, 2026
Merged

bahdotsh merged 17 commits into
mainfrom
fix/feedback-edge-routing

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Mermaid feedback (back) edges were routed straight through sibling nodes, so a
cycle rendered as edges that were never declared. Self-loops came out as a
dangling stub. Cycle classification recursed and blew the stack on a long
chain. Along the way this turned up a handful of related faults in the
renderer, listed below.

Refs #56. Supersedes #57.

What changed

Feedback edge routing

A back edge leaves its source through the bottom border, runs along a gap row
(top-down) or gap column (left-right) into a gutter lane, and enters its
destination through the opposite border. Gap rows and columns never contain
nodes, so a route cannot pass through a sibling. Crossings with forward edges
render as junctions.

The gaps are sized for what they carry rather than picked from a fixed table.
Each gap holds one row or column per feedback endpoint in the layers it
separates, on top of what the forward edges and their labels need, so two
routes never merge into one ambiguous line and a label never lands on a route.
Lanes are assigned by span, shortest innermost, so a longer back edge wraps
around a shorter one instead of crossing it. Self-loops close. Extra rows
outside the diagram are added only when a route needs them.

Left-right drops and rises use the edges of a node's column, not of its box.
Boxes are centred in a column as wide as its widest node, so a narrow box's own
margin can sit inside a taller neighbour.

Arrowheads

An arrowhead is a single cell, and the only cell of an edge that says which box
is the destination. Anything drawn across it used to repaint it as a junction,
which is a legitimate character in a legitimate place, so nothing complained
and the edge quietly stopped pointing anywhere. 926 of the 2000 graphs the
property test generates lost at least one head; main loses them too.

A head now records the axis its own edge runs through it and keeps its glyph
against any crossing. On top of that, both renderers keep a spanning forward
edge off an arrowhead row (top-down) or column (left-right) to begin with,
since a bus that has to break over a row of heads belongs elsewhere and every
gap is budgeted for somewhere to put it.

Cycle detection

classify_feedback_edges uses an explicit stack. A linear chain of ~80k nodes
overflowed the main thread stack in release on macOS, and far fewer on
Windows' 1 MB main-thread stack. It classifies a 200k-node chain in the test
suite now.

edge_layer_feedback_indices is gone. After longest-path layering over the
pruned DAG every non-feedback edge is strictly descending, so its extra check
could never fire. Both renderers share one layout() helper and one feedback
set.

Left-right forward edges

The right border of an even-width node was computed one column too far right,
leaving a gap between the box and its outgoing edge (│ Loop │ ────▶). Edges
start flush with the border now, and every edge crossing a gap bends at the
same column so junctions still converge for mixed-width columns.

Each gap is sized for the labels it carries, and the bend moves right with the
label rather than sitting at the gap's midpoint — a centred bend costs two
columns of gap per column of label, and a diagram wider than the terminal used
to be word-wrapped into fragments.

Wrapping

wrap_lines treated a rendered diagram row as prose, so an over-wide row's
overflow landed on a row of its own, interleaved with the rows below it.
Diagram rows are clipped to the display width instead. This is not only
cosmetic: the viewer paints a row in full whatever its width, so an over-wide
row ran past the border and shifted every row after it.

Only diagrams are clipped. Code blocks are text and wrap as before, and a
diagram is still a code block for every other purpose, so clicking one still
copies the mermaid source. Clipping applies to the HTML export too, where the
page could have scrolled instead; truncating still beats shredding, and
--width controls it.

Clippy

manual_filter in viewer.rs fails cargo clippy -- -D warnings on current
stable and would block CI for any PR. Also a useless_borrows_in_formatting
in an image.rs test.

Examples

graph TD with A --> B; A --> C; B --> D; C --> D; D --> B. Before, the back
edge into B ran through C and read as C --> B:

    ┌─────┐    ┌─────┐
    │  B  │◀───│  C  │────┐
    └─────┘    └─────┘    │

After:

         ┌─────┐
         │  A  │
         └─────┘
            │
            │
      ┌─────┴────┐
      │ ┌────────┼───────┐
      ▼ ▼        ▼       │
   ┌─────┐    ┌─────┐    │
   │  B  │    │  C  │    │
   └─────┘    └─────┘    │
      │          │       │
      │          │       │
      └─────┬────┘       │
            ▼            │
         ┌─────┐         │
         │  D  │         │
         └─────┘         │
          │              │
          └──────────────┘

Two labelled back edges, graph LR:

  ┌───────┐      ┌───────┐      ┌──────┐      ┌──────┐
  │ Start │─────▶│ Check │─────▶│ Work │─────▶│ Done │
  └───────┘      └───────┘      └──────┘      └──────┘
   ▲              ▲                   └─┐           └─┐
┌──┘           ┌──┘                     │             │
│              └─────────again──────────┘             │
│                                                     │
└────────────────────────retry────────────────────────┘

Difference from main

Acyclic top-down renders keep their layout exactly: over 400 random acyclic
graphs, line count and every row width are identical to main. They differ on
146 rows, all of one of two kinds — a head main had painted away, and a label
that overruns the canvas now ending in an ellipsis instead of being cut
mid-word. Both are pinned by tests.

Left-right renders differ by the even-width arrow fix, by labels no longer
being painted through the destination box, and by gap widths.

Known and not addressed

  • In a dense diagram a label can land on a different forward edge's line.
    Instrumented, that is 653 of 2000 randomly generated graphs, in both
    directions. It predates this branch and main has it too.
  • The same class reaches arrowheads in left-right, where each column is centred
    in the canvas on its own: a forward edge running in to its own destination
    can meet a feedback head belonging to a box in another column. A finished
    top-down render asserts that nothing crosses a head at all; left-right cannot
    yet.
  • A forward edge spanning more than one layer and drawn straight down the
    centre line runs its vertical through the box in between. main does the
    same.
  • Top-down canvases are sized for their boxes, not their labels, so they
    truncate where left-right diagrams now grow.

Each of these wants a placement pass that draws every line before deciding
where the rest goes, which is its own change rather than a special case bolted
onto this one.

Tests

196 pass. Every render checks two invariants of its own, so rendering a shape
is itself an assertion: no feedback route may lie over a node cell, and no
label may cover a route. A seeded property test pushes 2000 assorted graphs
through both, varying node count, node width, edge count, direction and
labelling, and now also counts arrowheads against the heads each graph's shape
calls for — that count went from 926 failures to none. Cycle renders run on a
detached helper thread with a timeout, so a regression of the original hang
fails cargo test instead of hanging it, and a panicking render is told apart
from a hanging one.

Snapshots cover the acyclic examples, top-down and left-right cycles,
self-loops, sibling and stacked-node avoidance, several endpoints sharing a
layer, gaps carrying both an exit and an entry, truncated and inline labels,
every forward-label collision, and the two spanning-edge shapes above.

cargo fmt --check, cargo clippy -- -D warnings,
cargo clippy --all-targets -- -D warnings and cargo test are clean.

@bahdotsh
bahdotsh force-pushed the fix/feedback-edge-routing branch from 40c6e9b to a1a7bd6 Compare September 3, 2026 08:11
`manual_filter` in viewer.rs fails `cargo clippy -- -D warnings` on the
current stable toolchain and blocks the CI clippy job for every PR.
Also fixes `useless_borrows_in_formatting` in the image.rs tests, which
`cargo clippy --all-targets` reports.
…ling

Follow-up to #57 (refs #56).

Feedback (back) edges now leave their source through the bottom border,
travel along a gap row/column into a gutter lane, and enter the
destination through its top (TD) or bottom (LR) border. Gap rows and
columns never contain nodes, so a route can no longer pass through a
sibling and read as an edge that does not exist. Self-loops render as
closed loops. Lanes are assigned by span so longer edges wrap around
shorter ones, sources or targets that share a layer use distinct
rows/columns, and labels sit beside (TD) or inline on (LR) their own
lane instead of overwriting each other.

classify_feedback_edges uses an explicit stack instead of recursion,
which overflowed the main-thread stack on long chains. The redundant
edge_layer_feedback_indices pass is removed; both renderers share one
layout() helper and one feedback set.

LR forward edges now start flush with the right border of even-width
nodes and bend at a column-based midpoint so junctions converge.

Acyclic top-down output is byte-identical to before. Tests cover the
acyclic example, TD/LR cycles and self-loops, sibling avoidance, shared
layers, labels, a 200k-node chain, and run cycle renders under a
timeout so a regression of the hang fails instead of hanging.
@bahdotsh
bahdotsh force-pushed the fix/feedback-edge-routing branch from a1a7bd6 to f509b51 Compare September 3, 2026 08:12
Review of the new feedback routing turned up back edges that merge
into one ambiguous line, and back edges that vanish into a node box
and reappear on the far side as if they had tunnelled through it.

The reason is that the router picked its rows from a fixed table:
two rows below the box for the source nearest the gutter, one for
everybody else. That is fine for two sources and wrong for three.
It also worked out to exactly the rows the entry side used, so a
source in one layer and a target in the next fused their routes
together whenever both landed in the same gap. Left-right had the
same mistake in the other axis, measuring the drop and rise columns
from the *node's* border -- but boxes are centred in a column as
wide as its widest node, so a narrow box's own margin can sit
squarely inside a taller neighbour. The route was drawn into the
box, silently skipped, and what came out the other side read as an
edge that does not exist.

So stop guessing. Count the feedback endpoints per layer up front
and size each gap to hold one row (or column) per endpoint on top
of what the forward edges need. Nothing shares. Lanes measure from
column edges, the only place guaranteed clear of every box. With no
feedback edges the budget collapses to the old four-row gap and
six-column gutter, so acyclic diagrams render byte for byte as they
did before.

Labels needed the same treatment. A straight forward edge writes
its label on the first gap row, which is the row a feedback stem
drops through, so the stem now leaves under the left border where
nothing else writes. A left-right lane label that does not fit is
cut with an ellipsis rather than painted over the route's own
corner and off the edge of the canvas. And each gutter lane
reserves the width of *its own* label instead of the longest one in
the diagram, which was turning a single twenty-column label into
twenty wasted columns per lane.

Every route now asserts under test that it never lands on a node
cell, and a seeded property test pushes two thousand assorted
graphs through that check. Reverting any one of these fixes fails a
test, which is rather the point.

While at it, the render-with-timeout test helper reported a
panicking render thread as a ten second timeout, because the sender
drops on unwind and recv_timeout comes back Disconnected. A
misleading error from a helper that only speaks up when something
is already wrong. Please don't do that.
The previous commit budgeted a row or a column for every feedback
endpoint so that no two routes could merge. Then it drew all the
labels on top of them.

Labels go on last and overwrite whatever is underneath, so a label
that lands on a route hides the route rather than the other way
round. Four did. A top-down gutter label took the middle row of its
own lane, which is a gap row an outer lane runs along. A left-right
bent label started two columns right of the bend, which is where
the route into the next column rises. A straight one started two
columns right of its box, which is the column a stacked sibling
drops through. A lane label centred itself across its whole run,
crossings included.

None of this looks like corruption. You get a route with a word in
the middle of it, which reads as an edge that stops there.

Labels now take only the columns the routes left free, cut with an
ellipsis when there are not enough of them, and a lane label takes
the longest unbroken stretch of its own run rather than the middle.

Two other things drawn after the routes could cover them. A forward
arrowhead lands in the column immediately left of its box, which
was also the rank-0 rise column, so rises now start one column
further out. And a forward edge spanning more than one layer picks
the midpoint of its own span, which no gap budget covers, so it is
nudged off any row or column a route reserved.

While at it: drop the unused `_src_cx` parameter from draw_edge_lr,
and stop the property test leaking a string per case and passing
silently when a render returns None.

Top-down acyclic renders are still byte-identical to main.
Left-right acyclic changes once more beyond the arrow fix: a bent
label no longer overwrites the destination box, which the previous
commit listed as not addressed. Every render now asserts that no
label lands on a feedback route, and reverting any one of the fixes
above fails at least one test.

Claude-Session: https://claude.ai/code/session_01YSfTy6Fz4tGed1Fpe8ESe7
… calls

The feedback-routing invariants were asserted from inside the drawing
functions, which meant `set_label` grew an `on_lane: bool` parameter
that production code never reads. There is even a
`#[cfg(not(test))] let _ = on_lane;` sitting in there to stop the
compiler complaining about the thing.

That is backwards. The caller was being asked to declare what it was
allowed to paint over, so the check ended up trusting precisely the
code it existed to check.

Put the permission on the cell instead. A gutter lane marks its own
horizontal run as it draws it, and that run is the one kind of edge
cell a label may legitimately cover, so `set_label` can look at what
is actually underneath it rather than take the caller's word for it.

The route-through-a-box check moves out to `assert_invariants`, run
once when a render finishes. That one really does belong after the
fact: `add_connection` silently skips node cells, so a route drawn
over a box leaves nothing to catch at the moment of drawing — just a
line that stops dead at a border and reads as an edge nobody wrote.

No behavior change. Every snapshot renders byte-for-byte identical.

Claude-Session: https://claude.ai/code/session_01YSfTy6Fz4tGed1Fpe8ESe7
Reviewing the feedback-edge work turned up something worse than the
bug it was chasing. An ordinary labelled flowchart —

    graph LR
      A[Start] --> B{Check}
      B -->|success| C[Deploy]

— rendered that edge as `su…`. Two characters and an ellipsis.

The gap between two columns was budgeted for the feedback drops and
rises it has to carry, and for nothing else. It stays six columns
wide, the bend sits in the middle of it, and a bent label has to fit
on one side of that bend. Three columns is all a label ever got.
Anything longer than "Yes" was cut.

For the record `main` doesn't get this right either. It just fails in
the other direction and paints the label straight through the
destination box. Neither of those is a label.

So budget the gap for its labels too. A bent one needs twice its own
width because it lives beside a bend in the middle of the gap; a
straight one runs the length of it. Diagrams with long edge labels
come out wider, and that is the right trade — a wide diagram says
what it means, and a narrow one saying `su…` does not.

Two smaller things fell out of the same review.

A bent label picked its row by splitting the difference between the
two endpoints, which lands on the source's own row whenever the two
are two rows apart — and that is exactly where the edge's outgoing
run is. `│  A  │lbl┐`, the label eating the line it was naming. Any
row strictly between the two runs is clear of both, so use one of
those; when the rows are adjacent there is no such row, and the label
takes whichever side of the bend the run on its own row cannot reach.

And `fit_label` would hand back a bare `…` given a single column,
which tells the reader nothing except that something used to be
there. Under three columns the label is dropped instead.

While at it, write down why a top-down feedback stem leaves under the
left border and crosses its own source's outgoing edge. That is a
deliberate trade — a crossing reads as two edges meeting, which is
what it is — and without a note someone will helpfully "fix" it back.

Not fixed, and not a regression: in a dense diagram a label can still
land on a *different* edge's line. That predates all of this routing
work and wants a real placement pass, not another special case.

Claude-Session: https://claude.ai/code/session_01YSfTy6Fz4tGed1Fpe8ESe7
…rawn

The gap budget reserves a row or column for every feedback endpoint in
a layer, and the routes are placed by their rank among those endpoints.
Both numbers were counted straight off the feedback set, before the
planner had decided which of those edges it could actually place.

An edge whose endpoints are not both in a layer is silently dropped a
few lines later. Today nothing can trigger that, because every edge
endpoint is a registered node, so the two counts happen to agree. But
they agree by luck rather than by construction, and if the drop ever
starts firing the budget will be sized for a route nobody draws while
the ranks are numbered as if it existed. Good luck debugging that from
a picture of a box with a line through it.

Resolve the endpoints once, up front, into an explicit list of the
edges that survived, and count from that. Same output, one fewer way
for the geometry to disagree with itself later.
The previous commit in this branch sized each left-right gap for the
labels it carries, because a fixed six-column gap cut anything longer
than `Yes` down to an ellipsis. That was the right problem to solve.
The way it got solved was not.

The bend sat at the middle of the gap, and a bent edge writes its label
on one side of that bend. So buying N columns of room for the label
meant buying 2N columns of gap. An eighteen-character label cost
thirty-eight columns, and `wrap_lines` has no exemption for diagram
rows: anything wider than the terminal gets word-wrapped into
fragments. We traded a truncated label for a shredded diagram.

Nothing actually requires the bend to sit at the middle. All it has to
do is be the *same* column for every edge crossing that gap, so the
runs converge into one junction instead of fanning out. So put the
label between the drop columns and the bend, and let the bend sit
wherever that leaves it. A gap now holds the drops, a column of
padding, the label, the bend, a column of run, the rises, and the
arrowhead — which is one term, linear in the label, instead of two
terms and a doubling.

While at it, that one term charges for the drop columns, which the old
straight-label term forgot. It was covered anyway, because the bent
term's doubling was big enough to hide the shortfall in every shape I
could construct. Being accidentally correct because a *different*
wrong number happened to be larger is not a property I want to rely
on, so the term now stands on its own.

Diagrams with long labels come out roughly a third narrower. Top-down
renders are untouched.
A top-down canvas is sized for its boxes and not for its labels, so a
long enough label runs out of canvas at the right edge. On main the
write simply ran off the end, where `set` dropped it on the floor, and
the label stopped mid-word with nothing to say it had been cut. This
branch runs it through `fit_label` instead, so it ends in an ellipsis.

That is the *only* way an acyclic top-down render differs from main,
and up to now nothing said so out loud. The branch description claimed
those renders were byte-identical, which was very nearly true and
therefore exactly the kind of claim that quietly stops being true.

So pin it with a test. No behaviour change here — this is a statement
that the ellipsis is deliberate, not a leftover.

The asymmetry underneath is real and is not addressed here: left-right
diagrams now grow their gaps to fit labels while top-down ones still
truncate against a fixed canvas. Making top-down grow too would widen
every labelled diagram and cost the byte-identical property that makes
this branch reviewable. It can wait for its own change.
These flipped while poking at the interactive checkboxes from #39 in
the viewer. The toggle writes straight back to the file, so a test
session leaves footprints in the working tree.

Keeping them anyway. The task list in test.md is a rendering sample,
not a status report, and a few more unchecked boxes scattered through
it exercise the empty-box path better than a wall of green. The
features themselves are still very much implemented.
The route/label property test spawns each render on its own thread and
waits on a channel with a 20 second timeout, so that a layout which
never terminates fails the suite instead of wedging it. That was the
whole point of the timeout.

Except the timeout arm then called handle.join().

On a timeout the render thread is by definition still running, so
joining it waits for exactly the render that just failed to finish.
The test hangs instead of failing, and CI sits there until the job
limit kills it. The one failure this was built to catch is the one
failure it could not report.

Detach the thread instead and panic straight away. While at it, split
the arm in two: a disconnect means the render panicked, a timeout
means it hung, and those are different bugs that deserve different
messages. The snapshot helper next door already got this right.
A feedback route's arrowhead was drawn with a bare `set`, which writes
a character and nothing else. Every other cell of a route goes through
`connect_route`, which also marks the cell as feedback and records
which way the line runs through it.

So the head was the one cell of the route that the invariant machinery
could not see. `set_label` refuses to write over a feedback cell and
`assert_invariants` reports one that landed on a box, and neither of
them knew the arrowhead existed. A label written across it was allowed
through without a murmur, and the destination came out looking like a
box nothing points at.

Mark the head like the rest of the route, and record its direction
while at it, so a forward edge crossing it renders as a junction
rather than replacing it with a plain line that shows only the forward
edge.

That alone turns the silence into a failure, and it fires: a random
case in the property test really does put a label on an arrowhead.

It turns out the reserved rows were short. A route holds every row
between the border it leaves and the run it turns onto, and every row
between its run and the head it drops to, but only the runs were
reserved. A forward edge spanning several layers has no gap budget of
its own, so it takes the midpoint of its span and puts its label on
the row above — which was free to be the arrowhead row. Reserve the
rows a route actually runs through and the collision goes away.

Acyclic diagrams reserve nothing, so they are untouched: top-down
renders are still byte-identical to main.

Three tests: the reduced case from the property run, which keeps seven
arrowheads instead of five, and the two shapes nothing pinned before —
two back edges leaving one source, and two entering one target.
A diagram is 2D art on a fixed grid. `wrap_lines` treats every line
as prose, so a diagram wider than the terminal gets word-wrapped: the
overflow of each row lands on a row of its own, interleaved with the
rows below it, and what comes out is not a narrower diagram but a
shredded one. Boxes and edges from opposite sides of the drawing end
up stacked on top of each other.

This has always been reachable, but sizing left-right gaps for their
labels made it easy — four labelled back edges is enough to blow past
eighty columns.

Word-wrap is the wrong tool here and there is no right amount of it.
Cut the row at the display width and drop the rest. The left of the
diagram stays readable and, more to the point, stays aligned with the
rows above and below.

Clipping rather than leaving the row long is not a detail. The viewer
paints a line in full whatever its width, so an over-wide row runs
past the border and the scrollbar and shifts every row after it. A
long diagram row does not just look bad, it wrecks the frame.

The marker rides on the existing code-content metadata rather than in
a new variant, so a diagram is still a code block for every other
purpose — clicking one still copies the mermaid source. Only diagrams
are clipped; code blocks are text and their long lines wrap exactly
as they always have.

Note this cuts diagrams in the HTML export too, where the page could
have scrolled instead. Truncating beats shredding either way, and
`--width` is right there. A per-medium wrap policy can come later if
anyone actually wants it.
A forward edge spanning more than one layer has no gap budgeted for
it, so it takes the midpoint of its own span and then walks off any
row a feedback route holds. The walk only ever went *down*.

Down has a floor. The rows a layer's feedback entries reserve run
unbroken from the outermost entry's own row to the arrowhead row
just above the boxes, so a midpoint landing inside that block has
nothing free below it. The search hit the floor and returned the
arrowhead row anyway, which put the bus straight across the head of
every edge arriving at that layer and the label on an entry's run.
Nine feedback targets in one layer is enough to get there, eight is
fine, and seven arrowheads disappear when you do.

So search upward when downward runs out. Somewhere to go always
exists: a span of more than one layer covers a whole gap, and every
gap is budgeted for a bus row with a free label row above it.
Acyclic diagrams reserve nothing, the first candidate is still the
midpoint, and top-down renders stay byte-identical to main.

The property test could not have found this. It caps a case at 15
nodes and 30 edges, and nine targets carrying their own descendants
and back edges needs twenty of each. The new test builds that shape
directly and counts arrowheads, because nothing else objects when a
bus runs over a head: the junction it leaves behind is a perfectly
legitimate character in a perfectly legitimate place.
Three checkboxes in test.md got unchecked by poking at the
interactive checkboxes in the viewer, which writes straight back to
the file. That has nothing to do with routing feedback edges, and it
has been sitting in the middle of a 2500-line diff making reviewers
ask what it is doing there.

Reverting the change rather than dropping the commit that made it:
the pull request description references the SHAs of the three
commits that follow it, and rewriting those to save one line of diff
is a bad trade.
Half of this module is marked pub(crate), and almost none of it needs
to be. The JSON card view reaches in for exactly four things —
Canvas::new, draw_card, draw_edge_lr and to_span_rows — plus the
CardDrawRow it fills in. Everything else is diagram-internal: the
feedback route geometry, the cell flags, the connection bits, the
layout types, the label fitting.

Crate visibility on all of that costs nothing at runtime, but it does
mean the compiler cannot tell me when one of them stops being used,
and it quietly invites the next person to drive the router from
json.rs instead of going through a drawing call.

Make the rest private. No behaviour change; the tests live in the same
module and never noticed.
An arrowhead is a single cell, and it is the only cell of an edge that
says which of the two boxes is the destination. Draw anything across
it and add_connection helpfully repaints it as a junction — a
perfectly legitimate character in a perfectly legitimate place, so
nothing objects. The edge simply stops saying which way it runs, and
the reader is left with two boxes joined by a line that points at
neither.

It turns out this is not rare. Of the 2000 graphs the property test
generates, 926 lost at least one head. main loses them too, so it is
not a regression, but two of the ways to get there are new on this
branch. Top-down: a forward edge spanning more than one layer has no
gap budgeted for it, so it takes the midpoint of its own span, and the
rows a feedback route reserves only cover a layer that back edges
*enter* — a layer nothing enters leaves its arrowhead row free for the
bus to settle on. Left-right is the mirror: the bend search walked
right until it ran out of rise columns and stopped on exactly the
column those heads occupy.

The fix has two halves. A cell holding a head now records the axis its
own edge runs through it, and add_connection records a crossing there
without repainting the glyph, so the head survives whatever is drawn
over it. That alone takes the 926 to zero. Then both routers keep a
spanning edge off an arrowhead row or column to begin with, because a
bus that has to break over a row of heads belongs somewhere else and
every gap is budgeted for one.

Reserving those rows moves no layout. Against main, acyclic top-down
output over 400 random graphs differs only where main had painted a
head away, plus the overrun-label ellipsis already pinned by a test.
No snapshot moved.

The property test counts heads now, and a finished top-down render
asserts that nothing crosses one at all. Left-right cannot assert that
yet: each column is centred in the canvas on its own, so a forward
edge running in to its own destination can meet a feedback head
belonging to a box in another column entirely. That is the same class
as a label landing on an unrelated edge's line — it predates this
branch, main has it, and it wants a real placement pass rather than
another special case bolted on here.
@bahdotsh
bahdotsh merged commit 5560db7 into main Sep 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant