Skip to content

Commit 32d4f17

Browse files
authored
Merge pull request #1567 from seqeralabs/fix/symfan-terminus-half-pitch
fix(layout): seat an orphaned half-pitch branch on a full grid row
2 parents 81e43cb + c79334a commit 32d4f17

10 files changed

Lines changed: 858 additions & 37 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

scripts/gallery.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1837,9 +1837,9 @@ gallery:
18371837
source_dir: examples/topologies
18381838
category: Feature Showcase
18391839
description:
1840-
"A paired-track map: the input source fans to two stacked
1841-
converters and threads the two-track bundle cleanly between them to
1842-
the somatic caller, whose dead-end 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
),

src/nf_metro/layout/phases/fan_bundles.py

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
from __future__ import annotations
44

5+
import math
56
from collections import defaultdict
67
from collections.abc import Iterator
78

89
from nf_metro.layout.constants import (
910
SAME_COORD_TOLERANCE,
1011
SECTION_Y_PADDING,
1112
)
12-
from nf_metro.layout.geometry import shift_section
13+
from nf_metro.layout.geometry import lanes_run_along_x, shift_section
1314
from nf_metro.layout.phase_state import require_phase_field
1415
from nf_metro.layout.phases._common import (
1516
_fan_offsets,
@@ -989,13 +990,7 @@ def _carry_symmetric_branch_continuations(
989990
for s in section.station_ids
990991
if s in graph.stations and not graph.stations[s].is_port
991992
]
992-
if content_ys:
993-
grow_section_bbox_min_edge(
994-
graph, section, "y", min(content_ys) - section_y_padding
995-
)
996-
grow_section_bbox_max_edge(
997-
graph, section, "y", max(content_ys) + section_y_padding
998-
)
993+
_grow_section_bbox_over_ys(graph, section, content_ys, section_y_padding)
999994

1000995

1001996
def _section_lr_entry_port(graph: MetroGraph, section: Section) -> str | None:
@@ -1240,3 +1235,116 @@ def _pull_continuation_onto(
12401235
for sid in carried:
12411236
graph.stations[sid].y = branch.y
12421237
return carried
1238+
1239+
1240+
def _section_occupants(graph: MetroGraph, section: Section) -> list[Station]:
1241+
"""The section's visible stations -- the ones that can occupy a row slot."""
1242+
return [
1243+
st
1244+
for sid in section.station_ids
1245+
if (st := graph.stations.get(sid)) is not None
1246+
and not st.is_port
1247+
and not st.is_hidden
1248+
]
1249+
1250+
1251+
def _grow_section_bbox_over_ys(
1252+
graph: MetroGraph, section: Section, ys: list[float], section_y_padding: float
1253+
) -> None:
1254+
"""Grow *section*'s bbox so *ys* sit inside it with padding on both edges."""
1255+
if not ys:
1256+
return
1257+
grow_section_bbox_min_edge(graph, section, "y", min(ys) - section_y_padding)
1258+
grow_section_bbox_max_edge(graph, section, "y", max(ys) + section_y_padding)
1259+
1260+
1261+
def _half_grid_frame(
1262+
graph: MetroGraph, section: Section, y_spacing: float
1263+
) -> tuple[float, float] | None:
1264+
"""``(anchor_y, row_pitch)`` for *section*, or None when it has no frame.
1265+
1266+
The anchor is the LR/RL port Y that the half-pitch passes fan about, so a
1267+
vertical flow (which stacks its lines along X) and a section without an
1268+
LR/RL port both have nothing to measure against.
1269+
"""
1270+
if lanes_run_along_x(section.direction):
1271+
return None
1272+
anchor = _section_lr_port_anchor_y(graph, section)
1273+
if anchor is None:
1274+
return None
1275+
pitch = _section_row_pitch(graph, section.id, y_spacing)
1276+
return (anchor, pitch) if pitch > 0 else None
1277+
1278+
1279+
def _straddles_nothing(
1280+
station: Station, anchor: float, pitch: float, occupants: list[Station]
1281+
) -> bool:
1282+
"""True when *station* sits half a pitch off *anchor* with its mirror empty.
1283+
1284+
A half-pitch offset is meaningful only as one side of a pair straddling the
1285+
anchor, so the slot at the mirrored offset has to be occupied for the offset
1286+
to buy anything. Stations a whole number of rows from the anchor are
1287+
already on the grid and never qualify.
1288+
"""
1289+
offset = station.y - anchor
1290+
if abs(abs(offset) - 0.5 * pitch) > SAME_COORD_TOLERANCE:
1291+
return False
1292+
mirror = anchor - offset
1293+
return not any(
1294+
other is not station and abs(other.y - mirror) < SAME_COORD_TOLERANCE
1295+
for other in occupants
1296+
)
1297+
1298+
1299+
def _expand_orphaned_half_grid_stations(
1300+
graph: MetroGraph,
1301+
y_spacing: float,
1302+
section_y_padding: float = SECTION_Y_PADDING,
1303+
) -> None:
1304+
"""Seat on a full row any half-pitch station whose pair partner moved away.
1305+
1306+
``_recenter_full_bundle_columns``, ``_apply_half_grid_2branch_symfan`` and
1307+
``_apply_half_grid_symmetric_diamonds`` place both members of a two-way
1308+
fork at ``anchor +/- 0.5 * pitch`` together, and
1309+
``_carry_symmetric_branch_continuations`` marks each member's onward chain
1310+
on that member's Y. ``_align_terminus_to_upstream`` is entitled to pull a
1311+
terminus member onto its producer's Y, and :func:`_straddles_nothing`
1312+
detects the partner left behind.
1313+
1314+
Seating derives the new Y from the row pitch rather than doubling the
1315+
measured offset, so the branch lands exactly on the row even though it
1316+
qualified within a tolerance band.
1317+
1318+
An off-track icon counts as a straddle partner while never being seated
1319+
itself: the off-track lift owns its Y, so it can hold a slot it must not be
1320+
moved out of.
1321+
"""
1322+
require_phase_field(graph, "half_grid_station_ids")
1323+
half_grid = graph.half_grid_station_ids
1324+
if not half_grid:
1325+
return
1326+
for section in graph.sections.values():
1327+
marked = [sid for sid in section.station_ids if sid in half_grid]
1328+
if not marked:
1329+
continue
1330+
frame = _half_grid_frame(graph, section, y_spacing)
1331+
if frame is None:
1332+
continue
1333+
anchor, pitch = frame
1334+
occupants = _section_occupants(graph, section)
1335+
moved_ys: list[float] = []
1336+
for sid in marked:
1337+
st = graph.stations.get(sid)
1338+
if st is None or st.is_port or st.off_track or st.is_hidden:
1339+
continue
1340+
if not _straddles_nothing(st, anchor, pitch, occupants):
1341+
continue
1342+
st.y = anchor + math.copysign(pitch, st.y - anchor)
1343+
half_grid.discard(sid)
1344+
moved_ys.append(st.y)
1345+
if not moved_ys:
1346+
continue
1347+
# Only the expanded branch can have crossed an edge; sizing against the
1348+
# whole section instead would hand the bbox slack that the row-compact
1349+
# passes then take up by dragging unrelated content off its row.
1350+
_grow_section_bbox_over_ys(graph, section, moved_ys, section_y_padding)

0 commit comments

Comments
 (0)