disposition 0.4.0

SVG diagram generator
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# Edge Descriptions

Edge descriptions are text labels associated with edges in the diagram.  They
are specified via `edge_descs` in `InputDiagram`, keyed by the edge
**instance** ID (not the edge group ID).

> **Note:** `EdgeDescs` is **not** rendered through face-label slots.
> Description text is rendered via `edge_description_container` nodes
> interleaved between rank containers -- except for cycle edges (see
> [Same-Rank (Cycle Edge) Placement]#same-rank-cycle-edge-placement below),
> whose container is inserted as a sibling *within* the shared rank instead.
> See `edge_description_containers_plan.md` for the implementation plan of
> that feature.

Face-label slots (documented below) are structural taffy leaf nodes placed at
the `from`/`to` node faces.  They exist purely for edge contact-point
positioning and face-offset calculations (see `edge_paths.md` -- Offset
Calculation).  They always measure as zero size and carry no rendered text.


## Text Measurement and Markdown Rendering

Edge description text measurement and rendering follows one of two paths
depending on the diagram level of detail:

### DiagramLod::Simple

At `DiagramLod::Simple`, each edge description is rendered as a single taffy
leaf node with `TaffyNodeCtx::EdgeDescription` context. The leaf is measured
using the description text as plain text (no markdown parsing), and spans are
computed by `HighlightedSpansComputer::compute_edge_desc_containers` after
layout.

### DiagramLod::Normal

At `DiagramLod::Normal`, the single description leaf is replaced by an
`md_content_node` sub-tree built via `MdNodeBuilder`. The markdown text is
parsed by `MdBlocksParser` into `MdBlock` structures, which are then converted
into a flex sub-tree with per-token and per-image leaves.

After layout, `MdSpansComputer::compute_edge_descs` processes these sub-trees
to merge adjacent word leaves on the same visual line into consolidated text
spans with markdown styling (bold, italic, code, headings, links) and converts
image leaves into `MdImageSpan` values.

The results are stored in `TaffyNodeMappings::edge_description_highlighted_spans`
and `TaffyNodeMappings::edge_description_image_spans`, then mapped to
`SvgTextSpan` and `SvgImageSpan` values by `SvgEdgeDescriptionsBuilder::build`
for final SVG rendering.


## Edge ID Format

Edge IDs are generated in the form:

```
{edge_group_id}__{edge_index}
```

For example, an edge group named `edge_dep` whose first (and only) edge is
index 0 has the edge ID `edge_dep__0`.

For a `symmetric` group named `edge_sym` between nodes `t_a` and `t_b` the two
edges are:

| Index | Direction | Edge ID       |
|-------|-----------|---------------|
| 0     | t_a -> t_b | `edge_sym__0` |
| 1     | t_b -> t_a | `edge_sym__1` |

## How to Add a Description

In your `InputDiagram` (YAML or Rust), add an entry to `edge_descs`:

```yaml
thing_dependencies:
  edge_dep:
    kind: sequence
    things:
      - t_a
      - t_b

edge_descs:
  edge_dep__0: "A depends on B"
```

The description text is rendered in an `edge_description_container` node
positioned between the rank containers of the edge's divergent ancestors.  See
`edge_description_containers_plan.md` for details, and
[Same-Rank (Cycle Edge) Placement](#same-rank-cycle-edge-placement) below for
the exception when the divergent ancestors share a rank.


## Same-Rank (Cycle Edge) Placement

When an edge's divergent ancestors share a rank -- a cycle edge, e.g. a
`cyclic` dependency group, or any edge (dependency or interaction) between two
nodes that a dependency cycle placed on the same rank -- there is no gap
*between* rank containers to interleave a container into: both ancestors live
in the same rank container's children.

For this case, `EdgeDescriptionBuilder::build` inserts the
`edge_description_container` as a direct child of the shared rank, at the
sibling index between the two divergent ancestors, rather than as a sibling of
rank containers. This mirrors how `EdgeSpacerBuilder` places same-level
cross-rank spacers (see [edge_spacers.md](edge_spacers.md) -- Same-Level
Cross-Rank Spacers): both use the shared
[`RankSiblingInserter`](crate/input_ir_rt/src/ir_to_taffy_builder/rank_sibling_inserter.rs)
helper to compute the sibling insertion index
(`(from_sibling_index + to_sibling_index) / 2 + 1`) and to insert at the
effective index, accounting for other insertions already made at that rank.

Multiple edges whose divergent ancestors are the *same* pair of same-ranked
siblings (e.g. a cyclic dependency plus a symmetric interaction group between
the same two nodes) share one container, grouped by `(rank,
sibling_index_middle)` so that a different pair of same-ranked siblings gets
its own container rather than being merged in.

Unlike a cross-rank `edge_description_container` (which mirrors
`rank_container_style.flex_direction`, since it is inserted *as a sibling of*
rank containers and multiple descriptions sharing that position should lay
out along the same axis rank siblings use), a same-rank container's own
children layout is *inverted* (`EdgeDescriptionBuilder::container_style_build`,
via `taffy_container_builder::flex_direction_invert`): the container is
inserted *as a rank sibling itself*, directly between the two divergent
ancestors, which already occupy the rank's own stacking axis. Mirroring that
axis for multiple described edges sharing the slot would stack their boxes
along the same axis the two divergent ancestors sit on, widening (or
heightening) the gap between them per extra description. Since the divergent
ancestors' own edges run *along* that axis, the descriptions instead stack
along the perpendicular (cross) axis -- e.g. under `rank_dir: top_to_bottom`,
two described edges between the same pair of same-ranked (horizontally
adjacent) siblings stack vertically (`Column`) rather than widening the
horizontal gap between them.

Either way (cross-rank or same-rank), a `RowReverse`/`ColumnReverse`
`flex_direction` -- which occurs under `rank_dir: bottom_to_top` /
`right_to_left`, whose rank containers use the reversed variant so ranks
stack in reverse screen order -- is stripped down to plain `Row`/`Column`
for the `edge_description_container`. Ordinary rank containers need the
reversed variant because their *own* sibling order is separately corrected
for it (see [Sibling order for reversed rank directions](edge_paths.md#sibling-order-for-reversed-rank-directions)),
but an `edge_description_container`'s children are freshly built and sorted
by `sibling_index_middle`/`EdgeId` in visual order every time (see above), so
no such correction exists -- a reversed direction would instead render them
back to front, crossing over each other.

This placement is scoped per LCA level exactly like same-level cross-rank
spacers: `EdgeDescriptionBuilder::build` is called once per level (root, and
once per container that is an LCA for at least one described edge), each with
its own independently-scoped `rank_to_taffy_ids`, so two cyclic pairs at
different nesting depths (e.g. a root-level cycle and, separately, a cycle
between two children of one of those root nodes) cannot collide.

The description's own rendered position is also a routing waypoint for its
owning edge's path: `SpacerCoordinatesResolver::description_contact_resolve`
reads the description leaf's post-layout rect and bends the edge's path to
touch it, applied unconditionally regardless of edge curvature (see
`edge_spacers.md` -- Edge Description Container Spacers). This mirrors how
`label_face_span_compute` bends a path's face contact to sit beside an edge
label's own box. See [Description Contact Waypoint](#description-contact-waypoint)
below for how that waypoint is chosen -- it differs for same-rank vs
cross-rank edges.


## Description Contact Waypoint

`description_contact_resolve` branches on
`EdgeDescriptionTaffyNodes::is_cross_rank` (`true` for
`EdgeDescPosition::BetweenRanks`, `false` for `EdgeDescPosition::SameRank`).
In both cases, the description box sits directly *on* the connection between
the edge's two divergent ancestors -- between ranks for a cross-rank edge, or
directly between the two same-ranked siblings for a same-rank (cycle) edge --
so in both cases the path threads *through* the box (`entry != exit`), the
same way an ordinary spacer's corridor is threaded via
`EdgeSpacerCoordinatesCalculator::calculate`:

- **Cross-rank (`BetweenRanks`)**: threaded via `calculate_description_thread`
  -- see [Cross-Rank Contact]#cross-rank-contact below.
- **Same-rank (cycle edges)**: threaded via
  `calculate_description_thread_same_rank`, which additionally rotates onto
  the axis the two divergent ancestors are laid out on *within* their shared
  rank -- see [Same-Rank Contact]#same-rank-contact below.

Both share the same divergent-ancestor sibling-order input
(`sibling_index_from_cmp_to`, stored on `EdgeDescriptionTaffyNodes` alongside
the taffy node IDs, computed in `EdgeDescriptionBuilder::edge_desc_build` from
the same `sibling_index_from`/`sibling_index_to` values used for
`sibling_index_middle`), because both must account for an edge travelling
*against* the diagram's canonical `RankDir` flow (e.g. a `symmetric`
interaction group's reverse edge) -- naively assigning entry/exit purely from
`RankDir` would force such an edge through its waypoints in the wrong order,
backtracking through the box. This was a real, observed regression:
`edge_ix_client_server__1` in `020_interaction_halo_with_labels.yaml`
(`t_server -> t_client`, i.e. high-rank to low-rank under
`rank_dir: left_to_right`) once rendered as `... 456 -> 245(entry) ->
285(exit) -> 91 ...`, visibly looping back on itself.


### Cross-Rank Contact

A cross-rank edge's description box sits directly on the rank corridor
between its divergent ancestors. `EdgeSpacerCoordinatesCalculator::
calculate_description_thread` returns a proper corridor pair (`entry !=
exit`), the same shape `calculate` produces for ordinary spacers. The fixed
cross-axis coordinate mirrors `calculate`'s `cx`/`cy` convention (unchanged
between a `RankDir` and its reverse pair -- `top_y` for
`LeftToRight`/`RightToLeft`, `left_x` for `TopToBottom`/`BottomToTop`);
`Ordering::Less` (this edge's `from` is before its `to`, i.e. it travels in
the topological-forward direction) reuses `calculate`'s canonical entry/exit
assignment for that `RankDir` (substituting the fixed value for `cx`/`cy`);
`Ordering::Greater` (a reverse-direction edge, e.g. a `symmetric` interaction
group's response edge) swaps entry and exit so the pair always runs in *this
edge's own* travel direction rather than the diagram's canonical one:

| `RankDir` | fixed axis | `from` before `to` (`Less`) | else (`Greater`) |
|---|---|---|---|
| `LeftToRight` | `y = top_y` | entry=`(left_x,top_y)` exit=`(right_x,top_y)` | entry=`(right_x,top_y)` exit=`(left_x,top_y)` |
| `RightToLeft` | `y = top_y` | entry=`(right_x,top_y)` exit=`(left_x,top_y)` | entry=`(left_x,top_y)` exit=`(right_x,top_y)` |
| `TopToBottom` | `x = left_x` | entry=`(left_x,top_y)` exit=`(left_x,bottom_y)` | entry=`(left_x,bottom_y)` exit=`(left_x,top_y)` |
| `BottomToTop` | `x = left_x` | entry=`(left_x,bottom_y)` exit=`(left_x,top_y)` | entry=`(left_x,top_y)` exit=`(left_x,bottom_y)` |

`Ordering::Equal` should not occur (two distinct divergent ancestors always
have distinct sibling indices); treated the same as `Greater`.

Concretely, before this fix, `edge_dep_client_server__0` in
`020_interaction_halo_with_labels.yaml` (`rank_from: 0, rank_to: 1`) used a
single-point calculation and rendered pinned at the box's `left_x` with
wildly varying, out-of-box `y` values -- downstream spacer-ordering and
protrusion logic (built to expect a real two-point corridor, like every
other spacer kind) mishandled the degenerate zero-length waypoint. Threading
through the box properly fixed this.

This waypoint pair is folded into `SpacerCoordinatesResolver::resolve`'s
merged, sorted spacer list exactly like any other spacer kind, so protrusion
and turn-minimization logic (built generically over entry/exit corridors)
handle it without any special-casing.


### Same-Rank Contact

A same-rank (cycle) edge's divergent ancestors are laid out side by side
*within* their shared rank -- horizontally when the rank's own children stack
via `Row`/`RowReverse` (`RankDir::TopToBottom`/`BottomToTop`), vertically when
they stack via `Column`/`ColumnReverse` (`RankDir::LeftToRight`/
`RightToLeft`). The description box sits directly between them, on that
within-rank axis, so the path threads through it just like the cross-rank
case -- but on the axis the *siblings* are laid out on, not the diagram's
overall rank axis.

Because within-rank sibling order always matches declaration order
regardless of `RankDir`'s forward/reverse convention (see
[Sibling order for reversed rank directions](edge_paths.md#sibling-order-for-reversed-rank-directions)
in `edge_paths.md`), `Ordering::Less`/`Greater` here means the same thing
(`from`'s divergent ancestor sits earlier/later along the shared rank) for
both members of a forward/reverse `RankDir` pair -- unlike the cross-rank
case, where the physical meaning of `Less`/`Greater` flips between a
`RankDir` and its reverse pair. Only the horizontal-vs-vertical layout axis
depends on `RankDir`.

`EdgeSpacerCoordinatesCalculator::calculate_description_thread_same_rank`
therefore reuses `calculate_description_thread`'s table by rotating
`rank_dir` onto whichever of its two canonical rows matches the axis
same-rank siblings are actually laid out on: `TopToBottom`/`BottomToTop`
(horizontal siblings) both use the `LeftToRight` row (fixed `y = top_y`);
`LeftToRight`/`RightToLeft` (vertical siblings) both use the `TopToBottom`
row (fixed `x = left_x`).

Before this fix, the same-rank case used a single-point calculation (a fixed
corner of the box, biased by `sibling_index_from_cmp_to` to avoid two edges
sharing a box backtracking through its center) instead of threading through
-- visible in `019_interaction_halo.yaml`'s `edge_ix_client_server__0`
(`t_client`/`t_server`, same rank since there are no `thing_dependencies`
between them, only `thing_interactions`), which touched only its box's
top-left corner rather than running along its top edge. Also see
[Same-Rank (Cycle Edge) Placement](#same-rank-cycle-edge-placement) above
for the companion `FlexDirection` fix: the `edge_description_container` for a
same-rank group inverts `rank_container_style.flex_direction` (via
`taffy_container_builder::flex_direction_invert`) so multiple described
edges sharing that same-rank slot stack along the axis perpendicular to the
two divergent ancestors, instead of widening/heightening the gap between
them.


### Halo Clearance

Like edge labels (see `TaffyEnvelopeBuilder::label_margin_build`, and
`SvgEdgeInfosBuilder::label_face_span_compute`'s `- halo_pad_px` pullback),
a described edge's box needs clearance from the interaction edge halo -- a
wide path drawn `interaction_edge_halo_stroke_width / 2.0` ("`halo_pad_px`")
either side of the edge's own path -- so the halo doesn't visually overlap the
box's rendered content. Unlike a label (which the path approaches and stops
beside), a description box is threaded *through*, flush against exactly one
of its own edges (see the fixed-axis tables above), so the clearance
mechanism mirrors the label case in two coordinated halves:

1. **Build time** (`EdgeDescriptionBuilder::edge_desc_build`): the description
   leaf/`md_content_node` gets a `margin` (not `padding`) of `halo_pad_px +
   label_margin_px` (`label_margin_px` = `TEXT_FONT_SIZE / 2.0`) on **both**
   sides of whichever axis the routing path runs flush against.
2. **Routing time** (`EdgeSpacerCoordinatesCalculator::description_thread_from_rect`):
   the same fixed-axis coordinate is pulled back by `halo_pad_px` only (not
   the full margin), canceling just the halo-clearance component of the
   margin's push for the routing calculation -- the path ends up
   `label_margin_px` further from the box's pre-margin position (rather than
   exactly pinned to it), so the description still reads as visually
   associated with its edge, matching how `label_face_span_compute`'s
   pullback likewise only ever cancels `halo_pad_px` for edge labels.

Both sides of the margin get the *same* value (rather than only the far side)
because the fixed axis chosen below is also the axis multiple described edges
sharing the same position are packed along (`container_style_build` mirrors
-- cross-rank -- or inverts -- same-rank -- `rank_container_style`'s
`flex_direction` onto that same axis, so sibling description boxes at a
shared position stack along it). If only the far side carried the margin, a
dependency edge (whose `halo_pad_px` is `0.0`, see below) would end up with
*no* margin at all on the near/routing-path side, losing separation from
whatever sits before it along the packing axis -- typically the previous
sibling description box sharing the same position, or the container's own
edge for the first box.

Which side gets the margin/pullback follows the same fixed-axis selection as
[Cross-Rank Contact](#cross-rank-contact) and [Same-Rank Contact](#same-rank-contact)
above:

| Effective `RankDir`\* | fixed axis | margin / pullback side |
|---|---|---|
| `TopToBottom` / `BottomToTop` | `x = left_x` | **left and right** |
| `LeftToRight` / `RightToLeft` | `y = top_y` | **top and bottom** |

\* For same-rank (cycle edge) boxes, "effective `RankDir`" is `rank_dir`
rotated via `EdgeSpacerCoordinatesCalculator::rank_dir_same_rank_rotate` --
the same rotation `calculate_description_thread_same_rank` already applies --
so both the build-time margin axis and the routing-time pullback axis are
derived identically and cannot drift apart. `EdgeDescriptionBuilder::edge_desc_build`
reuses `rank_dir_same_rank_rotate` directly (re-exported `pub(crate)` from
`taffy_to_svg_elements_mapper`) rather than re-deriving the mapping.

`halo_pad_px` itself is `0.0` for **dependency** edges (`EntityType::is_dependency_edge`):
only interaction edges render the wide interaction-edge halo (`render_options.interaction_edge_halo`),
so a dependency edge's description has nothing to clear from a halo, and its
routed path sits flush against the box (which still carries `label_margin_px`
of margin on both sides, for separation from its neighbors -- see above).
`label_margin_px` applies unconditionally to both edge kinds. Both the
build-time margin (`EdgeDescriptionBuilder::edge_desc_build`, which looks up
the edge's own entity types) and the routing-time pullback (each call site
threading `interaction_edge_halo_stroke_width` into `SpacerCoordinatesResolver::resolve`
/ `description_contact_resolve` -- `SvgEdgeInfosBuilder::build_edge_path_infos_with_offsets`,
`face_offsets_gap_transit_separate`, and `OrthoProtrusionCalculator::calculate`
-- substitutes `0.0` per edge based on `EdgePass1Info::is_interaction`) apply
this exception independently, so a mismatch between the two would show up as
either an unfilled gap (routing pulls back but the box never moved) or an
uncleared overlap (the box moved but routing didn't compensate); keeping both
checks in sync is required.

Edge labels (`TaffyEnvelopeBuilder::label_margin_build`) apply the identical
dependency-edge exception and both-sides margin, for the same reason: the
label's packing axis (multiple sibling labels on the same node face) is the
same axis the halo-clearance margin applies to, so a label-only far-side
margin would equally lose near-side separation for dependency edges.
`SvgEdgeInfosBuilder::face_offsets_compute` passes `0.0` for
`interaction_edge_halo_stroke_width` per edge (via `FaceContactEntry::is_interaction`)
when calling `label_face_span_compute`, mirroring the description case.


## Step-by-Step: How Face-Label Slots Are Built

Face-label slots are the taffy leaf nodes placed in each node's envelope
face-wrappers.  They are used downstream as contact-point anchors for edge path
routing.

### Step 1 -- `InputDiagram.edge_descs`

`InputDiagram.edge_descs` is a `Map<Id, String>`.  The user places a
description string keyed by the edge instance ID (e.g. `edge_dep__0`).

Source: `crate/input_model/src/input_diagram.rs`.

The map is carried through the pipeline as `IrDiagram.edge_descs`.  It is
**not** consulted during face-label slot construction or measurement.

### Step 2 -- `InputToIrDiagramMapper` computes face assignments

`InputToIrDiagramMapper` copies `edge_descs` verbatim into
`IrDiagram.edge_descs` and simultaneously computes two derived structures:

- `EdgeFaceAssignments` -- maps each edge ID to the face of its `from` node
  that the edge leaves and the face of its `to` node that the edge enters.
  Computed by `EdgeFaceAssigner::compute` from rank and sibling data
  (no pixel coordinates needed).

- `NodeFaceEdges` -- maps `(NodeId, NodeFace)` to the list of edge IDs that
  use that face.  Derived from `EdgeFaceAssignments` and `EdgeGroups` by
  `NodeFaceEdges::from_assignments`.

Source: `crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs`,
`crate/input_ir_rt/src/edge_face_assigner.rs`,
`crate/ir_model/src/node/node_face_edges.rs`.

### Step 3 -- `IrToTaffyBuilder` builds face-label slots

This step happens inside `IrToTaffyBuilder::build_taffy_trees_for_dimension`.

#### Step 3a -- Envelope node construction

For each diagram node a taffy **envelope node** is built that wraps the node's
own content (`taffy_envelope_node_build`).  The envelope has four face-wrapper
containers (top, bottom, left, right).

For each face that has edges (looked up via `NodeFaceEdges::edges_for`), an
`EdgeLabel` leaf taffy node is created with `TaffyNodeCtx::EdgeLabel` context.
These leaves are collected in `edge_label_leaves`.

Source: `crate/input_ir_rt/src/ir_to_taffy_builder.rs` --
`taffy_envelope_node_build`, `taffy_envelope_node_build_face_leaves`.

#### Step 3b -- Layout measurement (`node_size_measure`)

During `compute_layout_with_measure`, `node_size_measure` is called for each
taffy node.  For `EdgeLabel` leaves the handler returns zero size -- no text
measurement is performed and `edge_descs` is not consulted here.

The leaf collapses to zero size in the layout, reserving a structural slot in
the envelope without affecting the node's rendered dimensions.

Source: `crate/input_ir_rt/src/ir_to_taffy_builder.rs` -- `node_size_measure`.

#### Step 3c -- `edge_label_taffy_nodes_build`

After layout, `edge_label_taffy_nodes_build` assembles the collected
`EdgeLabelLeafBuilt` entries into a `Map<EdgeId, EdgeLabelTaffyNodeIds>`.
Each entry maps an edge ID to its optional `from_label_taffy_node_id` and
`to_label_taffy_node_id`.

A leaf is assigned to `from_label_taffy_node_id` when its `node_id` matches the
edge's `from` endpoint and the pre-assigned `from_face` is `Some`.  The
`to_label_taffy_node_id` is assigned symmetrically.

Self-loop edges use only a `from_label` slot (since `from == to`, one slot
is sufficient). Contained edges (where one endpoint is an ancestor of the
other) produce label slots on both endpoints using forward or reverse faces
depending on hierarchy direction.

This map is stored as `TaffyNodeMappings::edge_label_taffy_nodes` and consumed
by the edge path routing code (see `edge_paths.md` -- Offset Calculation,
label-based offset).

Source: `crate/input_ir_rt/src/ir_to_taffy_builder.rs` --
`edge_label_taffy_nodes_build`, `edge_id_to_node_ids_build`.


## Key Requirements

### 1 -- Correct edge ID key

The `edge_descs` key must be the edge **instance** ID in the format
`{edge_group_id}__{edge_index}`, not the edge group ID by itself.

### 2 -- All edges produce face-label slots

Face-label slots are created for every edge that has a valid face assignment,
regardless of whether a description exists in `edge_descs`.  The slots are
always zero-size but are necessary for the edge path routing calculations.

Self-loop edges (`from == to`) produce a single `from_label` slot on the
rank-direction face of the node (`Bottom` for `TopToBottom`, `Top` for
`BottomToTop`, `Right` for `LeftToRight`, `Left` for `RightToLeft`);
`to_label` is `None` since one slot is sufficient.

Contained edges (where one endpoint is an ancestor of the other in the node
hierarchy) produce label slots on both endpoints. The faces used depend on
hierarchy direction, mirroring the forward/reverse face logic for regular edges:

- `from` is ancestor of `to` (downward): `from_face` = rank-dir face,
  `to_face` = opposite face (e.g. `Bottom`/`Top` for `TopToBottom`).
- `to` is ancestor of `from` (upward): `from_face` = opposite face,
  `to_face` = rank-dir face.

### 3 -- Face selection: unified pre-layout source (Option B)

Face assignment is computed once before taffy layout runs, by
`EdgeFaceAssigner::compute`.  The result is stored as
`IrDiagram::edge_face_assignments`.

`SvgEdgeInfosBuilder::build_edge_pass1_infos` looks up the pre-computed
assignment for each edge instead of re-computing faces from pixel geometry.
This guarantees that the face a label slot is reserved on always matches the
face the edge path exits.

**Special cases:**

- **Self-loops** (`from == to`): pre-layout assigns
  `(from_face: <rank-dir face>, to_face: None)` -- only one label slot is
  created.  Pass 1 duplicates the from face for both routing contacts, so the
  offset and protrusion machinery treats the loop as two contacts on the same
  face, and pass 2 routes it through the curvature-specific builders (an
  orthogonal U-shape with cycle-edge protrusions, or a curved loop).
- **Contained edges** (one endpoint is a pixel-level ancestor of the other):
  face-based contact points are bypassed (returns `(None, None)`), consistent
  with the pass-2 `is_node_contained_in` early-return.
- **Cycle edges** (same LCA rank, non-adjacent siblings):
  `EdgeFaceAssigner::cycle_faces` uses the same face mapping as
  `cycle_edge_faces_select` -- sibling index is a reliable proxy for
  horizontal/vertical relative position within a rank level.
- **Missing assignment** (should not occur for well-formed diagrams):
  falls back to the post-layout `faces_select`.


## Data Flow Summary

```
InputDiagram
  edge_descs: { "edge_dep__0": "A depends on B" }
       |
       | InputToIrDiagramMapper::map
       v
IrDiagram
  edge_descs: { "edge_dep__0": "A depends on B" }  --> edge_description_containers_plan.md
  edge_face_assignments: { "edge_dep__0": { from_face: Bottom, to_face: Top } }
  node_face_edges: { t_a: { Bottom: ["edge_dep__0"] },
                     t_b: { Top:    ["edge_dep__0"] } }
       |
       | IrToTaffyBuilder::build_taffy_trees_for_dimension
       v
TaffyNodeMappings
  edge_label_taffy_nodes:
    "edge_dep__0": { from_label: taffy_X (size 0), to_label: taffy_Y (size 0) }
       |
       | TaffyToSvgElementsMapper::map / SvgEdgeLabelsBuilder::build
       v
SvgElements
  edge_label_infos:
    [ SvgEdgeLabelInfo {
        edge_id: "edge_dep__0",
        from_label: Some(SvgEdgeLabelEndpointInfo {
          x, y, width, height,
          text_spans: []   -- always empty; no text from face-label slots
        }),
        to_label: Some(SvgEdgeLabelEndpointInfo { ... })
      } ]
       |
       | SvgEdgeInfosBuilder::face_offsets_compute (label-based offset)
       | SvgElementsToSvgMapper::render_edge_labels (no SVG output; text_spans empty)
       v
Edge path contact points use from_label / to_label bounds as offset anchors
(slot-based fallback applies since label size is 0).
```