Skip to content

How nf-metro is built

nf-metro is a layout engine, a rendering pipeline, and a test harness. Four validation layers guard the output, a CI render diff carries the visual review, and every check that lands stays in. Read this before working on the codebase.

The hard part of turning a text description of a pipeline graph into a metro-map SVG is that “metro map” is an aesthetic standard rather than a mathematical one. This is not graph layout in the academic sense. The engine uses no force-directed placement and no crossing minimization. It is a set of constraints that a human recognizes as “right”. Stations sit on straight track, never on corners. Bundles of lines sweep round curves together. Sections do not overlap, and labels do not collide with routes. None of those constraints come for free.

The engine solves this through a sequence of ~40 numbered phases, each responsible for a specific piece of the coordinate assignment problem. The phases run in strict order, and each one reads the state left by the previous one and writes into it. src/nf_metro/layout/CONTRACT.md records each phase contract: what it requires on entry and what it guarantees on exit.

The phases share mutable state and everything depends on everything else. Moving a station for label clearance can compress a diagonal to an ugly angle, and widening a section can push a neighboring section out of its column. You can therefore only verify correctness at the end of the pipeline, never from reading a single phase.

The codebase has four validation layers, and each one catches bugs the others cannot see. The Testing docs describe them in detail:

LayerWhat it checksWhen
Layout oracleGraph geometry after layoutEvery topology test
Routing invariantsEdge waypoints as each route is computedAlways-on
Phase guardsPre/post-conditions at each phase boundaryAlways-on
Render oracleFinished SVG geometryOpt-in CLI flag, corpus gate

One design principle runs through all four layers. Checks only accumulate, and the bar only moves up. Once a check is in, it stays in. A regression is a red build rather than something a human notices later.

Known bugs follow a related pattern. Write the test for the correct behavior, then mark it xfail. The build stays green while the bug is present. When someone fixes it, the test flips to XPASS and CI turns red, which forces the developer to retire the old expectation and lock in the correct behavior. Here is an example of that pattern in tests/test_layout_invariants.py.

The strongest checks eliminate a category of mistake rather than catching one instance of it. Two examples:

Station-as-elbow. Metro maps do not put stations on corners. Stations sit on straight track, with the curves in between. Early versions of the engine used station nodes as inflection points because it was convenient. After correcting this many times, the layout oracle gained check_station_as_elbow, which fires as an ERROR on any fixture where a station sits at a bend. The mistake is still possible to write, but it fails CI immediately.

Concentric bundle corners. For a long time every bend in the routing had its radius computed by hand where the curve was drawn, with direction, sign, and magnitude all chosen locally. Get the sign wrong and the bundle fans apart or crosses itself. The fix pulled all of it into one place. A route now describes the centerline it wants to follow, and a single builder fans the individual lines out as parallel offsets, deriving every corner from the geometry. Nobody writes a radius by hand any more, and no code remains where a pinching or crossing bug could be written.

Most layout bugs only appear in specific topologies. That is what makes a layout engine hard to test. A suite that exercises one shape of graph passes indefinitely while the engine fails on every other shape.

examples/topologies/ answers that with a library of ~285 .mmd fixtures, each isolating a specific graph topology such as a fan-out, fan-in, diamond, fold, mixed port sides, or cross-column entries. tests/test_topology_validation.py parametrizes over every fixture in that directory and runs the full layout oracle against each one. Adding a .mmd to that directory enrolls it in the suite with no further wiring.

When a new topology case produces bad output, the workflow is:

  1. Write a minimal .mmd that reproduces it and drop it in examples/topologies/.
  2. Confirm the topology test now fails against the oracle.
  3. Fix the engine so the oracle passes.
  4. Leave the fixture in place as a regression guard.

The topology library also feeds the visual review CI described later.

Automated geometry checks verify that coordinates are correct. They cannot verify that the result looks right. A rendered diff in CI covers that.

Every pull request triggers .github/workflows/pr-renders.yml, which:

  1. Renders the full gallery on the PR branch.
  2. Checks out the base branch and renders the same gallery.
  3. Runs scripts/build_render_diff.py to build a side-by-side before/after page for every SVG that changed.
  4. Publishes the result at https://seqeralabs.github.io/nf-metro/_pr/<PR_NUMBER>/.

Scroll through the before/afters, look for anything that regressed, then file an issue or iterate on the fix. Some bugs only show up this way, because a layout that passes every geometric check can still look wrong to a human eye. A fan of routes that comes out lopsided, or a label that clips against a route, passes the oracle and still needs fixing. The render diff puts that judgment back in the loop.

The script exits 2 when the render shows no visual difference. A PR that intends to be neutral should therefore produce a byte-identical gallery. Layout changes are expected to differ. Refactors are expected to be byte-identical.

Phase guards fall into three tiers:

  • Tier A (always-on): structural invariants that must hold in all valid graphs. A failure here is a hard bug.
  • Tier B (defensive): guards that protect against known fragile transitions. Enabled but annotated.
  • Issue-pinned: guards that track a known defect against the corpus, marked xfail. When the issue is fixed, the test flips to XPASS and prompts removal of the pin.

The full tier taxonomy and rationale are in docs/dev/guard_tiers.md.

The routing module classifies each inter-section member once during planning. The selected family builds a frozen production path, and emission copies that path without trying another handler. Each family covers one combination of section orientation, entry and exit direction, and flow type. The family table and full inventory are documented in docs/dev/inter_section_dispatch.mdx and docs/dev/routing_gate_coverage.md.

To add a new routing case:

  1. Write a topology fixture that exercises the new case.
  2. Confirm no current family classifies it.
  3. Add a handler, or extend an existing one.
  4. Add a gate coverage entry so the new arm shows up in the coverage matrix.

The internals docs cover each subsystem in detail:

src/nf_metro/layout/CONTRACT.md is the per-phase lifecycle specification: what each phase receives, what it modifies, and what it guarantees.