Skip to content

feat(aspect): target output aspect ratio (#662) - #666

Draft
pinin4fjords wants to merge 851 commits into
mainfrom
fix/662-aspect-ratio
Draft

feat(aspect): target output aspect ratio (#662)#666
pinin4fjords wants to merge 851 commits into
mainfrom
fix/662-aspect-ratio

Conversation

@pinin4fjords

@pinin4fjords pinin4fjords commented Jun 12, 2026

Copy link
Copy Markdown
Member

Paused (draft). The feature works, but its "tall" direction pushes the layout engine into a regime it does not yet handle well (low fold_threshold, TB-reoriented sections, serpentine grids). Blocked on layout-engine quality, tracked with readiness criteria in #662 (#670 / #671 / #672 / #664). Un-pause when those hold.

Summary

Adds an aspect-ratio target so a map can be steered thin-and-wide or tall-and-narrow without hand-tuning fold_threshold.

  • New %%metro aspect: <ratio> directive and --aspect <ratio> CLI flag (width / height, e.g. 4 wide, 0.5 tall).
  • When set, the renderer searches the discrete set of fold-driven layouts and renders the one whose shape is closest to the target, judged in log space so "too wide" and "too tall" are penalised symmetrically. It reports the choice, e.g. Aspect 4 requested, 3.53 achieved (2135x605, fold_threshold=16).
  • A metro map's aspect ratio is emergent from section topology, so an arbitrary ratio is not always reachable. The search picks the closest achievable shape. For topologies whose layout is driven by convergence/tall-anchor placement (not folding), an author-pinned grid, or a single section, the shape cannot be reshaped at all; the flag is reported as not honourable and the map renders as-is rather than silently no-op'ing.
  • Precedence: an explicit fold_threshold (directive or --fold-threshold) suppresses the search entirely.

Mechanism

  • nf_metro.aspect.solve_aspect is a pure search over candidate fold thresholds against an injected measure(fold) -> (w, h) callable; it dedups layouts that render to identical sizes and returns adjustable=False when fewer than two distinct shapes exist.
  • auto_layout.candidate_fold_thresholds enumerates the fold values worth trying: the cumulative topo-column widths, plus 1 (maximum fold), bracketing most-folded to unfolded.
  • The CLI measures each candidate by rendering and reading the SVG root dimensions. The winning fold is rebuilt fresh for the final output because render_svg mutates the graph, so a measured graph cannot be reused for the final render.

Fixes #662

Test plan

  • pytest passes, including tests/test_aspect.py (pure-search behaviour, candidate enumeration, directive parse, and end-to-end wide-beats-tall / explicit-fold-wins / fixed-topology cases)
  • ruff format --check + ruff check + mypy clean on the whole repo
  • Gallery renders byte-identical to main (feature is inert unless aspect is set) - verified locally against origin/main
  • Visual review of render preview - expected verdict: no visual changes
  • No runtime _guard_* added: this is a CLI-level selection wrapper, not a layout-geometry invariant that could silently regress (the chosen fold is guaranteed to be a measured candidate by construction).

🤖 Generated with Claude Code

pinin4fjords and others added 30 commits May 20, 2026 13:17
Closes #397.

* ``l_shape_radii`` parameter ``going_down: bool`` becomes
  ``vertical: Direction``.
* ``bypass_radii`` parameters ``going_right: bool`` /
  ``gap1_going_down: bool`` / ``gap2_going_down: bool`` become
  ``horizontal: Direction`` / ``gap1_vertical: Direction`` /
  ``gap2_vertical: Direction``.
* core.py call sites drop the ``going_down = vertical is Direction.D``
  adapter aliases; the typed values flow straight through.
* Inside ``bypass_radii`` a local ``going_right`` boolean is
  retained for the index-mirroring arithmetic (used twice; inlining
  twice would be chattier).
* tests/test_corners.py parametrize labels updated to match.

No behaviour change; gallery render diff vs ``main`` remains empty.
refactor(routing): switch corner-radii helpers from bool to Direction
The descriptor scaffolding in ``inter_section.py`` documented a
hypothetical future section-DAG propagator that would propagate
``Section.flip_lines`` along the DAG and feed WRAP_TABLE.  Neither
exists in the codebase: ``_propagate_wrap_flip_parity`` was rolled
back in #387 before merge, and ``Section.flip_lines`` had its
single reader removed in the same rollback.

The propagator's job - keep bundle ordering consistent across
complex wraps - is handled by per-corner offset propagation inside
the wrap-route handlers (handedness-aware radii at each turn).
The runtime ``check_bundle_order_preserved`` invariant catches
any regression.  No third layer of defense is needed.

* ``inter_section.py`` module + ``TurnSequence.parity`` docstrings:
  drop "future propagator" / ``_propagate_wrap_flip_parity`` /
  ``Section.flip_lines`` references; describe ``WRAP_TABLE`` as a
  documentation catalogue.
* ``test_inter_section_descriptor.py``: drop
  ``test_wrap_table_parity_matches_propagator_contribution`` -
  it tested a contract no consumer enforces.
* ``parser/model.py``: delete the unused ``Section.flip_lines``
  field.

Tracked by closing issues #393 (parity drift) and #394 (wire
WRAP_TABLE into dispatcher).

No behaviour change; gallery render diff vs ``main`` remains empty.
chore: retire propagator references; drop unused Section.flip_lines
Aggregate fixes to the inter-section routing geometry, motivated by
visible artefacts in sarek's wraps (preprocessing -> variant_calling,
variant_calling -> post_vc, preprocessing -> reporting).  Result:
every bundled route preserves its per-line ordering with no
crossovers at any corner.

* Cross-row LEFT/RIGHT-entry wraps (``_route_left_entry_wrap`` /
  ``_route_right_entry_wrap``):

  - C1 lead-in pushed past the source section's right edge so the V1
    column gets visible breathing room from the bbox.
  - Handedness-aware corner radii: CW corners (C1, C2) use the
    outside-of-turn radius; CCW corners (C3, C4) use the
    inside-of-turn radius.  Previously all four used the same
    outside-of-turn r, which the renderer's segment-budget clamp
    silently truncated at C3/C4 - producing the visible
    "outer-line collapse" the user reported.
  - Natural offset propagation through the four corners with the
    outer bundle line consistently on the outer side of travel.

* ``_route_around_section_below``: new handler for the case where an
  L-shape's horizontal segment would cut through an intervening
  section.  Routes down past the target row, leftward under the
  target section, up the inter-column gap, then right into the LEFT
  entry from below.  Fires from ``_route_inter_section`` after the
  L-shape selection when an intervening-section intersection test
  reports a cross.

* Merge trunk: lands at the entry port Y instead of the
  ``__merge_X`` junction Y so same-row bypasses don't leave a
  "hanging" disconnected stub.

* ``SECTION_ROUTE_CLEARANCE`` (16 px) applied to wrap V1, wrap V2,
  and around-route V_up channels so the closest line in each bundle
  keeps clear of section bbox edges.

* ``ICON_TERMINUS_FORK_LEAD``: short horizontal run added at fork
  points adjacent to terminus file icons, so the fork starts past
  the icon rather than inside it.

Conditional-firing of the new mechanisms, to avoid perturbing
fixtures the wrap geometry was never meant to touch:

* ``JUNCTION_MARGIN`` stays at its historical baseline (10 px).  A
  per-junction ``_required_junction_margin(n)`` helper in
  ``layout/engine.py`` derives the actual margin; in the concentric
  n-line fan the per-line stagger and per-line ``r_wrap`` cancel
  exactly, so every line's first-corner curve start lands at
  ``junction.x`` regardless of fan width.  A global bump was tried
  and visibly biased channels off the inter-section gap midline on
  simple fixtures (02_sections, 03_fan_out, 03b_fan_in_merge).

* ``_route_bypass`` going-left ``fan_mid_x`` keeps the historical
  ``sx - curve_radius - (un-1)*step/2`` form (mirror of the
  going-right branch).  The wrap-style ``+`` formula belongs in the
  wrap handlers, which compute their own first-corner geometry.

* ``_compute_junction_fan_info`` activates a shared first corner
  only when handlers would otherwise pick different source-side Xs:
  ``(lshape + bypass)`` (the historical condition) or
  ``(wrap + lshape | bypass)``.  Pure single-handler junctions stay
  with their natural per-handler corridor.

* ``_route_merge_trunk``'s ``force_cross_row`` only fires when the
  column range between trunk source and trunk target actually
  contains a section in a row OTHER than the trunk's
  (``_has_other_row_section_in_col_range``).  Flat single-row
  pipelines (genomeassembly, sarek-without-row-1-sections) keep
  the historical ``cross_row=False`` placement and don't pay the
  +41 px of extra canvas depth.

* ``_has_around_section_sibling`` (which gates the trunk's
  ``trunk_v_up_pull_away``) skips siblings whose column span pushes
  them into the bypass dispatcher - those route as merge-branches or
  trunks, never as around-section, so they don't compete for the
  same V_up channel and pulling the trunk away on their behalf
  produces visible unbundling (originally observed on
  03b_fan_in_merge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a 3-row topology fixture (``examples/topologies/around_section_below.mmd``)
that triggers ``_route_around_section_below`` - until now no gallery
fixture dispatched the handler, so it was dead code in CI.

* Fixture: source in row 0 col 2, middle in row 1 col 1, target in
  row 2 col 0 with a LEFT entry port.  The natural inter-row channel
  between source and target lands inside the middle section's bbox,
  so the dispatcher selects the around-section handler over the
  standard left-entry wrap.

* Regression test
  (``tests/test_routing.py::test_around_section_below_dispatched_for_cross_row_left_entry``)
  installs a hook on ``_route_around_section_below``, runs the
  layout, and asserts the handler fires and produces the expected
  6-point R-D-L-U-R shape with 4 concentric CW corners.

* ``test_routes_dont_loop_backwards`` (the #250 pinwheel guard) also
  exempts the first segment now - U-turn routes
  (around-section-below, left-entry-wrap with dx<0) legitimately lead
  OUT of the source's right edge before turning back leftward.  The
  guard still catches interior pinwheels (its actual purpose).

* Wire the fixture into ``scripts/build_gallery.py`` so the docs
  gallery and CI render-diff preview include it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(routing): wrap geometry, around-section route, clearance
examples/variantbenchmarking.png is referenced nowhere in the repo
(docs and guide embed the build-time SVG renders, not this file). It
was last rendered in March and no longer matches the current layout
engine output, so browsing it on GitHub showed a stale, misleading
map. Drop it; the live render in the docs gallery is the source of
truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hmarking-png

chore: remove orphaned variantbenchmarking.png
GitHub's built-in Mermaid renderer choked on subgraph titles and node
labels containing parentheses (e.g. "Preprocessing (Optional)",
"Liftover (Picard, UCSC)"), aborting with a parse error before any
diagram appeared.

Wrap those titles/labels in double quotes, which is Mermaid's escaping
mechanism for special characters, and strip a single surrounding pair
of quotes in the parser so nf-metro's own render is unchanged. The
metro maps still won't render meaningfully on GitHub (the %%metro
layout is ours), but the source at least parses instead of erroring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix: make example mmds parse in stock Mermaid
…ad pass

Layout-engine grid-strictness and robustness changes:

* compute_layout warns (UserWarning) when an explicit y_spacing is below
  the minimum the graph's content needs, instead of silently widening.
  The auto path (y_spacing unset) still widens via compute_min_y_spacing.
* _snap_canvas_y_to_grid (Stage 6.15) re-aligns the whole canvas to
  integer y_spacing multiples after late bbox-reanchoring leaves a
  uniform residue; half-grid symfan + convergence stations stay exempt.
* Remove the inert _pad_stacked_captioned_file_icons pass and its
  _required_captioned_icon_pitch / _has_under_icon_caption helpers.
  Captioned-icon auto-widening lives in compute_min_y_spacing; an
  explicit too-tight pitch now surfaces the warning rather than a silent
  layout-time widen that overrode the user's choice.
* _position_junctions re-runs after the Stage 6.13 row-tighten so
  junctions follow the shrink instead of leaving S-kinks at row-2 trunk
  boundaries.
* _guard_bundle_order_preserved phase guard (validate=True) catches
  inter-section bundles whose per-line order flips at a corner.
* Extract _fan_offsets (shared by the full-bundle redistribute/recenter
  passes) and split _shift_graph_into_canvas into _min_section_bbox_top
  + _translate_graph_y (also reused by the canvas snap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `%%metro file:` directive places a terminus file icon only when the
station is a true sink.  A mid-pipeline hub (one with both an
inter-section predecessor and a successor, e.g. sarek's `cram_in` which
receives from preprocessing and feeds the variant callers) now renders as
a plain marker, so the file icon no longer overlaps the through-track.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add terminal_symmetric_fan (a terminal section fanning into equal-rank
sinks) and trunk_through_fan (a pass-through section with a symmetric
split/join), and list them in the gallery so they get render-preview and
visual-diff coverage alongside the other topology fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Three invariants guarding entry/exit-port placement: a terminal
  section's equal-rank fan stays symmetric about its port
  (test_terminal_fan_symmetric_about_entry_port), a fan-and-reconverge
  exit port stays on its merge row (test_trunk_exit_follows_reconvergence),
  and a thick multi-line bundle keeps >= min_track_gap row clearance
  (test_thick_bundle_row_pitch).  Each guards `tested >= 1` so it can't
  pass vacuously.
* test_inter_section_routes_dont_reenter_source_section gated on an exit
  index so a junction-originated route's outward run isn't flagged as
  re-entry; boundary-originated routes keep full strictness.
* Retarget the Stage 6.15 phase-guard tests onto _snap_canvas_y_to_grid
  after the captioned-icon pass removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(layout): y-grid re-snap, captioned-icon pitch, hub-terminus fix
…ces)

Size each inter-section gap as A + Σ bundle_widths + (N-1)·B + A, where
A = EDGE_TO_BUNDLE_CLEARANCE (section edge to nearest bundle line) and
B = BUNDLE_TO_BUNDLE_CLEARANCE (between adjacent bundles sharing a gap).
_bundles_in_gap enumerates the distinct concentric bundles traversing each
gap (coalescing channels that share lines), and _min_gap_for_bundles applies
the formula, replacing the outermost-curve-radius sizing.  A single line of
one bundle collapses to ~2A, below the static MIN_INTER_SECTION_GAP, so
ordinary gaps are not widened; multi-line / multi-bundle corridors claim
only the horizontal space their visual width occupies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
symmetric_bundle_midpoint centres N bundles in an inter-section gap, keeping
exactly B between adjacent bundles and flooring the edge-to-bundle distance
at A; bundle_width gives a bundle's visual span.  column_gap_edges /
column_gap_midpoint and the underlying col_left_edge/col_right_edge take an
optional row so a diversion travelling in one row measures the gap against
that row's sections only (a wide section stacked in another row of the same
column no longer skews the gap centre).  RoutedPath gains normalize_exempt
for routes whose vertical channels follow a concentric loop that the
gap-channel normalization must not re-stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A post-routing pass (_normalize_gap_channels) re-stacks every inter-section
vertical channel into its final position, uniformly across all routing
handlers: lines running the same direction in a gap bundle OFFSET_STEP apart
(segments sharing a line_id overlay at one x), a downward bundle and an
upward bundle sharing a gap separate by B, a lone bundle centres with >=A
clearance, and the per-line order is chosen to minimise crossings.  Channels
are matched to their gap by actual Y delta and row-aware gap edges; wrap /
around-section / top-entry routes set normalize_exempt and are skipped.
_gap_channel_base provides the initial in-gap placement during routing that
the pass then refines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… invariants

Lock in the gap-width formula (dedup of fanned-to-many-targets channels),
single-bundle centring (incl. junction-sourced and around-section
diversions), same-direction bundling at OFFSET_STEP with same-line overlay,
the down/up B-separation in a shared gap, and that normalization adds no
inter-line crossings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(routing): principled inter-section gap formula + symmetric multi-bundle
At a single-upstream-source fan-out junction the upstream
port->junction route and the downstream junction->target route are
two separate RoutedPaths whose handoff points don't coincide: the
downstream L-shape lead-in starts a curve_radius PAST the junction,
leaving an along-travel gap that renders as a notch at the corner
apex of the outer line.

Add check_fanout_tail_join (and the fanout_junctions classifier) in
routing/invariants.py: for every fan-out junction, the component of
the upstream-end / downstream-start offset measured along the
upstream travel direction must be within tolerance. A purely
perpendicular offset (an inner concentric-corner member's approach Y,
hidden under the stroke) is tolerated. Merge junctions (>1 upstream
source) are excluded so their trunk routing is never implicated.

Wire it into compute_layout(validate=True) as _guard_fanout_tail_join
and add tests/test_fanout_tail_join_invariant.py (gallery happy-path,
the variant_calling_tuned __junction_6 case, and merge-junction
exclusion). The gallery test fails until the routing fix lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add _join_fanout_upstream_tails as a route_edges post-pass: at every
single-upstream-source fan-out junction, extend each incoming
port->junction route's horizontal final segment along X to the X of
its same-line downstream junction->target route's first waypoint.

This closes the along-travel gap (the downstream L-shape lead-in
begins a curve_radius past the junction) that rendered as a dark notch
at the corner apex of the outer line. The upstream Y is kept unchanged
so an inner concentric-corner member meets its downstream with only a
sub-line-width perpendicular offset, hidden under the stroke, rather
than a tilted/stepped approach that would reintroduce an apex kink.

Gated to genuine fan-out junctions (single distinct upstream source);
merge junctions are excluded so their trunk bypass routing is never
perturbed. Gallery diff: only the four fan-out fixtures change, each
by a single upstream-tail X extension; all other renders are
byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract _fanout_route_maps so the apex-gap check and the routing pass
that closes it index fan-out-incident routes by (junction, line_id) in
one place, and drop the duplicated tangent/perpendicular explanation
from the guard docstring (it lives with the check function).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(routing): join fan-out upstream tails to downstream curve start
Section bbox top padding was anchored to trunk_y - SECTION_Y_PADDING
while the bottom grew with content, so a fan symmetric about the trunk
read as pushed up. New Stage 6.15a _grow_bboxes_to_content_top grows
each bbox top to a full SECTION_Y_PADDING above the highest marker
(bounded against the row above by section_y_gap +
SECTION_HEADER_PROTRUSION so a grown box never crowds its header badge
into an inter-section route). Shared _section_content_top_target helper
feeds a new runtime guard _guard_section_top_padding and the invariant
test test_section_bbox_has_top_padding (differentialabundance plots
xfailed as gap-bounded). Extracted shared _bbox_cols_overlap predicate.

Fixes #406
…#410)

* fix(layout): re-anchor junctions after Stage 6.14 loop-station shift

Stage 6.14 (_shift_and_propagate_loop_stations) can move an exit port off
the Y it held when junctions were last positioned at Stage 6.13. Junctions
were only re-anchored inside _snap_canvas_y_to_grid, which early-returns when
the canvas is already on grid, leaving a fan-out junction stranded above its
exit port. The fanned routes then dip to the stale junction Y and back,
producing the S-curve on complex_multipath section 3 -> sections 4/5.

Re-run _position_junctions after the loop-station shift so junctions track
the settled port Ys. Add test_fanout_junction_shares_exit_port_y over the
multi-section corpus and a _guard_fanout_junction_shares_exit_port_y runtime
validator wired into the final validate block.

Fixes #386

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: tighten fan-out junction test helper after fix for #386

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): center multi-row gap channels in the intersected gap

A vertical inter-section channel that crosses several grid rows was centred
in the gap of the first overlapping row only. When a sibling row's section
was narrower (its column edge further out), the channel centred in that wider
gap and stepped back behind its own source section edge - a right-to-left
notch on a left-to-right route (complex_multipath standard/legacy climb out
of Full Pre-process).

_normalize_gap_channels._find_gap now narrows a channel's gap to the
intersection of every crossed row's column gap, and gap_bounds shared by
bundles in one (gap, row) intersects across members instead of last-write-
wins, so a down/up bundle pair packed together clears all crossed rows.

Add test_inter_section_route_no_x_backtrack over the multi-section corpus
and _guard_inter_section_route_no_backtrack runtime validator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#412)

Suppresses the spurious trailing TB fold on the final topo column and adds boustrophedon normalization so wrapped section rows never get negative grid columns. Adds a parametrized fold-invariant test and a _guard_no_negative_grid_columns runtime validator.

Fixes #256
…try (#411) (#413)

A section whose RIGHT entry port is fed through a fold junction by a
BOTTOM exit drops the bundle vertically (X offsets) then turns left into
the entry. That down->left concentric corner reverses bundle ordering,
but detect_reversed_sections only handled TB-section exits, so the
downstream section's entry Y offsets did not match the incoming run and
the lines crossed at the fold apex (caught by _guard_bundle_order_preserved
under validate=True).

Add Phase 1c to detect_reversed_sections to flag these sections reversed,
mirroring the existing TB BOTTOM/LR-exit cases. Widen the bundle-order
invariant sweep to examples/topologies and drop the now-fixed
fold_stacked_branch xfail.

Fixes #411

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pinin4fjords and others added 24 commits June 12, 2026 09:42
- Drop balancing._section_trunk_y; reuse the already-imported
  _section_lr_port_anchor_y (identical port-anchor lookup, removes a name
  clash with _common._section_trunk_y).
- Bail _row_group_grid_spacing before classifying section Ys when the group
  needs no shared grid, and return section_class for reuse.
- Compute rail_side only on the non-TB label paths that consume it.

Gallery renders byte-identical; full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g error

A cyclic or self-loop Mermaid graph crashed render with a raw
NetworkXUnfeasible traceback out of assign_layers, and validate reported
it as valid (exit 0), because nothing on the parse/layout path checked
acyclicity.

Add a find_cycle witness helper to the graph-semantic validator and a
format_cycle_error formatter so both planes share one message. validate_graph
now emits an ERROR naming the cycle (a -> b -> a; a -> a for a self-loop),
and assign_layers raises ValueError with the same message before the
topological sort. The render CLI's compute_layout catch is widened to
ValueError so it surfaces as a clean ClickException.

Fixes #645

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hoist the acyclicity check out of assign_layers (called once per section
and per spread/strike iteration) up to compute_layout, where it runs once
per render. find_cycle now gates on is_directed_acyclic_graph so the common
acyclic path pays no exception, and the witness DiGraph is no longer rebuilt
inside the layout hot path. Replace the bare ValueError with a dedicated
CyclicGraphError(ValueError) so the render CLI catches cycles precisely
without masking unrelated layout errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract helpers from _compute_entry_port_offsets, _reindex_section_local,
_reorder_exit_only_lines, and _compact_station_gaps. Pure structural;
gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sed_sections below 30 [skip ci]

Lift nested closures to module helpers and extract phase loops. Pure
structural; gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elow 30 [skip ci]

Move the label-strike-clearance grow/minimize loop (with its closures) and
the per-section rail retrofit out of _compute_layout_scaled. Pure structural;
gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…skip ci]

Lift the two closures to module helpers and extract route classification,
the flat-to-internal-chain guard, and the per-station body. Pure structural;
gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract helpers from _balance_section_content_around_trunk,
_compute_section_offsets, _insert_bypass_stations, and
_assign_grid_positions. Pure structural; gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o 30 [skip ci]

Extract helpers from _snap_all_y_to_grid, _guard_row_trunk_cy_consistent,
and check_route_segment_crossings, then set max-complexity=30. With all 15
remaining offenders under 30, C901 is now a readability guard, not just a
regression backstop. Pure structural; gallery byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the validator's two Chebyshev point-near checks
(_pt_at_station / _pt_near_shared_port) into one _pt_near helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(layout): fail fast on cyclic / self-loop graphs with a node-naming error
Adds a free-text caption field to MetroGraph and wires it through the
full stack: directive parser, CLI flag (--caption), SVG renderer, and
nf-metro info output. The caption is rendered bottom-left of the canvas
in the same muted style as the existing watermark, mirroring its
bottom-right placement.

Fixes #574

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract WATERMARK_FILL so both the watermark and caption text share the
constant rather than duplicating the rgba literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Demonstrates the %%metro caption directive on the motivating example
from #574.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The caption (%%metro caption:) is intended to be readable, not a muted
watermark. Separate CAPTION_FONT_SIZE (11) and CAPTION_FILL (0.85
opacity) constants from the WATERMARK constants (8pt, 0.6 opacity).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New `nf-metro explain` command surfaces WHY the engine made each layout
decision (inferred section directions, inferred port sides, fold/row layout,
fan-out junctions, bypass-V stations).

Pairs with `nf-metro info` (WHAT was built); this command adds WHY each
non-trivial choice was made.

- `src/nf_metro/explain.py`: new `build_explain` / `format_explain_text` /
  `format_explain_json` API, parallel in structure to `introspect.py`
- `src/nf_metro/cli.py`: `explain` command with `--json`, `--section`,
  `--station` filter flags
- `tests/test_explain.py`: 90 tests covering schema, all decision types,
  filters, formatters, CLI integration, and no-crash sweep over every fixture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a skill that simulates a never-seen-before pipeline arriving: it
composes one novel, deliberately-complex metro map, renders it, and
surfaces layout-engine bugs the way real new pipelines do.

The loop: braid 2-4 structural axes that haven't been combined before
(off the failure-mode menu) into a correct-by-construction .mmd, probe
it (probe_layout.py runs parse -> layout -> validate -> route and sorts
findings into authoring / validator / crash / guard buckets), present
the render for aesthetic bugs the validator can't see, then confirm and
understand every candidate against the laid-out geometry (inspect_layout.py
flags off-trunk drags, off-track gaps, inter-row gaps; nf-metro explain
names the rule that fired) before filing. Each confirmed bug ships with
regression infra - an examples/topologies fixture, a GALLERY_ENTRIES
render so CI render-diff tracks it, and a strict-xfail test that reds CI
when the bug is fixed - so it can't silently return.

Operational answer to #364 (mutation/fuzz testing) and #323 (fragility
on new pipelines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tooling [skip ci]

Two additive, conditional notes so fix-issue coordinates with the
nf-metro-stress-render skill without losing its general-issue scope:

- Step 3 points at the bundled probe_layout.py / inspect_layout.py
  diagnostic scripts plus nf-metro explain/info as conveniences for the
  reproduce-in-numbers step (usable for any issue), and notes that a
  stress-render-filed issue carries a correct-by-construction repro .mmd
  in a <details> fold.
- Step 4 adds a 'check for an existing regression lock first' preamble:
  if a strict-xfail referencing the issue already exists (as stress-render
  leaves behind), it IS the failing test - don't duplicate it or re-add
  the fixture/gallery entry; the fix flips it to XPASS (reds CI) and is
  finished by removing the xfail marker. Explicitly framed as the
  exception, with the bare-issue path as the common case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-line bundle entering a section through an entry port to a deep
first station inserts one phantom pass-through per line, all converging
on that station. `_align_phantom_pass_throughs` snapped the convergence
node onto a phantom's track once per phantom, so the last-iterated
phantom's track won, dragging the consumer off the trunk into a
near-vertical onward climb where the bundle merged into one stroke.

Snap only when a convergence node has a single phantom predecessor (a
genuine bubble); a node fed by several phantoms is the section trunk
head and stays on its assigned trunk track.

Fixes #650

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Factor the off-track-input -> consumer edge filter shared by
test_off_track_inputs_above_consumer and the new consumer-id helper into
one _off_track_input_consumer_map, kept independent of the engine's own
_off_track_anchor_of so the invariant stays an independent oracle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An off-track input that shares a consumer-anchor with a differently-columned
off-track station (e.g. an input above the consumer plus a producer-fed output
beside it) was lifted by the whole anchor group's depth instead of its own
column's. A lone-in-its-column input was stranded an extra slot up over an
empty row above an earlier trunk station, which the layout validator flags as
excessive_column_gap.

Count the lift step per column in _place_off_track_relative_to_anchors via the
new _per_column_stack_steps helper, so stations sharing an anchor across
different columns each hug one row above their consumer. Genuine same-column
stacks are unchanged.

Add _guard_off_track_input_column_stack (validate block) and a parametrised
invariant test over single-trunk off-track-input fixtures, plus a
topology fixture and gallery entry so CI's render-diff tracks the case.

Fixes #651

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…crossover (#652)

Treat the lines leaving a fan-out junction as one bundle so a far-column
bypass no longer weaves under its sibling down-turns, and render the
down-turns as truly concentric arcs.

- Unclamp the shared fan corner (_restack_channel): extend each descent's
  source-side lead-in to its full r_first so resolve_curve_radii stops
  clamping the concentric radii to the short lead length; the down-turns
  now share one arc centre instead of squashing together. The extra lead
  length overlaps the upstream same-line tail, so it is visually free.
- Order the gap-bundle descent by approach side (_distinct_line_order):
  avoid fan-side weaves before deep-end crossings, so a bypass at its
  natural slot descends bundled-inner and crosses its down-turns once down
  in the inter-row gap (a clean fork) instead of weaving at the fan.
  Scoped to rightward-descending fans; the leftward mirror is left to the
  existing deep-end ordering.

Zero crossings is topologically impossible at this fan (the source emits
both the straight continuation and the down-turn). test_fan_bypass_no_fan_weave
guards the real invariant: no bypass-vs-descender crossing in the junction's
own row band; a gap-level crossover and the same-row trunk crossing are
expected.

Fixes #652. Also addresses #651.
)

Add a `%%metro aspect:` directive and `--aspect` flag that pick the
fold_threshold whose rendered layout shape is closest to a requested
width/height ratio. A metro map's aspect ratio is emergent from section
topology, so the search brackets the achievable set (candidate fold
thresholds bracketed by maximum fold and the single-row total), measures
each, and reports the closest in log space. When the topology offers only
one shape (convergence/tall-anchor layouts, an author-pinned grid, a single
section) it says so and renders as-is rather than silently doing nothing.

Inert unless aspect is set; gallery renders are byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Render preview: no visual changes detected. All renders match main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Control aspect ratio

2 participants