Summary
In examples/rnaseq_sections.mmd (and the near-identical tests/fixtures/rnaseq_sections.mmd), the fastqc_trimmed -> {BBSplit, SortMeRNA, RiboDetector} -> fastqc_filtered fan carries all 6 pipeline lines through every branch (not a partition - each branch is a full duplicate of the line set, since BBSplit/SortMeRNA/RiboDetector are alternative rRNA-removal tool choices). When BBSplit's (or RiboDetector's) diagonal bundle converges back into fastqc_filtered, individual line strokes visibly cross different-colored strokes of the branch that stays on the trunk row (SortMeRNA in symmetric style; SortMeRNA and RiboDetector in straight style), well before the actual merge point - the loop reads as a braided tangle rather than a clean convergence. This is what shows up visually as "SortMeRNA looks cramped in its loop."
This is not the defect #1259 or #1266 fixed. Both of those are about label clearance/spacing (#1259: a branch label grazing the loop's diagonals horizontally; #1266: stacked branch labels too tight vertically in straight style). Neither touches line-to-line crossings. check_route_segment_crossings - the validator built specifically to catch avoidable line crossings - exists and runs on this fixture, but a blanket exemption in its hub-detection helper silently waves this class of crossing through.
Repro
nf-metro render examples/rnaseq_sections.mmd -o /tmp/rnaseq.svg \
--x-spacing 60 --y-spacing 40 --no-chrome-css --diamond-style symmetric
# also reproduces (worse) with --diamond-style straight, and with the default
# (unset -> "straight") since neither .mmd sets %%metro diamond_style:
Crop the render around the BBSplit/SortMeRNA/RiboDetector/FastQC stations (roughly x=640-750, y=100-260 in the un-scaled SVG) to see the braid.
What is happening (measured on laid-out geometry)
Using route_edges() + apply_route_offsets() (the same functions the renderer and validator both call) on the fresh-parsed graph with graph.diamond_style set explicitly:
symmetric style - bbsplit -> fastqc_filtered (line star_salmon, green) and sortmerna -> fastqc_filtered (line star_rsem, blue) are different lines converging on the same target station. Their routed, offset segments cross at:
(718.9, 175.0) - 25.6px from the fastqc_filtered station centre (744.5, 175.0)
That is a third of the way across the whole loop (loop width is 96px, 648.5->744.5), not "near the merge."
straight style is worse: BBSplit sits on the trunk row, and both SortMeRNA's and RiboDetector's diagonals converge into it simultaneously. A pairwise scan of every cross-line, cross-branch segment pair feeding fastqc_filtered finds 45 distinct crossings between different lines, at distances from the shared station ranging from 31px up to 92px - i.e. crossings spanning almost the entire loop width.
None of these are flagged. Running tests/layout_validator.py::validate_layout (and check_route_segment_crossings directly) on this graph in either diamond style returns zero violations.
Root cause
tests/layout_validator.py's _edges_share_real_hub (used by _route_pair_crossing, which backs check_route_segment_crossings) exempts any crossing between two edges that share an endpoint at a real (non-port), multi-line station - with no distance check at all:
def _edges_share_real_hub(graph, ra_edge, rb_edge) -> bool:
shared = {ra_edge.source, ra_edge.target} & {rb_edge.source, rb_edge.target}
for sid in shared:
st = graph.stations.get(sid)
if st is not None and not st.is_port and len(graph.station_lines(sid)) > 1:
return True
return False
Compare this to the sibling exemption for synthetic port endpoints a few lines up in _route_pair_crossing, which only excludes a crossing near a shared port if it's within fan_tol (Y_SPACING) of that port and neither segment is near-vertical. _edges_share_real_hub has neither the distance gate nor the near-vertical carve-out - it's an unconditional pass for the whole edge pair, so a crossing 92px from the station is treated exactly the same as one 2px from it.
This means: any fork/join where two or more branches carry the same line (the common nf-core "alternative tool choice" pattern - BBSplit vs SortMeRNA vs RiboDetector vs Bowtie2, or similarly duplicated aligner/pseudo-aligner fans elsewhere in the corpus) can braid arbitrarily badly on the way to the merge station and check_route_segment_crossings will never catch it, because every such pair trivially shares the merge station as a real, multi-line hub.
Proposed fix direction
- Validator gap (should fix regardless of the render fix below): give
_edges_share_real_hub a distance gate analogous to the port case - only exempt a crossing within some tolerance (e.g. Y_SPACING, or the loop's own half-width) of the shared station, not unconditionally. This turns the check back on for exactly the cases in this issue and would have caught the underlying defect.
- Render fix: the actual braiding comes from how
_spread_diagonal_bundles / _apply_diagonal_spread (src/nf_metro/layout/routing/postprocess.py) assign each line's lateral offset for a join group - keyed off the target station's per-line ranking - while apply_route_offsets (src/nf_metro/layout/routing/common.py) assigns the offset for the route's near-source waypoint by proximity to whichever endpoint it's closer to, effectively pulling in the source station's ranking for that point. When a branch's own bundle occupies the exact trunk row the other branches are converging into (as SortMeRNA's does here), the diagonal bundles' descent overlaps that already-flat trunk band for a large fraction of the loop's width, and per-line rank mismatches between the two orderings show up as visible crossings rather than a clean nested convergence.
Which diamond styles are affected
Both. straight is worse here because two branches (not one) converge simultaneously into the row occupied by the third; symmetric still shows at least one crossing per branch pair.
Related
Summary
In
examples/rnaseq_sections.mmd(and the near-identicaltests/fixtures/rnaseq_sections.mmd), thefastqc_trimmed -> {BBSplit, SortMeRNA, RiboDetector} -> fastqc_filteredfan carries all 6 pipeline lines through every branch (not a partition - each branch is a full duplicate of the line set, since BBSplit/SortMeRNA/RiboDetector are alternative rRNA-removal tool choices). When BBSplit's (or RiboDetector's) diagonal bundle converges back intofastqc_filtered, individual line strokes visibly cross different-colored strokes of the branch that stays on the trunk row (SortMeRNA insymmetricstyle; SortMeRNA and RiboDetector instraightstyle), well before the actual merge point - the loop reads as a braided tangle rather than a clean convergence. This is what shows up visually as "SortMeRNA looks cramped in its loop."This is not the defect #1259 or #1266 fixed. Both of those are about label clearance/spacing (#1259: a branch label grazing the loop's diagonals horizontally; #1266: stacked branch labels too tight vertically in
straightstyle). Neither touches line-to-line crossings.check_route_segment_crossings- the validator built specifically to catch avoidable line crossings - exists and runs on this fixture, but a blanket exemption in its hub-detection helper silently waves this class of crossing through.Repro
Crop the render around the
BBSplit/SortMeRNA/RiboDetector/FastQCstations (roughly x=640-750, y=100-260 in the un-scaled SVG) to see the braid.What is happening (measured on laid-out geometry)
Using
route_edges()+apply_route_offsets()(the same functions the renderer and validator both call) on the fresh-parsed graph withgraph.diamond_styleset explicitly:symmetricstyle -bbsplit -> fastqc_filtered(linestar_salmon, green) andsortmerna -> fastqc_filtered(linestar_rsem, blue) are different lines converging on the same target station. Their routed, offset segments cross at:That is a third of the way across the whole loop (loop width is 96px, 648.5->744.5), not "near the merge."
straightstyle is worse: BBSplit sits on the trunk row, and both SortMeRNA's and RiboDetector's diagonals converge into it simultaneously. A pairwise scan of every cross-line, cross-branch segment pair feedingfastqc_filteredfinds 45 distinct crossings between different lines, at distances from the shared station ranging from 31px up to 92px - i.e. crossings spanning almost the entire loop width.None of these are flagged. Running
tests/layout_validator.py::validate_layout(andcheck_route_segment_crossingsdirectly) on this graph in either diamond style returns zero violations.Root cause
tests/layout_validator.py's_edges_share_real_hub(used by_route_pair_crossing, which backscheck_route_segment_crossings) exempts any crossing between two edges that share an endpoint at a real (non-port), multi-line station - with no distance check at all:Compare this to the sibling exemption for synthetic port endpoints a few lines up in
_route_pair_crossing, which only excludes a crossing near a shared port if it's withinfan_tol(Y_SPACING) of that port and neither segment is near-vertical._edges_share_real_hubhas neither the distance gate nor the near-vertical carve-out - it's an unconditional pass for the whole edge pair, so a crossing 92px from the station is treated exactly the same as one 2px from it.This means: any fork/join where two or more branches carry the same line (the common nf-core "alternative tool choice" pattern - BBSplit vs SortMeRNA vs RiboDetector vs Bowtie2, or similarly duplicated aligner/pseudo-aligner fans elsewhere in the corpus) can braid arbitrarily badly on the way to the merge station and
check_route_segment_crossingswill never catch it, because every such pair trivially shares the merge station as a real, multi-line hub.Proposed fix direction
_edges_share_real_huba distance gate analogous to the port case - only exempt a crossing within some tolerance (e.g.Y_SPACING, or the loop's own half-width) of the shared station, not unconditionally. This turns the check back on for exactly the cases in this issue and would have caught the underlying defect._spread_diagonal_bundles/_apply_diagonal_spread(src/nf_metro/layout/routing/postprocess.py) assign each line's lateral offset for a join group - keyed off the target station's per-line ranking - whileapply_route_offsets(src/nf_metro/layout/routing/common.py) assigns the offset for the route's near-source waypoint by proximity to whichever endpoint it's closer to, effectively pulling in the source station's ranking for that point. When a branch's own bundle occupies the exact trunk row the other branches are converging into (as SortMeRNA's does here), the diagonal bundles' descent overlaps that already-flat trunk band for a large fraction of the loop's width, and per-line rank mismatches between the two orderings show up as visible crossings rather than a clean nested convergence.Which diamond styles are affected
Both.
straightis worse here because two branches (not one) converge simultaneously into the row occupied by the third;symmetricstill shows at least one crossing per branch pair.Related
straight-style row pitch for stacked branch labels. Its own PR body reviewed this exact fixture's gallery render and called the change "neutral - pixel-level shifts only," meaning it does not touch this defect either.sylph->multiqc_final) but not this one, and the mechanism (offset collapse) is distinct from this one (offset order mismatch producing a real crossing, not a collapse).