Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
159 commits
Select commit Hold shift + click to select a range
089ab62
fix(layout): harden deterministic convergence routing [skip ci]
pinin4fjords Aug 12, 2026
357b968
fix(layout): settle planned convergence clearances [skip ci]
pinin4fjords Aug 12, 2026
b93ddce
fix(layout): clear late planned corridor conflicts [skip ci]
pinin4fjords Aug 13, 2026
0a86572
fix(layout): defer provisional route guards to settlement [skip ci]
pinin4fjords Aug 13, 2026
1e8c28f
fix(layout): preserve planned concentric corner inputs [skip ci]
pinin4fjords Aug 13, 2026
9718596
fix(layout): protect frozen convergence lanes [skip ci]
pinin4fjords Aug 13, 2026
fdde5e3
fix(layout): preserve settled route ownership [skip ci]
pinin4fjords Aug 13, 2026
b1e34ab
fix(layout): honour immutable corridor axes [skip ci]
pinin4fjords Aug 13, 2026
9889376
fix(routing): scope merge fanout pivots by line [skip ci]
pinin4fjords Aug 13, 2026
2177d05
fix(routing): settle strict recovery geometry [skip ci]
pinin4fjords Aug 13, 2026
1a339fd
refactor(routing): simplify strict settlement flow [skip ci]
pinin4fjords Aug 13, 2026
ccbc55d
merge main routing ownership fixes [skip ci]
pinin4fjords Aug 13, 2026
f8cfd3f
settle strict seed channel ownership [skip ci]
pinin4fjords Aug 13, 2026
b5b607c
wip: narrow final peel-off repair [skip ci]
pinin4fjords Aug 13, 2026
ad9cd9e
chore: restore type and lint cleanliness [skip ci]
pinin4fjords Aug 13, 2026
b729c8f
fix(layout): give a collector merge its own column
pinin4fjords Aug 13, 2026
ab153dd
fix(routing): preserve same-system convergence corridors
pinin4fjords Aug 13, 2026
7fb4983
fix(routing): preserve planned source fan lanes
pinin4fjords Aug 13, 2026
d824317
fix(routing): separate terminal convergence lanes [skip ci]
pinin4fjords Aug 13, 2026
d990573
fix(routing): keep fan turns off continuation tracks
pinin4fjords Aug 13, 2026
fcf3436
fix(routing): settle nested exempt doglegs
pinin4fjords Aug 13, 2026
66611e9
fix(routing): settle owned entry descents
pinin4fjords Aug 13, 2026
3c898bb
fix(routing): adopt settled entry bundles atomically
pinin4fjords Aug 13, 2026
0c95185
fix(routing): keep merge feeders outside source sections [skip ci]
pinin4fjords Aug 13, 2026
f7e1093
fix(routing): preserve settled convergence member geometry [skip ci]
pinin4fjords Aug 13, 2026
faf1801
fix(layout): seat canvas doglegs before member freeze [skip ci]
pinin4fjords Aug 13, 2026
2ff3b8e
refactor(routing): centralize inter-section grid resolution [skip ci]
pinin4fjords Aug 13, 2026
6d24a0b
fix(routing): preserve separated fan source lanes [skip ci]
pinin4fjords Aug 13, 2026
aa598c3
fix(routing): preserve convergence corner metadata [skip ci]
pinin4fjords Aug 13, 2026
ade8037
fix(routing): isolate deferred dogleg settlement [skip ci]
pinin4fjords Aug 13, 2026
8591e7e
fix(routing): preserve fork frames during allocation [skip ci]
pinin4fjords Aug 13, 2026
7dc7a30
refactor(routing): share orientation-neutral geometry [skip ci]
pinin4fjords Aug 13, 2026
519090e
fix(routing): avoid spurious ledger re-routes [skip ci]
pinin4fjords Aug 13, 2026
3a2e87b
fix(routing): retain exit side through junctions [skip ci]
pinin4fjords Aug 13, 2026
0764233
test(routing): exercise live cotravelling owners [skip ci]
pinin4fjords Aug 13, 2026
ddef0aa
fix(routing): retain opposing merge trunk lanes [skip ci]
pinin4fjords Aug 13, 2026
d767fd8
fix(routing): preserve mixed fan ownership [skip ci]
pinin4fjords Aug 13, 2026
e3ae86d
fix(routing): scope inherited exit sides [skip ci]
pinin4fjords Aug 13, 2026
fc7a281
fix(render): reject folded split fan cohorts [skip ci]
pinin4fjords Aug 13, 2026
b47e9f8
fix(routing): exempt terminal landings from their own corridor band […
pinin4fjords Aug 13, 2026
a22ac94
fix(routing): freeze packed handoffs on carrier [skip ci]
pinin4fjords Aug 13, 2026
8609dba
test(layout): tighten direction predicate ratchet [skip ci]
pinin4fjords Aug 13, 2026
33190c4
fix(routing): preserve settled corner inputs [skip ci]
pinin4fjords Aug 13, 2026
db480fd
fix(routing): reseat claims after peeloff reconciliation [skip ci]
pinin4fjords Aug 13, 2026
cfd088b
fix(routing): preserve reservation ownership through settlement [skip…
pinin4fjords Aug 13, 2026
0361213
fix(layout): retain final guards with clearance demands [skip ci]
pinin4fjords Aug 13, 2026
cb958b5
fix(layout): settle route-owned column demand [skip ci]
pinin4fjords Aug 13, 2026
4c8dfd0
fix(routing): restore bypass fan peel order [skip ci]
pinin4fjords Aug 13, 2026
4bf5335
test(settlement): keep cross-axis corpus demand-bearing [skip ci]
pinin4fjords Aug 13, 2026
c85f6c2
fix(layout): retain packed placement guard coverage [skip ci]
pinin4fjords Aug 13, 2026
ea328a8
test(layout): classify BT merge-port approach [skip ci]
pinin4fjords Aug 13, 2026
6b1b063
fix(routing): preserve off-row fan turn order [skip ci]
pinin4fjords Aug 13, 2026
6fd90da
test(routing): exercise analytic lane geometry directly [skip ci]
pinin4fjords Aug 13, 2026
1c71a04
test(routing): model exit-port ancestry in dispatch facts [skip ci]
pinin4fjords Aug 13, 2026
b7dcc10
test(routing): accept coincidence ownership options [skip ci]
pinin4fjords Aug 13, 2026
d0ca85a
test(render): include all frozen seed fixtures [skip ci]
pinin4fjords Aug 13, 2026
d93ba52
fix(routing): settle convergence carrier conflicts [skip ci]
pinin4fjords Aug 13, 2026
6999d8a
refactor(routing): remove unused corner solver [skip ci]
pinin4fjords Aug 13, 2026
582206e
test(render): lock compressed split fan rejection [skip ci]
pinin4fjords Aug 13, 2026
1487acb
refactor(routing): remove vertical lane offset alias [skip ci]
pinin4fjords Aug 13, 2026
864c8de
fix(routing): prefer clear right-entry steps [skip ci]
pinin4fjords Aug 13, 2026
dd7d939
fix(routing): wrap crossing U-bypass above merge trunk [skip ci]
pinin4fjords Aug 13, 2026
fc69e64
fix(routing): plan shared single-line exit opening [skip ci]
pinin4fjords Aug 14, 2026
87de758
fix(routing): select shared-opening barrier by geometry [skip ci]
pinin4fjords Aug 14, 2026
90a38f2
refactor: tighten convergence clearance after fix for #1671 [skip ci]
pinin4fjords Aug 14, 2026
01281a5
refactor: tighten shared exit opening after fix for #1671 [skip ci]
pinin4fjords Aug 14, 2026
258d05c
test(routing): assert split fan semantics [skip ci]
pinin4fjords Aug 14, 2026
eb6976f
fix(routing): scope shared exit opening ownership [skip ci]
pinin4fjords Aug 14, 2026
db34f85
fix(routing): settle emitted member tail channels [skip ci]
pinin4fjords Aug 14, 2026
462a7bf
fix(routing): clear shared opening barrier sections [skip ci]
pinin4fjords Aug 14, 2026
190c435
fix(routing): preserve and clear shared opening trunks [skip ci]
pinin4fjords Aug 14, 2026
f11a648
fix(routing): settle shared opening member conflicts [skip ci]
pinin4fjords Aug 14, 2026
11ee449
fix(routing): settle post-convergence shared trunks [skip ci]
pinin4fjords Aug 14, 2026
ad96b40
refactor(routing): use semantic settlement ownership [skip ci]
pinin4fjords Aug 14, 2026
f527b0a
refactor: tighten routing ownership helpers [skip ci]
pinin4fjords Aug 14, 2026
d5c9c01
fix(validation): run deferred guards after settlement [skip ci]
pinin4fjords Aug 14, 2026
83f43b4
fix(routing): reconcile owned dogleg crossings [skip ci]
pinin4fjords Aug 14, 2026
2fb50d5
fix(routing): seat reconciled trunks inside their reserved corridors …
pinin4fjords Aug 14, 2026
bf442ea
test(routing): lock the shared-opening exit-turn contract [skip ci]
pinin4fjords Aug 14, 2026
65e86c8
fix(routing): run route guards on owned geometry and reconcile what t…
pinin4fjords Aug 14, 2026
a9bbcb6
test(routing): refresh generated baselines for the reconciled engine …
pinin4fjords Aug 14, 2026
432f362
fix(routing): keep movers inside their contracts after review [skip ci]
pinin4fjords Aug 14, 2026
75b6d0c
test(routing): refresh gate coverage for the upstream-tail carry [ski…
pinin4fjords Aug 14, 2026
d9f038b
chore: run the full pipeline on the reconciled strict-seed engine
pinin4fjords Aug 14, 2026
cce8925
test(executor): give the heavyweight seed fixture a load-proof budget…
pinin4fjords Aug 14, 2026
b69ac42
fix(routing): derive corner families from their own concentric frames…
pinin4fjords Aug 14, 2026
3b1793a
fix(routing): let single-line-port seams agree with their partner lan…
pinin4fjords Aug 14, 2026
569e277
chore: keep review renders out of the tree [skip ci]
pinin4fjords Aug 14, 2026
0ea8806
test(routing): refresh gate coverage for the seam snap [skip ci]
pinin4fjords Aug 14, 2026
eaaab55
chore: keep review renders out of the tree [skip ci]
pinin4fjords Aug 14, 2026
f918631
fix(offsets): hold a line's lane along its flat in-section run [skip ci]
pinin4fjords Aug 14, 2026
488ff46
test(routing): refresh gate coverage for the integrated quality fixes…
pinin4fjords Aug 14, 2026
8eccd90
fix(routing): draw a lane mismatch as a mid-seam ramp [skip ci]
pinin4fjords Aug 14, 2026
f432b64
fix(routing): keep the seam ramp to lone lines [skip ci]
pinin4fjords Aug 14, 2026
2b8fb46
chore: run the full pipeline on the quality-reconciled engine
pinin4fjords Aug 14, 2026
345e692
fix(routing): move a seam port with its whole station row
pinin4fjords Aug 14, 2026
b2d5667
fix(offsets): keep a settled port frame from stranding an empty lane
pinin4fjords Aug 14, 2026
2dfffbc
test(routing): identify nested corner families by geometry alone
pinin4fjords Aug 14, 2026
41ccda8
fix(routing): size a landing corner family by its arrival, not its node
pinin4fjords Aug 14, 2026
ba1d210
test(routing): refresh gate coverage for the landing corner cohort
pinin4fjords Aug 14, 2026
836a9c1
fix(routing): read corner families only from routes stating every radius
pinin4fjords Aug 14, 2026
01b698b
fix(ordering): keep an unbranched in-section chain on one track
pinin4fjords Aug 14, 2026
353cfc9
fix(routing): replay a planned opening drop at emission
pinin4fjords Aug 14, 2026
a31d26f
fix(routing): state a convergence continuation's handover point
pinin4fjords Aug 14, 2026
37efb52
fix(routing): compare trunk corridors by carrier, over plan flanks too
pinin4fjords Aug 14, 2026
0349ed2
fix(routing): keep a destination-tail band off an occupied corridor lane
pinin4fjords Aug 14, 2026
058a54c
fix(offsets): re-slot a station's own line off a carried bundle's lane
pinin4fjords Aug 14, 2026
fe8a4e1
fix(reservations): hold a canvas corridor off the content it runs beside
pinin4fjords Aug 14, 2026
f937b66
test(routing): refresh gate coverage for the corridor and lane fixes
pinin4fjords Aug 14, 2026
187f846
test(guards): refresh the guard golden for the reflowed funcprofiler …
pinin4fjords Aug 14, 2026
b9b05ad
fix(reservations): publish no content band where no box bounds the co…
pinin4fjords Aug 15, 2026
8478acc
test(layout): measure the station grid on its own row's slot pitch
pinin4fjords Aug 15, 2026
8894bdd
refactor(routing): name the shared pieces the corridor and lane fixes…
pinin4fjords Aug 15, 2026
dd84728
test(routing): refresh gate coverage for the combined quality tree
pinin4fjords Aug 15, 2026
f31bad9
fix(routing): offer a translating bundle every claim-clearing lane
pinin4fjords Aug 15, 2026
8d0d0ef
fix(routing): read a gap fan's approach weave per leg
pinin4fjords Aug 15, 2026
4115a02
test(routing): refresh gate coverage for the per-leg fan weave
pinin4fjords Aug 15, 2026
c7a6987
fix(routing): snap a partly-continuing junction bundle onto its entry…
pinin4fjords Aug 15, 2026
8392b01
fix(routing): refresh a divergence junction after its exit port recom…
pinin4fjords Aug 15, 2026
ef525a5
fix(routing): re-seat a fan-out source bundle on its peel order
pinin4fjords Aug 15, 2026
9726aa1
test(routing): hold seam stubs level and approaches on the port lane
pinin4fjords Aug 15, 2026
9f0e7f4
test(layout): hold every topology to the almost-horizontal check
pinin4fjords Aug 15, 2026
fe489e7
test(routing): refresh gate coverage for the seam-lane tree
pinin4fjords Aug 15, 2026
155e381
test(routing): refresh gate coverage for the seam quality fixes
pinin4fjords Aug 15, 2026
e3ed0b8
test(layout): lock the carrier-anchored exit contract on a branching …
pinin4fjords Aug 15, 2026
7804391
fix(routing): seat a separated trunk lane in a corridor, not in a box
pinin4fjords Aug 15, 2026
10ad8a2
test(routing): lock a long trunk reach to its inter-row corridor
pinin4fjords Aug 15, 2026
95f12c5
test(routing): refresh gate coverage for the corridor-seated trunk fix
pinin4fjords Aug 15, 2026
4282aa3
fix(routing): seat convergence landings by their lane runs
pinin4fjords Aug 15, 2026
8de875b
fix(routing): separate distinct plans that share trunk carriers
pinin4fjords Aug 15, 2026
1089186
fix(routing): keep the unweave riser off plan-owned and overlaid columns
pinin4fjords Aug 15, 2026
5bcb920
test(routing): lock the resolved seed_15 column classes
pinin4fjords Aug 15, 2026
aa040bd
test(routing): refresh gate coverage for the lane-run landing fixes
pinin4fjords Aug 15, 2026
a1449fa
test(routing): lock the leftward upturn lane order on a settlement fi…
pinin4fjords Aug 15, 2026
3033090
fix(offsets): let a seam snap pass a line that only leaves the statio…
pinin4fjords Aug 15, 2026
a655d30
fix(routing): launch a fan L-shape from the side its turn column land…
pinin4fjords Aug 15, 2026
bad5031
test(routing): refresh gate coverage for the fan lead-in and lane-sid…
pinin4fjords Aug 15, 2026
1ca2f51
test(offsets): lock the level s14 seam the sequential-lane share buys
pinin4fjords Aug 15, 2026
e933f88
test(routing): refresh gate coverage for the seed_77 residual fixes
pinin4fjords Aug 15, 2026
8812d1c
fix(offsets): re-inherit a divergence junction after its exit port re…
pinin4fjords Aug 15, 2026
44175a8
fix(planning): keep an allocated path off a seam it lands transposed on
pinin4fjords Aug 15, 2026
1b59491
test(routing): refresh gate coverage for the seam lane fixes
pinin4fjords Aug 15, 2026
4cdc932
test(routing): refresh gate coverage for the seam order fixes
pinin4fjords Aug 15, 2026
00032c6
fix(reservations): state no realisation where no box bounds the corridor
pinin4fjords Aug 15, 2026
577420a
fix(settlement): hold open only the separations the grid orders
pinin4fjords Aug 15, 2026
e4d17c4
fix(settlement): keep the separation of boxes that share a grid index
pinin4fjords Aug 15, 2026
faa2750
fix(routing): hold an unweave riser on the column its plan names
pinin4fjords Aug 15, 2026
c056027
test(routing): read the seed_15 locks from its rendered geometry
pinin4fjords Aug 15, 2026
90b8681
test(routing): re-derive the trunk and entry-bundle locks from the se…
pinin4fjords Aug 15, 2026
53fe375
fix(routing): stack frame-mates in their planned lane order
pinin4fjords Aug 15, 2026
c83dc2c
test(routing): lock the reserved corridor bands over the seed_15 render
pinin4fjords Aug 15, 2026
da256c3
refactor(routing): read the fan lead-in direction once from the clear…
pinin4fjords Aug 15, 2026
a7c3216
fix(routing): descend a shared left-exit opening on the side it leave…
pinin4fjords Aug 15, 2026
71d1053
test(routing): refresh gate coverage for the exit-opening descent
pinin4fjords Aug 15, 2026
b7966ff
fix(routing): join a shared exit opening to the lane stack it descend…
pinin4fjords Aug 15, 2026
3dcf2b8
test(routing): re-derive the seed_15 exit-opening locks from its sett…
pinin4fjords Aug 15, 2026
62d149e
fix(routing): preserve frozen corridor lane order [skip ci]
pinin4fjords Aug 17, 2026
76abd3f
feat(routing): add atomic corridor cohort allocator [skip ci]
pinin4fjords Aug 17, 2026
3f11f17
wip(routing): preserve corridor planning research checkpoint [skip ci]
pinin4fjords Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,744 changes: 975 additions & 769 deletions docs/dev/routing_gate_coverage.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/build_gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ def render_test_fixtures() -> None:
mmd_path = TEST_FIXTURES_DIR / f"{stem}.mmd"
if not mmd_path.exists():
continue
svg_path = RENDERS_DIR / f"{stem}.svg"
svg_path = RENDERS_DIR / f"{Path(stem).name}.svg"
if _skip_render(mmd_path):
_manifest[svg_path.name] = section
continue
Expand Down
4 changes: 4 additions & 0 deletions scripts/gallery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2187,6 +2187,10 @@ render_only:
- { id: variant_calling_tuned, output: nf_variant_calling_tuned_icons }
# Stems from tests/fixtures/ rendered into the "Test Fixtures" render-diff section.
test_fixtures:
- hash_seed_determinism/seed_15
- hash_seed_determinism/seed_41
- hash_seed_determinism/seed_72
- hash_seed_determinism/seed_77
- multiline_labels
- rnaseq_simple
- genomeassembly_organellar
Expand Down
15 changes: 9 additions & 6 deletions scripts/routing_gate_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,22 @@ def _collect_corpus() -> list[tuple[Path, bool]]:

Mirrors ``tests/conftest.py``'s ``content_corpus``: the ``examples/`` tree
(``rglob`` already subsumes ``examples/topologies`` and ``examples/guide``),
the loose ``tests/fixtures/`` fixtures, and the Nextflow-DAG fixtures under
``tests/fixtures/nextflow/`` (which need ``convert_nextflow_dag`` before
parsing). Widening past the original ``examples/``-only scope lets the test
fixtures retire gate arms the gallery never reaches. Unlike
``content_corpus`` this keeps the ``rails`` fixtures -- their rail router is
a routing path the matrix should measure, not skip.
the loose ``tests/fixtures/`` fixtures, the stable hash-seed regression
fixtures, and the Nextflow-DAG fixtures under ``tests/fixtures/nextflow/``
(which need ``convert_nextflow_dag`` before parsing). Widening past the
original ``examples/``-only scope lets the test fixtures retire gate arms the
gallery never reaches. Unlike ``content_corpus`` this keeps the ``rails``
fixtures -- their rail router is a routing path the matrix should measure,
not skip.
"""
examples = PROJECT_ROOT / "examples"
fixtures = PROJECT_ROOT / "tests" / "fixtures"
hash_seed_determinism = fixtures / "hash_seed_determinism"
nextflow = fixtures / "nextflow"
sources: list[tuple[list[Path], bool]] = [
(sorted(examples.rglob("*.mmd")), False),
(sorted(fixtures.glob("*.mmd")), False),
(sorted(hash_seed_determinism.glob("*.mmd")), False),
(sorted(nextflow.glob("*.mmd")), True),
]
candidates = [(p, is_nextflow) for paths, is_nextflow in sources for p in paths]
Expand Down
30 changes: 1 addition & 29 deletions src/nf_metro/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@
metrics_face_context,
metrics_face_for_portability,
)
from nf_metro.themes import DEFAULT_MODE, THEME_MODES, THEMES

# `style: dark` predates theme names; alias it onto the nfcore brand.
_STYLE_THEME_ALIASES = {"dark": "nfcore"}
from nf_metro.themes import resolve_theme


@dataclass
Expand Down Expand Up @@ -103,31 +100,6 @@ def apply_layout_overrides(graph: MetroGraph, opts: Mapping[str, object]) -> Non
graph.layout_provenance.record_caller_line_order(value)


def resolve_theme(
theme: str | None, graph: MetroGraph, mode: str | None = None
) -> Theme:
"""Resolve a concrete theme from independent brand and mode axes.

Brand comes from the explicit ``theme`` name (``--theme``) or the
``%%metro style:`` directive (``dark`` aliases to ``nfcore``). Mode comes
from the explicit ``mode`` argument (``--mode``), then the ``%%metro mode:``
directive, then ``DEFAULT_MODE``. No brand carries its own mode: a known
brand always resolves through its light/dark family for the chosen mode.
"""
if theme is not None:
brand = theme
else:
name = graph.style.strip().lower()
brand = _STYLE_THEME_ALIASES.get(name, name)

resolved_mode = (mode or graph.mode).strip().lower() or DEFAULT_MODE
family = THEME_MODES.get(brand)
if family and resolved_mode in family:
return family[resolved_mode]

return THEMES.get(brand, THEMES["nfcore"])


@dataclass(frozen=True)
class RenderResult:
"""Rendered content and the immutable plan used to create it."""
Expand Down
6 changes: 5 additions & 1 deletion src/nf_metro/candidate_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,11 @@ def collect(stage: CandidateStage) -> bool:
try:
begin(CandidateStage.ROUTE_PLAN)
offsets = compute_station_offsets(graph)
observation = observe_route_edges(graph, station_offsets=offsets)
observation = observe_route_edges(
graph,
station_offsets=offsets,
allow_convergence_clearance_requirements=True,
)
build_route_plan_query(observation.plan)
route_evidence = _route_evidence(observation.plan)
route_findings = observation.plan.diagnostics
Expand Down
7 changes: 1 addition & 6 deletions src/nf_metro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,12 +759,7 @@ def validate(input_file: Path, with_layout: bool, strict: bool) -> None:
if with_layout:
try:
compute_layout(graph, validate=True)
except (
CyclicGraphError,
BackwardFlowError,
MixedEntryDirectionError,
PhaseInvariantError,
) as e:
except (ValueError, FoldThresholdError, PhaseInvariantError) as e:
issues.append(ValidationIssue(ERROR, str(e)))

issues.extend(ValidationIssue(WARNING, str(w.message)) for w in caught)
Expand Down
26 changes: 22 additions & 4 deletions src/nf_metro/layout/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ frames armed on every fixture. That count is a function of how many systems
make ownership claims, so a tree that plans more systems shows more mid-pass
disagreement without its geometry being worse; it is not a quality metric.

If the final observation requires render-time envelope settlement, its
route-dependent guards defer within the layout engine. Before a successful
`compute_layout(validate=True)` returns, the engine builds the settled render
plan and runs those guards against the geometry that would be drawn. This is a
property of the validated layout API, not an extra obligation placed on CLI or
embedding callers.

## Anchor invariant

The **anchors** of a section are its port stations: synthetic points on the
Expand Down Expand Up @@ -1584,15 +1591,26 @@ They are design evidence, not part of this specification.
interchange idiom rather than the declared section gap, and widening one of
their boundaries to that gap turns a flat inter-row run into a staircase --
a decision change, which `_assert_settlement_decisions_frozen` refuses.
- **Clearance grants form a fixed prerequisite DAG, not a retry loop.** Render
discovery can publish `GENERAL` convergence requirements. Those are settled
first because their translation can change carrier orientation, making any
corridor-cohort ledger captured beforehand invalid. One re-observation then
builds corridor-cohort intent from the settled convergence geometry. It can
publish at most one `CORRIDOR_COHORT_APERTURE` requirement set, which is
settled once before the final strict replay and freeze. Each transition is
validated against its own requirement kind; neither stage iterates or accepts
requirements owned by the other.
- **The two demands can cover either axis.** The reservation ledger is settled
on both: `_settle_axis` runs once per axis and every row-gap and column-gap
claim is measured. Generic box-growth clearance remains row-only because
`measure_row_gap_clearance` represents `push_lower_rows_after_bbox_grow`.
Column demands are route-published and narrowly scoped: a
`BoundaryClearanceRequirement` grants runway only for a provisional
`NO_APPROACH_SETTLEMENT_ROOM` convergence, while a drawn-corridor demand pays
positive-side containment measured from the strict post-grant route. Label
wrapping does not independently create a column demand.
A `GENERAL` `BoundaryClearanceRequirement` grants runway only for a
provisional `NO_APPROACH_SETTLEMENT_ROOM` convergence. A
`CORRIDOR_COHORT_APERTURE` requirement grants only the typed, solver-measured
deficit of an otherwise atomic cohort allocation. A drawn-corridor demand
pays positive-side containment measured from the strict post-grant route.
Label wrapping does not independently create a column demand.
- **Precondition**: `compute_layout` has finished, routing has published the
reservation ledger, render-time label wrapping has taken its bbox growth, and
the header-collision reconcile has run. Local station geometry, section bbox
Expand Down
8 changes: 8 additions & 0 deletions src/nf_metro/layout/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ def compute_layout(

require_resolved_edge_endpoints(graph)
require_resolved_port_sections(graph)
graph._final_route_guards_deferred = False

if x_spacing is None:
x_spacing = graph.x_spacing
Expand Down Expand Up @@ -571,6 +572,13 @@ def _layout_pass(validate_pass: bool) -> None:
elif validate and graph._after_final_deferred:
_run_after_final_checkpoint(graph, section_y_gap, section_y_padding)

if validate and graph._final_route_guards_deferred:
from nf_metro.render.svg import build_render_plan
from nf_metro.themes import resolve_theme

build_render_plan(graph, resolve_theme(None, graph))
graph._final_route_guards_deferred = False


def _compute_layout_scaled(
graph: MetroGraph,
Expand Down
82 changes: 71 additions & 11 deletions src/nf_metro/layout/envelope_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@
Re-routing the settled geometry can produce a *different* ledger -- corridors
appear, vanish, and change their required width -- so this function never
iterates over successive ledgers. Each invocation settles exactly the ledger
it was handed. The renderer has one bounded exception around a provisional
convergence-clearance grant: it performs a strict fresh observation after the
grant, measures that observation's drawn containment once, and settles that
final ledger before a consuming re-route. No clearance requirement may be
published by either strict observation.
it was handed. The renderer has one bounded exception around strict drawn
geometry. After a provisional convergence-clearance grant, or when the
consuming re-route places a drawn corridor past its frozen edge, it freezes the
strict observation's measured containment and settles that final demand before
one consuming re-route. No clearance requirement may be published by the final
strict observation.

Every row- and column-gap claim this stage is handed is therefore allocatable:
the measurement bounds a boundary by the sections that lie wholly on each side
Expand Down Expand Up @@ -94,6 +95,7 @@
from nf_metro.layout.geometry import measured_distance, shift_section
from nf_metro.layout.phases.guards import PhaseInvariantError
from nf_metro.layout.route_plan import (
ConvergenceDisposition,
DemandAxis,
EmissionMemberId,
RoutePlan,
Expand All @@ -114,6 +116,7 @@
from nf_metro.layout.settlement_demand import (
BoundaryClearanceDemand,
BoundaryClearanceRequirement,
BoundaryClearanceRequirementKind,
ClearanceMeasurement,
SettlementAxis,
)
Expand All @@ -124,6 +127,7 @@
"ROW_AXIS",
"BoundaryClearanceDemand",
"BoundaryClearanceRequirement",
"BoundaryClearanceRequirementKind",
"ClearanceMeasurement",
"DrawnCorridorClearanceRequirement",
"EnvelopeSettlement",
Expand Down Expand Up @@ -190,6 +194,14 @@ def drawn_corridor_clearance_requirements(
) -> tuple[DrawnCorridorClearanceRequirement, ...]:
"""Freeze widths owed by strict routes drawn past a corridor edge."""
requirements: list[DrawnCorridorClearanceRequirement] = []
fixed_member_ids = {
member_id
for convergence in (
plan.convergence_plans if isinstance(plan, RoutePlan) else ()
)
if convergence.disposition is ConvergenceDisposition.PLANNED
for member_id in convergence.member_ids
}
for reservation in plan.reservations:
region = reservation.region
if not isinstance(region, RowGapRegion | ColumnGapRegion):
Expand All @@ -200,12 +212,21 @@ def drawn_corridor_clearance_requirements(
containment = drawn_corridor_containment(
reservation, realised, route_polylines, reservation.claims
)
deficit = -containment.positive_side_slack
negative_deficit = -containment.negative_side_slack
positive_deficit = -containment.positive_side_slack
deficit = max(negative_deficit, positive_deficit)
if deficit <= COORD_TOLERANCE:
continue
recentres = not reservation.claims or any(
claim.member_id not in fixed_member_ids for claim in reservation.claims
)
# Boundary settlement moves the positive side. A fixed run past the
# negative edge needs matching room on the positive edge to recenter.
recentres = recentres or negative_deficit >= positive_deficit
requirements.append(
DrawnCorridorClearanceRequirement(
reservation, realised.available_width + deficit
reservation,
realised.available_width + deficit * (2 if recentres else 1),
)
)
return tuple(requirements)
Expand Down Expand Up @@ -455,11 +476,32 @@ def _reservation_coordinate_translation(
coordinates at or beyond the band's start move.
"""
section_ids = frozenset(translation.section_ids)
endpoint_section_by_group = {
group.id: group.section_id for group in plan.endpoint_groups
}
hidden_endpoint_sections = {
divergence.junction_id: endpoint_section_by_group[divergence.exit_group_id]
for divergence in plan.divergences
}
hidden_endpoint_sections.update(
{
convergence.junction_id: endpoint_section_by_group[
convergence.entry_group_id
]
for convergence in plan.convergences
}
)
fully_owned: list[EmissionMemberId] = []
crossing: list[EmissionMemberId] = []
for member in plan.members:
source_owned = member.source.section_id in section_ids
target_owned = member.target.section_id in section_ids
source_section = member.source.section_id or hidden_endpoint_sections.get(
member.source.station_id
)
target_section = member.target.section_id or hidden_endpoint_sections.get(
member.target.station_id
)
source_owned = source_section in section_ids
target_owned = target_section in section_ids
if source_owned and target_owned:
fully_owned.append(member.id)
elif source_owned != target_owned:
Expand Down Expand Up @@ -800,6 +842,13 @@ def _box_extents(section: Section) -> tuple[tuple[float, float], tuple[float, fl
)


def _drawn_against_the_grid(
axis: SettlementAxisGeometry, ahead: Section, behind: Section
) -> bool:
"""*ahead* is drawn before *behind* while indexed strictly after it."""
return axis.start_index(behind) + axis.span(behind) <= axis.start_index(ahead)


def _axis_gaps(
graph: MetroGraph, axis: SettlementAxisGeometry
) -> dict[tuple[str, str], float]:
Expand All @@ -809,6 +858,13 @@ def _axis_gaps(
translation. Boxes that do not overlap across the axis never face each
other, so the distance between them is not a separation this stage owes
anything to.

Neither does a pair drawn in the reverse of the grid order it is filed
under. A widening carries the bands from its boundary onward and leaves
the rest, so it opens the distance between two boxes where that distance
runs the way their indices do, and closes it where it runs against them.
Boxes sharing an index move together under every translation, so their
separation is held and stays comparable.
"""
along_index = 1 if axis.axis is SettlementAxis.ROW else 0
across_index = 1 - along_index
Expand All @@ -826,9 +882,13 @@ def _axis_gaps(
continue
first_start, first_end = first_extents[along_index]
second_start, second_end = second_extents[along_index]
if first_end <= second_start:
if first_end <= second_start and not _drawn_against_the_grid(
axis, first, second
):
gaps[first_key, second_key] = second_start - first_end
elif second_end <= first_start:
elif second_end <= first_start and not _drawn_against_the_grid(
axis, second, first
):
gaps[second_key, first_key] = first_start - second_end
return gaps

Expand Down
10 changes: 10 additions & 0 deletions src/nf_metro/layout/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
_Box = tuple[float, float, float, float]


def section_bbox_edges(section: Section) -> _Box:
"""Return ``(left, top, right, bottom)`` for one section box."""
return (
section.bbox_x,
section.bbox_y,
section.bbox_x + section.bbox_w,
section.bbox_y + section.bbox_h,
)


def quantize_coord(value: float, ndigits: int) -> float:
"""Round *value* to *ndigits* decimal places for use as a grouping key.

Expand Down
23 changes: 21 additions & 2 deletions src/nf_metro/layout/ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,29 @@ def _place_single_node(
if node in continuation_nodes:
return pred_avg

# Sole-successor chain: every drawn stroke into this node comes from its
# single predecessor, and that predecessor feeds nothing else, so the two
# form one unbranched run that reads as a straight track. Lines that
# merely begin or end at either station add no incoming geometry, so a
# changed line set alone is no reason to hop tracks. Falls through when
# the predecessor's track is already taken at this layer.
preds = list(G.predecessors(node))
node_layer = layers.get(node, 0) if layers else 0
if (
len(preds) == 1
and preds[0] in tracks
and list(G.successors(preds[0])) == [node]
and not _is_track_occupied_at_layer(
tracks[preds[0]],
node_layer,
layer_occupancy if layer_occupancy is not None else {},
node,
)
):
return tracks[preds[0]]

# Detect divergence: predecessor has more lines than this node
if graph is not None:
preds = list(G.predecessors(node))
node_layer = layers.get(node, 0) if layers else 0
node_lines = set(graph.station_lines(node))
pred_lines: set[str] = set()
for p in preds:
Expand Down
Loading