Skip to content

Guard tiers

nf-metro defends its layout output with two families of runtime invariant:

  • _guard_* functions in src/nf_metro/layout/phases/guards.py - raise PhaseInvariantError on a violation.
  • check_* functions in src/nf_metro/layout/routing/invariants.py - return a list of violation objects; a caller decides whether to raise.

The render path runs the cheap Tier-A subset of both families on the settled geometry, so a layout defect reaches the end user as a warning (or a --strict error) instead of a silently-broken SVG:

  • assert_render_layout_invariants (phases/guards.py) runs the Tier-A _guard_* postconditions, and
  • assert_render_curve_invariants (routing/invariants.py) runs the Tier-A routing check_* invariants.

The Tier-B remainder of the _guard_* suite is gated behind compute_layout(validate=True), which the nf-metro render path never sets; it is costlier or depends on a mid-pipeline reroute.

Tier A is the always-on render-path set; Tier B is the validate=True set.

TierMeaningPromotion intent
ACheap, observational structural checks: placement (finite coords, bbox containment with marker overhang, station overlap, ports on boundaries) plus _guard_stations_within_bbox, the inter-section backtrack/wrap guards (_guard_inter_section_route_no_backtrack, _guard_inter_section_route_no_full_width_backtrack, _guard_serpentine_no_backtrack, _guard_inter_section_route_clears_own_section_interior), the non-consumer-section breeze-through guards (_guard_no_route_through_section, _guard_no_line_crosses_non_consumer), and the routing check_* invariants already always-on via the render chokepoint.Run on the default render path, warning by default with a --strict escalation.
BThe remaining validate=True set: route-shape, bundle-order, label, rail, and merge-port geometry. Correct but either costlier or dependent on a mid-pipeline reroute (they consume route_edges output), so not cheap-always-on.Stay behind validate=True / --strict.
CTest-only oracles: too slow, too fixture-specific, or non-observational.Live in the test suite, not the runtime suite.

Tier A is mostly the cheap-observational set the cost audit confirms runs in low tens of microseconds, with a handful of routed-geometry sweeps in the ~10-90 us range whose visibility matters more than their cost: the inter-section backtrack/wrap guards (the dearest ~11 us) and the non-consumer-section breeze-through guards (_guard_no_route_through_section ~86 us, _guard_no_line_crosses_non_consumer ~75 us) — a line plotted over a section or station it never touches is at least as visibly broken as a backtracking bundle, so it stays on the always-on path despite the cost. Tier B holds everything else that is materially more expensive or depends on a mid-pipeline reroute; its most expensive members (_guard_no_opposing_line_overlap ~86 us, _guard_trunk_bands_crossing_optimal ~57 us) are routed-geometry sweeps. The always-on check_* routing invariants (routing/invariants.py) run through a separate chokepoint and include members up to check_no_hanging_routes at ~470 us; cost alone does not gate Tier-A membership in either family, visibility of the defect does. Tier C holds the two seam oracle checks - check_seam_approach_equals_departure and check_seam_segments_meet_at_port - which verify the rotation-unification property: at every inter-section seam the approach must place each line on the lane coordinate that lane_x assigns it. Both are correctness oracles for the rotation series rather than runtime guards, so they live in the test suite (tests/test_seam_lane_x.py). Every other guard and check is reachable from compute_layout or the render chokepoint.

The classification is data, not prose:

  • GUARD_REGISTRY (phases/guards.py) - the single ordered source of truth for the validate=True guard call sequence. Its order is the call order; run_validate_guards iterates it. Each entry is a GuardSpec with tier, the needs set (which of offsets / routes / section_y_* the guard takes), bisection_safe (runs at every Pass C checkpoint, gated by first_valid_stage) vs final-only, and the _BISECTION_FIRST_VALID data derived back out for the engine re-export.
  • INLINE_GUARD_REGISTRY (phases/guards.py) - the same GuardSpec schema applied to the guards engine.py invokes directly at a specific pipeline stage rather than through the Pass C / final runner. Like CHECK_REGISTRY it is classification only (no needs / bisection_safe dispatch data), so every defined _guard_* lives in exactly one of the two guard registries and none escapes tier / issue-pin classification.

The Tier-A _guard_* postconditions across both registries are run on the render path by assert_render_layout_invariants, the sibling of the routing chokepoint. render_layout_invariant_specs selects them: every Tier-A guard except _RENDER_CHOKEPOINT_AUTHORING_GUARDS, the two authoring-error guards (_guard_no_same_row_backward_feed, _guard_no_mixed_entry_directions) that raise a ValueError on un-renderable input and stay always-on hard fails in the engine rather than joining the warn-by-default chokepoint.

  • CHECK_REGISTRY (routing/invariants.py) - the same GuardSpec schema applied to the routing checks. Because checks return lists rather than raising, this is a classification registry, not a dispatcher; the runtime chokepoint stays assert_render_curve_invariants. The registries are unified through the shared schema and this page, not by merging the raise-vs-return error protocols.

Each GuardSpec also carries issue_pin (the #NNN issues a guard was born from, kept as data so consolidation cannot lose the regression trail) and narrow_reason (why an issue-pinned guard stays scoped to its case rather than being a general property). tests/test_guard_registry.py keeps these honest: every guard and check is registered exactly once, the Tier-A check set equals the curve render chokepoint exactly, the render-layout chokepoint equals the Tier-A guard set minus the authoring guards, no validate-only guard duplicates an always-on check, and every issue-pinned guard records its issue and a narrow_reason.

scripts/guard_cost_audit.py lays out every fixture under examples/ + examples/topologies/ once, then times each registered guard and check against the final geometry. Mean microseconds-per-fixture below are from that run; regenerate with:

Terminal window
python scripts/guard_cost_audit.py --json /tmp/guard_cost.json
guardtiermean usdispatch
_guard_coordinates_finiteA1.5bisection (first valid: start)
_guard_section_bboxes_positiveA0.4bisection (first valid: start)
_guard_stations_in_sectionsA3.5bisection (first valid: after Stage 5.3)
_guard_ports_on_boundariesA1.3bisection (first valid: start)
_guard_no_station_overlapA13.5bisection (first valid: after Stage 6.4)
_guard_no_coincident_station_coordsA3.3bisection (first valid: after Stage 6.4)
_guard_no_line_crosses_non_consumerA74.7bisection (first valid: after Stage 6.14)
_guard_station_x_column_driftA6.0bisection (first valid: start)
_guard_row_trunk_cy_consistentB12.1final-only
_guard_off_track_clear_of_anchorB3.5final-only
_guard_fanout_junction_shares_exit_port_yB0.9final-only
_guard_fanout_junction_resolves_upstreamB0.7final-only
_guard_entry_port_fed_only_by_portsB0.7final-only
_guard_flow_exit_anchored_to_carrierB3.5final-only
_guard_perp_entry_feed_not_collinearB0.7final-only
_guard_merge_port_approach_sideB5.0final-only
_guard_merge_port_outgoing_side_preservedB5.0final-only
_guard_exit_inherits_entry_bundle_orderB1.9final-only
_guard_bypass_port_no_slot_gapsB4.9final-only
_guard_partial_branch_offset_gapsB2.1final-only
_guard_row_gapsB0.5final-only
_guard_section_top_paddingB0.6final-only
_guard_terminus_icons_within_bboxB0.4final-only
_guard_inter_section_routes_in_row_bandB4.6final-only
_guard_topmost_row_top_entry_hugs_sectionB6.7final-only
_guard_off_track_output_clears_non_producerB2.4final-only
_guard_tb_exit_corner_column_orderB2.0final-only
_guard_no_split_same_line_fanout_descentsB2.4final-only
_guard_no_distinct_line_fanout_crossingB3.4final-only
_guard_no_dogleg_crosses_exempt_trunkB1.9final-only
_guard_no_stacked_elbow_grazeB7.6final-only
_guard_fanout_tail_joinB2.7final-only
_guard_perp_entry_boundary_consistentB7.4final-only
_guard_perp_exit_over_leadin_no_overdipB2.9final-only
_guard_right_entry_drop_in_when_clearB2.5final-only
_guard_inter_section_route_no_backtrackA4.7final-only
_guard_inter_section_route_no_full_width_backtrackA5.3final-only
_guard_routes_enter_sections_at_portsB61.9final-only
_guard_no_route_through_sectionA85.7final-only
_guard_inter_section_route_clears_own_section_interiorA11.3final-only
_guard_feeder_exits_section_through_sideB8.2final-only
_guard_entry_approach_from_port_sideB5.3final-only
_guard_no_opposing_line_overlapB86.1final-only
_guard_serpentine_no_backtrackA3.9final-only
_guard_no_artefactual_counter_flowB4.6final-only
_guard_inter_row_run_clearanceB3.6final-only
_guard_trunk_bands_crossing_optimalB56.9final-only
_guard_inter_section_descent_edge_clearanceB7.1final-only
_guard_fan_bundles_coincide_or_separateB14.5final-only
checktiermean usruns
check_bundle_order_preservedA34.9render chokepoint (always-on)
check_concentric_bundle_cornersA41.1render chokepoint (always-on)
check_no_collinear_distinct_linesA16.6render chokepoint (always-on)
check_intra_section_collinear_distinct_linesA103.6render chokepoint (always-on)
check_no_collinear_distinct_diagonalsA79.8render chokepoint (always-on)
check_no_same_line_parallel_descentsA5.6render chokepoint (always-on)
check_merge_branches_meet_trunkA6.9render chokepoint (always-on)
check_no_hanging_routesA430.0render chokepoint (always-on)
check_bottom_row_climb_stays_at_row_levelA2.9render chokepoint (always-on)
check_gap_channels_materializedA22.5render chokepoint (always-on)
check_trunks_declaredA1.8render chokepoint (always-on)
check_peeloff_concentricA4.2render chokepoint (always-on)
check_tb_exit_corner_preserves_column_orderB1.5via _guard_* wrapper
check_fanout_tail_joinB2.3via _guard_* wrapper
check_merge_port_approach_sideB4.6via _guard_* wrapper
check_merge_port_outgoing_side_preservedB4.6via _guard_* wrapper
check_exit_inherits_entry_bundle_orderB1.6via _guard_* wrapper
check_partial_branch_offset_gapsB1.8via _guard_* wrapper
check_no_split_same_line_fanout_descentsB2.0via _guard_* wrapper
check_no_distinct_line_fanout_crossingB2.9via _guard_* wrapper
check_no_dogleg_crosses_exempt_trunkB1.5via _guard_* wrapper
check_stacked_elbow_clearanceB7.1via _guard_* wrapper
check_perp_entry_boundary_consistentB6.9via _guard_* wrapper
check_perp_exit_over_leadin_clears_only_spanned_sectionsB2.5via _guard_* wrapper
check_right_entry_drop_in_when_clearB2.1via _guard_* wrapper
check_seam_approach_equals_departureC-test suite only (tests/test_seam_lane_x.py)
check_seam_segments_meet_at_portC-test suite only (tests/test_seam_lane_x.py)

These _guard_* functions are invoked directly at specific pipeline stages rather than through the Pass C / final dispatch, so they carry no needs / bisection_safe dispatch data - only their tier and any issue_pin / narrow_reason. Costs are not separately measured; each is a single structural pass.

guardtierrole
_guard_stations_within_bboxAAlways-on postcondition: every station centre lies within its section bbox.
_guard_no_negative_grid_columnsANo section sits at a negative grid column.
_guard_explicit_grid_directionsAExplicit-grid sections keep the LR default unless they declare a direction.
_guard_no_mixed_entry_directionsAA section’s incoming lines approach from a single side.
_guard_independent_components_disjointAIndependently-stacked components do not overlap.
_guard_no_same_row_backward_feedAA same-row inter-section edge does not run against source flow.
_guard_anchors_frozen_during_placementBContent placement leaves resolved anchors fixed.
_guard_bypass_v_flat_visibleBEvery bypass V keeps a visible horizontal run through its X.
_guard_centered_line_spread_balancedBA centered section’s weave balances about its trunk.
_guard_file_icon_no_name_labelBA file-icon station gets no separate node-name label.
_guard_interchange_bar_clears_non_membersBAn interchange bar does not cross a non-member station.
_guard_no_diagonal_strikes_horizontal_labelBNo foreign fan diagonal rakes a stacked station’s name.
_guard_no_label_overlapBNo station label overlaps another label or a marker.
_guard_no_line_crosses_file_iconBNo rendered line passes through a file/terminus icon.
_guard_no_line_strikes_labelBNo rendered line strikes through a station label.
_guard_no_wrapped_label_trunk_strikeBNo wrapped label overruns a foreign horizontal trunk.
_guard_off_track_consumer_on_trunkBAn off-track input’s straight-through consumer stays on trunk (#650).
_guard_off_track_input_column_stackBSingle-trunk off-track inputs hug their consumer column (#651).
_guard_rail_above_label_bandBA rail section reserves room above its top rail for labels.
_guard_rail_one_station_per_columnBRails place one distinct station per column.
_guard_rail_stations_seat_on_railsBRail stations seat on their lines’ fixed rails.
_guard_single_trunk_off_track_stepBSingle-trunk sections lift off-track stations by the base pitch (#580).
_guard_tall_anchor_stack_well_formedBA tall-anchor vertical stack keeps its downstream chain intact.
_guard_tb_top_entry_drop_hugs_topBA clean TB TOP-entry drop seats its first station at the top.

The consolidation pass (#922) removed the validate-only _guard_* wrappers that only raised around a check already in the always-on render chokepoint - _guard_bundle_order_preserved, _guard_concentric_bundle_corners, _guard_no_collinear_distinct_lines, _guard_no_intra_section_collinear_distinct_lines, and _guard_no_same_line_parallel_descents. Each check is the single authority and runs on every render via assert_render_curve_invariants, so the wrapper was pure duplication; test_no_registry_guard_duplicates_an_always_on_check keeps the duplication from returning.

The remaining issue-pinned guards each express a distinct geometric property (their docstrings explicitly distinguish them from one another), so rather than force-merging bodies the pass records each guard’s originating issue in issue_pin and documents its scope in narrow_reason. The two genuinely general inline guards (_guard_no_negative_grid_columns, _guard_explicit_grid_directions) carry no pin: they state a general structural property, not a special case.

A further candidate left for later is folding the collinear-distinct check family (check_no_collinear_distinct_lines, check_intra_section_collinear_distinct_lines, check_no_collinear_distinct_diagonals) into one parametrised check.