Skip to content

Commit c79334a

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 81e43cb commit c79334a

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)