mmdflux 2.1.0

Render Mermaid diagrams as Unicode text, ASCII, SVG, and MMDS JSON.
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
//! Shared attachment, port, and face policy for graph-family routing.
//!
//! Grid replay and float routing both need the same attachment planning,
//! face classification, and backward-edge policy. Keeping those contracts out
//! of `routing` avoids a grid -> routing dependency and gives the shared
//! policy a single owner.

use std::collections::HashMap;

use crate::graph::Direction;
use crate::graph::space::{FPoint, FRect};

/// Which face of a node boundary an edge port attaches to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PortFace {
    Top,
    Bottom,
    Left,
    Right,
}

impl PortFace {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Top => "top",
            Self::Bottom => "bottom",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

impl std::str::FromStr for PortFace {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "top" => Ok(Self::Top),
            "bottom" => Ok(Self::Bottom),
            "left" => Ok(Self::Left),
            "right" => Ok(Self::Right),
            _ => Err(()),
        }
    }
}

/// Port attachment information for one end of a routed edge.
#[derive(Debug, Clone, PartialEq)]
pub struct EdgePort {
    /// Face on the node boundary where the edge attaches.
    pub face: PortFace,
    /// Fractional position along the face (0.0 = start, 1.0 = end).
    /// For top/bottom: 0.0 is left, 1.0 is right.
    /// For left/right: 0.0 is top, 1.0 is bottom.
    pub fraction: f64,
    /// Computed position on the node boundary in layout coordinate space.
    pub position: FPoint,
    /// Number of edges attached to this face of this node.
    pub group_size: usize,
}

/// Which face of a rectangular node an edge attaches to.
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum Face {
    Top,
    Bottom,
    Left,
    Right,
}

impl Face {
    /// Convert to the geometry IR port face type.
    pub fn to_port_face(self) -> PortFace {
        match self {
            Face::Top => PortFace::Top,
            Face::Bottom => PortFace::Bottom,
            Face::Left => PortFace::Left,
            Face::Right => PortFace::Right,
        }
    }
}

/// Direction-specific overflow lane for fan-in spill candidates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverflowSide {
    LeftOrTop,
    RightOrBottom,
}

/// Primary face capacity for deterministic overflow policy.
pub const FAN_IN_PRIMARY_FACE_CAPACITY_TD_BT: usize = 4;
pub const FAN_IN_PRIMARY_FACE_CAPACITY_LR_RL: usize = 2;

/// Long backward edges (3+ user-visible rank gaps, normalized rank_span >= 6)
/// use side-face channel routing.
pub const BACKWARD_SIDE_CHANNEL_LONG_RANK_SPAN: usize = 6;

/// Shared threshold for choosing wide horizontal detours.
pub const LARGE_HORIZONTAL_OFFSET_THRESHOLD: usize = 30;

const TD_BT_PARITY_MIN_RECT_SPAN: f64 = 20.0;

/// Return the deterministic base capacity for the primary incoming face.
pub fn fan_in_primary_face_capacity(direction: Direction) -> usize {
    match direction {
        Direction::TopDown | Direction::BottomTop => FAN_IN_PRIMARY_FACE_CAPACITY_TD_BT,
        Direction::LeftRight | Direction::RightLeft => FAN_IN_PRIMARY_FACE_CAPACITY_LR_RL,
    }
}

/// Convert canonical fan-in spill slot into an overflow face for a direction.
pub fn fan_in_overflow_face_for_slot(direction: Direction, slot: OverflowSide) -> Face {
    match direction {
        Direction::TopDown | Direction::BottomTop => match slot {
            OverflowSide::LeftOrTop => Face::Left,
            OverflowSide::RightOrBottom => Face::Right,
        },
        Direction::LeftRight | Direction::RightLeft => match slot {
            OverflowSide::LeftOrTop => Face::Top,
            OverflowSide::RightOrBottom => Face::Bottom,
        },
    }
}

/// Canonical backward channel for backward-channel policy.
pub fn canonical_backward_channel_face(direction: Direction) -> Face {
    match direction {
        Direction::TopDown | Direction::BottomTop => Face::Right,
        Direction::LeftRight | Direction::RightLeft => Face::Bottom,
    }
}

/// Primary incoming target face for forward edges under fan-in policy.
pub fn fan_in_primary_target_face(direction: Direction) -> Face {
    match direction {
        Direction::TopDown => Face::Top,
        Direction::BottomTop => Face::Bottom,
        Direction::LeftRight => Face::Left,
        Direction::RightLeft => Face::Right,
    }
}

fn fan_in_non_canonical_overflow_face(direction: Direction) -> Face {
    match direction {
        Direction::TopDown | Direction::BottomTop => Face::Left,
        Direction::LeftRight | Direction::RightLeft => Face::Top,
    }
}

/// Resolve a target/source face with explicit precedence when both fan-in overflow and
/// backward channels are in contention.
pub fn resolve_overflow_backward_channel_conflict(
    direction: Direction,
    is_backward: bool,
    target_has_backward_conflict: bool,
    overflow_face: Option<Face>,
    proposed_face: Face,
) -> Face {
    if !is_backward || overflow_face.is_none() {
        if target_has_backward_conflict
            && overflow_face.is_some()
            && proposed_face == canonical_backward_channel_face(direction)
        {
            return fan_in_non_canonical_overflow_face(direction);
        }
        return proposed_face;
    }

    canonical_backward_channel_face(direction)
}

/// Whether a backward edge should prefer the canonical backward side channel.
pub fn prefer_backward_side_channel(
    is_backward: bool,
    has_layout_waypoints: bool,
    rank_span: Option<usize>,
) -> bool {
    if !is_backward {
        return false;
    }
    if rank_span.is_some_and(|span| span >= BACKWARD_SIDE_CHANNEL_LONG_RANK_SPAN) {
        return true;
    }
    !has_layout_waypoints
}

/// Whether TD/BT backward hint-parity overrides can be applied safely.
pub fn can_apply_td_bt_backward_hint_parity(
    direction: Direction,
    is_backward: bool,
    has_subgraph_endpoint: bool,
    rank_span: usize,
    source_rect: FRect,
    target_rect: FRect,
    source_center_x: f64,
) -> bool {
    if !matches!(direction, Direction::TopDown | Direction::BottomTop) {
        return false;
    }
    if has_subgraph_endpoint {
        return false;
    }
    if prefer_backward_side_channel(is_backward, true, Some(rank_span)) {
        return false;
    }
    if source_rect.width < TD_BT_PARITY_MIN_RECT_SPAN
        || source_rect.height < TD_BT_PARITY_MIN_RECT_SPAN
        || target_rect.width < TD_BT_PARITY_MIN_RECT_SPAN
        || target_rect.height < TD_BT_PARITY_MIN_RECT_SPAN
    {
        return false;
    }

    let target_right = target_rect.x + target_rect.width;
    source_center_x <= target_right
}

/// Classify which face a point approaches, using slope-vs-diagonal comparison.
pub fn classify_face_float(center: FPoint, rect: FRect, approach: FPoint) -> Face {
    let dx = approach.x - center.x;
    let dy = approach.y - center.y;

    if dx.abs() < 0.5 && dy.abs() < 0.5 {
        return Face::Bottom;
    }

    let half_w = rect.width / 2.0;
    let half_h = rect.height / 2.0;

    if dy.abs() * half_w > dx.abs() * half_h {
        if dy < 0.0 { Face::Top } else { Face::Bottom }
    } else if dx < 0.0 {
        Face::Left
    } else {
        Face::Right
    }
}

/// Compute a point on a rectangle face at the given fraction.
pub fn point_on_face_float(rect: FRect, face: Face, fraction: f64) -> FPoint {
    let fraction = fraction.clamp(0.0, 1.0);
    match face {
        Face::Top => FPoint::new(rect.x + rect.width * fraction, rect.y),
        Face::Bottom => FPoint::new(rect.x + rect.width * fraction, rect.y + rect.height),
        Face::Left => FPoint::new(rect.x, rect.y + rect.height * fraction),
        Face::Right => FPoint::new(rect.x + rect.width, rect.y + rect.height * fraction),
    }
}

/// Per-edge attachment location on a node face.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeAttachment {
    pub face: Face,
    pub fraction: f64,
}

/// Source and target attachment assignments for one edge.
#[derive(Debug, Clone, PartialEq)]
pub struct PlannedEdgeAttachments {
    pub source: Option<EdgeAttachment>,
    pub target: Option<EdgeAttachment>,
}

/// Deterministic attachment assignments for all planned edges.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AttachmentPlan {
    edge_attachments: HashMap<usize, PlannedEdgeAttachments>,
    group_sizes: HashMap<(String, Face), usize>,
    source_fractions: HashMap<(String, Face), Vec<f64>>,
    target_fractions: HashMap<(String, Face), Vec<f64>>,
}

impl AttachmentPlan {
    /// Return source-side fractions for a node face in deterministic order.
    #[cfg(test)]
    #[allow(dead_code)]
    pub fn source_fractions_for(&self, node_id: &str, face: Face) -> Vec<f64> {
        self.source_fractions
            .get(&(node_id.to_string(), face))
            .cloned()
            .unwrap_or_default()
    }

    /// Return the edge-specific source/target assignments.
    pub fn edge(&self, edge_index: usize) -> Option<&PlannedEdgeAttachments> {
        self.edge_attachments.get(&edge_index)
    }

    /// Return the number of attachments planned for a node face.
    pub fn group_size(&self, node_id: &str, face: Face) -> usize {
        self.group_sizes
            .get(&(node_id.to_string(), face))
            .copied()
            .unwrap_or(0)
    }

    pub fn attachments(&self) -> impl Iterator<Item = (&usize, &PlannedEdgeAttachments)> + '_ {
        self.edge_attachments.iter()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AttachmentSide {
    Source,
    Target,
}

#[derive(Debug, Clone, PartialEq)]
pub struct AttachmentCandidate {
    pub edge_index: usize,
    pub node_id: String,
    pub side: AttachmentSide,
    pub face: Face,
    pub cross_axis: f64,
}

pub fn plan_attachment_candidates(candidates: Vec<AttachmentCandidate>) -> AttachmentPlan {
    let mut groups: HashMap<(String, Face), Vec<AttachmentCandidate>> = HashMap::new();
    for candidate in candidates {
        groups
            .entry((candidate.node_id.clone(), candidate.face))
            .or_default()
            .push(candidate);
    }

    let mut plan = AttachmentPlan::default();
    for ((node_id, face), mut group) in groups {
        group.sort_by(compare_attachment_candidates);
        plan.group_sizes
            .insert((node_id.clone(), face), group.len());

        for (idx, candidate) in group.iter().enumerate() {
            let fraction = if group.len() <= 1 {
                0.5
            } else {
                idx as f64 / (group.len() - 1) as f64
            };
            let attachment = EdgeAttachment { face, fraction };
            let edge_entry = plan.edge_attachments.entry(candidate.edge_index).or_insert(
                PlannedEdgeAttachments {
                    source: None,
                    target: None,
                },
            );

            match candidate.side {
                AttachmentSide::Source => {
                    edge_entry.source = Some(attachment);
                    plan.source_fractions
                        .entry((candidate.node_id.clone(), candidate.face))
                        .or_default()
                        .push(fraction);
                }
                AttachmentSide::Target => {
                    edge_entry.target = Some(attachment);
                    plan.target_fractions
                        .entry((candidate.node_id.clone(), candidate.face))
                        .or_default()
                        .push(fraction);
                }
            }
        }
    }
    plan
}

fn compare_attachment_candidates(
    a: &AttachmentCandidate,
    b: &AttachmentCandidate,
) -> std::cmp::Ordering {
    a.cross_axis
        .total_cmp(&b.cross_axis)
        .then_with(|| a.edge_index.cmp(&b.edge_index))
        .then_with(|| a.side.cmp(&b.side))
}

pub fn edge_faces(direction: Direction, is_backward: bool) -> (Face, Face) {
    let (forward_src, forward_tgt) = match direction {
        Direction::TopDown => (Face::Bottom, Face::Top),
        Direction::BottomTop => (Face::Top, Face::Bottom),
        Direction::LeftRight => (Face::Right, Face::Left),
        Direction::RightLeft => (Face::Left, Face::Right),
    };

    if is_backward {
        (forward_tgt, forward_src)
    } else {
        (forward_src, forward_tgt)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn port_face_round_trips() {
        assert_eq!(PortFace::Top.as_str(), "top");
        assert_eq!(PortFace::Bottom.as_str(), "bottom");
        assert_eq!(PortFace::Left.as_str(), "left");
        assert_eq!(PortFace::Right.as_str(), "right");
        assert_eq!("top".parse::<PortFace>(), Ok(PortFace::Top));
        assert_eq!("bottom".parse::<PortFace>(), Ok(PortFace::Bottom));
        assert_eq!("left".parse::<PortFace>(), Ok(PortFace::Left));
        assert_eq!("right".parse::<PortFace>(), Ok(PortFace::Right));
        assert_eq!("invalid".parse::<PortFace>(), Err(()));
    }

    #[test]
    fn edge_port_construction() {
        let port = EdgePort {
            face: PortFace::Top,
            fraction: 0.5,
            position: FPoint::new(50.0, 10.0),
            group_size: 1,
        };
        assert_eq!(port.face, PortFace::Top);
        assert!((port.fraction - 0.5).abs() < f64::EPSILON);
        assert_eq!(port.position, FPoint::new(50.0, 10.0));
        assert_eq!(port.group_size, 1);
    }

    #[test]
    fn prefer_backward_side_channel_uses_no_waypoint_fallback() {
        assert!(prefer_backward_side_channel(true, false, None));
        assert!(!prefer_backward_side_channel(true, true, None));
    }

    #[test]
    fn prefer_backward_side_channel_uses_long_span_override() {
        assert!(prefer_backward_side_channel(
            true,
            true,
            Some(BACKWARD_SIDE_CHANNEL_LONG_RANK_SPAN)
        ));
        assert!(!prefer_backward_side_channel(
            true,
            true,
            Some(BACKWARD_SIDE_CHANNEL_LONG_RANK_SPAN - 1)
        ));
    }

    #[test]
    fn prefer_backward_side_channel_ignores_forward_edges() {
        assert!(!prefer_backward_side_channel(false, false, Some(10)));
    }

    #[test]
    fn td_bt_backward_hint_parity_requires_safe_geometry() {
        let source_rect = FRect::new(10.0, 10.0, 40.0, 40.0);
        let target_rect = FRect::new(20.0, 0.0, 40.0, 40.0);
        let source_center_x = source_rect.x + source_rect.width / 2.0;

        assert!(can_apply_td_bt_backward_hint_parity(
            Direction::TopDown,
            true,
            false,
            2,
            source_rect,
            target_rect,
            source_center_x
        ));
    }

    #[test]
    fn td_bt_backward_hint_parity_rejects_long_span_and_crossing_topology() {
        let source_rect = FRect::new(80.0, 10.0, 40.0, 40.0);
        let target_rect = FRect::new(10.0, 0.0, 40.0, 40.0);
        let source_center_x = source_rect.x + source_rect.width / 2.0;

        assert!(!can_apply_td_bt_backward_hint_parity(
            Direction::TopDown,
            true,
            false,
            BACKWARD_SIDE_CHANNEL_LONG_RANK_SPAN,
            source_rect,
            target_rect,
            source_center_x
        ));
        assert!(!can_apply_td_bt_backward_hint_parity(
            Direction::TopDown,
            true,
            false,
            2,
            source_rect,
            target_rect,
            source_center_x
        ));
    }
}