Skip to content

Commit 2983c55

Browse files
pinin4fjordsclaude
andcommitted
fix(layout): seat an orphaned half-pitch branch on a full grid row
A half-pitch offset draws a two-way symmetric fork as one compact grid unit, so both members are placed together and each only reads as deliberate because it balances the other across the trunk. When Stage 6.10's _align_terminus_to_upstream pulls a terminus member onto its producer's trunk Y, the surviving member holds an offset that straddles nothing and renders as a branch stranded between two grid rows. Add Stage 6.18, _expand_orphaned_half_grid_stations: mirror each marked station's offset about the section's LR/RL port anchor and, with the opposite slot empty, seat the branch one full row from the anchor on the side it already sits. Gated to stations at a genuine half pitch, since the marker set also carries members already a whole number of rows out. An earlier gate cannot do this. _layout_once runs up to seven times per render and the motivating section carries two structurally identical half-pitch pairs, only one of them broken, so every predicate tried inside _recenter_full_bundle_columns or _align_terminus_to_upstream flipped the section into a different fixpoint and bent the trunk. _half_grid_frame and _straddles_nothing are shared with the invariant test, as _iter_symmetric_diamonds already does, so the oracle and the fix cannot disagree about which stations are legitimately half-pitch. Rename the exercising fixture to paired_input_fan_branch_tree and give it generic labels and station ids. The replacements are width-matched to what they replace, so its geometry is coordinate-for-coordinate identical and the three regressions it locks all still reproduce: the input-fan bypass-V band, the reconverging-diamond entry-port centring, and the new half-pitch straddle. Its stage_a4 branch now sits level with the out_a1 branch instead of half a row above the trunk. Register the resulting off-track reanchor divergence, tracked in #1566. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9fcbb58 commit 2983c55

11 files changed

Lines changed: 322 additions & 109 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
%%metro title: Paired-track demo
2+
%%metro diamond_style: symmetric
3+
%%metro style: dark
4+
%%metro legend: bl
5+
%%metro files: pairs_in | FILE | Input set
6+
%%metro files: out_a1 | OUT | Track A
7+
%%metro file: out_a2 | OUT | Output A2
8+
%%metro file: out_a3 | OUT | Output three
9+
%%metro file: out_b | OUT | Probe value
10+
%%metro files: out_c | OUT | Chain out
11+
%%metro line: track_a | Track A | #0570b0
12+
%%metro line: track_b | Track B | #d94801
13+
14+
graph LR
15+
subgraph input_sec [Inputs]
16+
pairs_in[ ]
17+
convert_a[Convert one\ntrack a]
18+
convert_b[Convert two\ntrack b]
19+
20+
pairs_in -->|track_a| convert_a
21+
pairs_in -->|track_b| convert_b
22+
end
23+
24+
subgraph branch_sec [Branching stage]
25+
stage_a1[Upstream\nprimary stage]
26+
stage_a2[Filter step]
27+
stage_a3[Fork\nannotation]
28+
stage_a4[Terminal\nsegment]
29+
out_a1[ ]
30+
out_a2[ ]
31+
out_a3[ ]
32+
33+
pairs_in -->|track_a| stage_a1
34+
pairs_in -->|track_b| stage_a1
35+
stage_a1 -->|track_a,track_b| stage_a2
36+
stage_a2 -->|track_a,track_b| out_a1
37+
stage_a2 -->|track_a,track_b| stage_a3
38+
stage_a3 -->|track_a,track_b| out_a2
39+
stage_a3 -->|track_a,track_b| stage_a4
40+
stage_a4 -->|track_a,track_b| out_a3
41+
end
42+
43+
subgraph probe_sec [Hub]
44+
stage_b1[merge node]
45+
out_b[ ]
46+
47+
convert_a -->|track_a| stage_b1
48+
convert_b -->|track_b| stage_b1
49+
stage_b1 -->|track_a,track_b| out_b
50+
end
51+
52+
subgraph chain_sec [Downstream chain]
53+
split_a[Region split\ntrack a]
54+
split_b[Region split\ntrack b]
55+
stage_c1[Assemble\nstage]
56+
stage_c2[Collate\nmerge step]
57+
stage_c3[Fit and rank\nstage]
58+
stage_c4[Adjust to\nscale]
59+
out_c[ ]
60+
61+
convert_a -->|track_a| split_a
62+
convert_b -->|track_b| split_b
63+
split_a -->|track_a| stage_c1
64+
split_b -->|track_b| stage_c1
65+
stage_c1 -->|track_a,track_b| stage_c2
66+
stage_c2 -->|track_a,track_b| stage_c3
67+
stage_c3 -->|track_a,track_b| stage_c4
68+
stage_c4 -->|track_a,track_b| out_c
69+
end

examples/topologies/tn_wes_input_fan_somatic_tree.mmd

Lines changed: 0 additions & 69 deletions
This file was deleted.

scripts/gallery.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,13 +1833,13 @@ gallery:
18331833
station. The output's branch peels off between the two rather than
18341834
overrunning the successor's column, so the icon clears the downstream
18351835
stations and their labels."
1836-
- id: tn_wes_input_fan_somatic_tree
1836+
- id: paired_input_fan_branch_tree
18371837
source_dir: examples/topologies
18381838
category: Feature Showcase
18391839
description:
1840-
"A tumour/normal WES map: the input source fans to two CRAM->BAM
1841-
converters and threads the germline+tumour bundle cleanly between them to
1842-
the somatic caller, whose dead-end VCF outputs peel off without crowding
1840+
"A paired-input map: the source fans to two stacked converters and
1841+
threads the two-track bundle cleanly between them to the branch tree,
1842+
whose dead-end outputs peel off onto full grid rows without crowding
18431843
their neighbours."
18441844
- id: single_line_dual_source_stacked_exit
18451845
source_dir: examples/topologies

src/nf_metro/layout/CONTRACT.md

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,10 @@ pass:
247247
grid snap (6.4).
248248
- `graph.half_grid_station_ids` - written by Stage 6.3 (`center_ports` only)
249249
and Stage 6.17 (`diamond_style='symmetric'`); read by the Stage 6.4 grid
250-
snap, which must skip these half-pitch stations. Stage 6.17 runs after the
251-
last snap, so its writes mark branches for the invariant tests / straddle
252-
guard rather than feeding a later snap.
250+
snap, which must skip these half-pitch stations. Stage 6.18 both reads the
251+
set and clears the marking off any station it seats back on a full row, so
252+
the post-layout readers (the straddle guard, the co-fanned drop-clearance
253+
rule in `routing/intra_handlers.py`) see only stations still at half pitch.
253254
- `graph.symfan_trunk_station_ids` - written by Stage 6.3 (`center_ports` only);
254255
read by the Stage 6.4 grid snap, which must skip these source/trunk stations
255256
so they stay on the symfan's local frame instead of snapping to a rowspan
@@ -878,7 +879,8 @@ in pipeline order.
878879
- **Related tests**: `test_symfan_pairs_share_y`.
879880
- **Lifecycle:** invariant - 2-branch symfan pairs keep their half-pitch
880881
offsets at the final boundary (Stage 6.4 skips
881-
`graph.half_grid_station_ids`).
882+
`graph.half_grid_station_ids`); only Stage 6.18 may seat one on a full
883+
row, and only once its straddling partner has moved away.
882884

883885
### Stage 6.4: snap all Y to grid (engine.py)
884886
- **Purpose**: Final pass snapping every station and port Y to the
@@ -959,7 +961,9 @@ in pipeline order.
959961
are temporarily broken; both are restored before leaving the
960962
`if center_ports:` block.
961963
- **Lifecycle:** invariant - full-bundle columns are symmetric around
962-
the row's final trunk Y at the boundary; no later stage re-fans them.
964+
the row's final trunk Y at the boundary; no later stage re-fans them,
965+
though Stage 6.18 seats a half-pitch member on a full row once its
966+
straddling partner has moved away.
963967
*liftable:* no - one-shot, order-dependent (computes against the final
964968
trunk Y, so a premature run is wrong).
965969

@@ -1223,10 +1227,10 @@ in pipeline order.
12231227
Per-diamond, so a diamond compacts even when it shares a section with a
12241228
wider fan (which keeps its full-pitch slots) and regardless of
12251229
`center_ports`. Records the branches on
1226-
`MetroGraph.half_grid_station_ids`. Runs last, after every
1227-
trunk-settling pass, so the branches straddle the section trunk's final
1228-
Y exactly; the compaction only moves them inward toward the trunk, so it
1229-
never breaks bbox containment.
1230+
`MetroGraph.half_grid_station_ids`. Runs after every trunk-settling
1231+
pass, so the branches straddle the section trunk's final Y exactly; the
1232+
compaction only moves them inward toward the trunk, so it never breaks
1233+
bbox containment.
12301234
- **Helper**: `_apply_half_grid_symmetric_diamonds`.
12311235
- **Precondition**: Trunk Ys settled (post-6.16); `diamond_style`
12321236
is `symmetric`.
@@ -1239,7 +1243,41 @@ in pipeline order.
12391243
`test_symmetric_diamond_both_branches_deviate`,
12401244
`_guard_symmetric_diamond_branches_straddle_trunk`.
12411245
- **Lifecycle:** invariant - symmetric diamond branches keep their
1242-
half-pitch offsets at the final boundary (no later Y mutation).
1246+
half-pitch offsets at the final boundary; only Stage 6.18 may move one,
1247+
and only when its straddling partner is gone.
1248+
1249+
### Stage 6.18: orphaned half-pitch expansion (engine.py)
1250+
- **Purpose**: A half-pitch offset encodes one side of a symmetric pair
1251+
straddling the section trunk, so the pair reads as one compact grid
1252+
unit. Stage 6.10's `_align_terminus_to_upstream` may pull a terminus
1253+
member onto its producer's trunk Y, leaving the other member holding an
1254+
offset that straddles nothing and rendering as a branch stranded
1255+
between two grid rows. `_straddles_nothing` mirrors each marked
1256+
station's offset about the section's LR/RL port anchor; with no station
1257+
at the mirrored slot, the branch is seated one full row from the anchor
1258+
on the side it already sits, its half-grid marking cleared, and the
1259+
section bbox grown over the moved branch alone. Stations marked
1260+
half-grid whose settled Y is already a whole number of rows from the
1261+
anchor are left alone.
1262+
- **Helper**: `_expand_orphaned_half_grid_stations`
1263+
(`phases/fan_bundles.py`), sharing `_half_grid_frame` /
1264+
`_straddles_nothing` with the invariant test.
1265+
- **Precondition**: Every pass that places or dissolves a half-pitch pair
1266+
has run (post-6.17), so the half-grid marks are final.
1267+
- **Postcondition**: No station in `graph.half_grid_station_ids` sits half
1268+
a pitch off its section's LR/RL port anchor with the mirrored slot
1269+
empty.
1270+
- **Invariants preserved**: Trunk station Y, ports, bbox containment.
1271+
Deliberately not preserved: the half-grid marker set (the seated
1272+
station's id is discarded, so the post-layout readers see only stations
1273+
still at half pitch) and the section bbox extent, which grows over the
1274+
seated branch. No runtime `_guard_*` arms this postcondition:
1275+
`test_half_grid_stations_straddle_in_pairs` covers it across the corpus
1276+
without the abort risk a live guard would add to novel input.
1277+
- **Related tests**: `test_half_grid_stations_straddle_in_pairs`.
1278+
- **Lifecycle:** invariant - the expanded branch keeps its full-row Y at
1279+
the final boundary (no later Y mutation). The cleared marker reaches the
1280+
next `_layout_once` pass, which re-derives the marks from scratch.
12431281

12441282
## Unclear / structural-debt signals
12451283

src/nf_metro/layout/engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
_center_lr_entry_ports_on_fork,
111111
_convergence_source_ys,
112112
_divergence_target_ys,
113+
_expand_orphaned_half_grid_stations,
113114
_iter_symmetric_diamonds,
114115
_recenter_full_bundle_columns,
115116
_redistribute_fanout_siblings,
@@ -2007,6 +2008,13 @@ def _finalize_layout(
20072008
_apply_half_grid_symmetric_diamonds(graph, y_spacing)
20082009
_snap(graph, "6.17")
20092010

2011+
# Stage 6.18: Seat orphaned half-pitch stations on a full grid row (see
2012+
# _expand_orphaned_half_grid_stations and CONTRACT.md Stage 6.18). Must
2013+
# follow every pass that places or dissolves a half-pitch pair, so the
2014+
# half-grid marks are final.
2015+
_expand_orphaned_half_grid_stations(graph, y_spacing, section_y_padding)
2016+
_snap(graph, "6.18")
2017+
20102018
if validate:
20112019
if graph._defer_final_guards:
20122020
graph._after_final_deferred = True

src/nf_metro/layout/phase_state.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10",
6464
"5.1", "5.2", "5.3", "5.4", "5.5",
6565
"6.1", "6.2", "6.3", "6.4", "6.5", "6.6", "6.7", "6.8", "6.9", "6.10",
66-
"6.11", "6.12", "6.13", "6.14", "6.15a", "6.15", "6.16", "6.17",
66+
"6.11", "6.12", "6.13", "6.14", "6.15a", "6.15", "6.16", "6.17", "6.18",
6767
) # fmt: skip
6868

6969

@@ -106,23 +106,24 @@ class PhaseFieldSpec:
106106
"_row_y_grid_info": PhaseFieldSpec(
107107
name="_row_y_grid_info",
108108
writer_stage="1.2",
109-
reader_stages=("4.2", "6.3", "6.4"),
109+
reader_stages=("4.2", "6.3", "6.4", "6.18"),
110110
enforcement=FieldEnforcement.REQUIRE_WRITER,
111111
why=(
112112
"row-grid metadata from Stage 1.2's _align_row_y_grids; the grid-group "
113-
"port snap, fan re-centre, and grid snap read it to group same-row "
114-
"sections onto a shared pitch"
113+
"port snap, fan re-centre, grid snap and half-pitch expansion read it "
114+
"to group same-row sections onto a shared pitch"
115115
),
116116
),
117117
"half_grid_station_ids": PhaseFieldSpec(
118118
name="half_grid_station_ids",
119119
writer_stage="6.3",
120-
reader_stages=("6.4",),
120+
reader_stages=("6.4", "6.18"),
121121
enforcement=FieldEnforcement.REQUIRE_WRITER,
122122
why=(
123123
"2-branch symfan stations Stage 6.3 places at half-pitch; Stage 6.4's "
124124
"grid snap must skip them or it snaps their intentional half-grid Y to "
125-
"the full grid"
125+
"the full grid, and Stage 6.18 reads the set to find members whose "
126+
"pair partner moved to a full row"
126127
),
127128
run_condition_attr="center_ports",
128129
),

0 commit comments

Comments
 (0)