@@ -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
0 commit comments