Skip to content

Fork-join fan with duplicated line-set braids into a crossing tangle; route_segment_crossing's real-hub exemption has no distance gate #1276

Description

@pinin4fjords

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

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions