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, and the 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 as 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. Tier-B members are costlier, or they depend 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 that the cost audit confirms runs in low tens of microseconds. It also holds a handful of routed-geometry sweeps in the ~10-90 us range whose visibility matters more than their cost: the inter-section backtrack and wrap guards, of which the dearest is ~11 us, and the non-consumer-section breeze-through guards (_guard_no_route_through_section at ~86 us and _guard_no_line_crosses_non_consumer at ~75 us). A line plotted over a section or station it never touches is at least as visibly broken as a backtracking bundle, so those stay 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 at ~86 us and _guard_trunk_bands_crossing_optimal at ~57 us, are routed-geometry sweeps. The always-on check_* routing invariants in 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. The visibility of the defect does.

Tier C contains checks that run only against the test corpus. Two of them check section seams: check_seam_approach_equals_departure and check_seam_segments_meet_at_port verify that each inter-section approach uses the lane assigned by lane_x. They live in tests/test_seam_lane_x.py.

The third is check_merge_feeders_land_on_trunk, in tests/test_merge_branch_trunk_invariant.py. It requires exact contact between a feeder and its trunk. Its Tier-A counterpart allows a small tolerance, because an alternate routing handler can produce a valid map without exact contact. Every other guard and check is reachable from compute_layout or rendering.

The classification is data rather than prose:

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

The Tier-A _guard_* postconditions across both registries run on the render path through 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. That exception holds the two authoring-error guards, _guard_no_same_row_backward_feed and _guard_no_mixed_entry_directions, which 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) applies the same GuardSpec schema to the routing checks. Checks return lists rather than raising, so this is a classification registry rather than a dispatcher, and the runtime chokepoint stays assert_render_curve_invariants. The registries are unified through the shared schema and this page, not by merging the raise-against- return error protocols.

Each GuardSpec also carries issue_pin and narrow_reason. issue_pin holds the #NNN issues a guard was born from, kept as data so consolidation cannot lose the regression trail. narrow_reason says why an issue-pinned guard stays scoped to its case rather than stating 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/ and examples/topologies/ once, then times each registered guard and check against the final geometry. The mean microseconds per fixture below come from that run. Regenerate them 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_ports_clear_unanchored_box_edgesA7.9final only
_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_sideA5.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_collinear_distinct_linesA200.0render chokepoint (always-on)
check_no_same_line_parallel_descentsA5.6render chokepoint (always-on)
check_no_fused_cotravelling_linesA63.5render 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)
check_merge_feeders_land_on_trunkC-test suite only (tests/test_merge_branch_trunk_invariant.py)

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

_guard_canvas_margin_settled is the one inline guard whose tier does not describe its gating. The render’s canvas-margin move loop calls it on every render, not only under validate=True. Its input is the shortfall a move left behind, which exists nowhere else, so it cannot join the dispatched Tier-A set the render chokepoint runs.

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 retain direction unless resolution reverses it.
_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_canvas_margin_settledBNo left/top canvas margin outlives the render moves that own it (#1769).
_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_off_track_not_hubBNo off-track station has edges on both sides (#1295).
_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 through 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, and their docstrings distinguish them from one another explicitly. Rather than force-merging their 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 and _guard_explicit_grid_directions, carry no pin, because they state a general structural property rather than a special case.

The collinear-distinct overlay checks are unified as one always-on check_collinear_distinct_lines, whose scopes argument selects the inter-section, intra-section and diagonal scans. The render chokepoint runs every scope, while callers that need a subset, such as the strike-clearance probe and the per-scope unit tests, pass the scopes they want.