Skip to main content

yui_link/link/
builder.rs

1//! [`LinkBuilder`]: assembles a diagram by adding nodes and connecting
2//! [`Port`]s. `build()` rejects unconnected or duplicated ports and non-planar wirings.
3
4use std::collections::{BTreeMap, HashMap};
5use std::fmt::Display;
6
7use petgraph::stable_graph::{StableDiGraph, NodeIndex, EdgeIndex};
8use yui_core::algo::UnionFind;
9
10use crate::{Link, Node, NodeType, Edge, Slot};
11
12// A port is slot `s` (0..4, CCW) of vertex `v`. For a crossing the slots are
13//     3   2
14//      \ /          under-strand 0 → 2, over-strand 1 → 3  (PD convention)
15//      / \
16//     0   1
17pub type Port = (NodeIndex, usize);
18
19// Two-phase builder for `Link`: add nodes (vertices weighted by `NodeType`), `connect` their ports
20// pairwise (graph edges, weighted by the slot at each end), then `build()` validates and orients.
21#[derive(Debug, Default)]
22pub struct LinkBuilder {
23    graph: StableDiGraph<NodeType, (usize, usize)>,
24    loops: usize,
25}
26
27impl LinkBuilder {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn n_nodes(&self) -> usize {
33        self.graph.node_count()
34    }
35
36    // Add a node of the given type; returns its vertex. Its ports are (vertex, 0..4) in CCW order.
37    pub fn add_node(&mut self, node_type: NodeType) -> NodeIndex {
38        self.graph.add_node(node_type)
39    }
40
41    pub fn add_crossing(&mut self, node_type: NodeType) -> NodeIndex {
42        assert!(matches!(node_type, NodeType::XL | NodeType::XR), "add_crossing expects XL or XR, got {node_type}");
43        self.add_node(node_type)
44    }
45
46    // Join two ports into one edge, recording the slot at each end.
47    pub fn connect(&mut self, a: Port, b: Port) {
48        assert!(a.1 < 4 && b.1 < 4, "slots must be 0..4, got {} and {}", a.1, b.1);
49        self.graph.add_edge(a.0, b.0, (a.1, b.1));
50    }
51
52    // Add a free loop (a closed component with no crossings).
53    pub fn add_loop(&mut self) {
54        self.loops += 1;
55    }
56
57    // A column of `k ≥ 1` crossings of type `tt`, chained bottom→top; returns the column's four
58    // corner ports in CCW slot order (SW, SE, NE, NW) — for k = 1 the crossing's own ports.
59    pub fn add_v_twist(&mut self, tt: NodeType, k: usize) -> (Port, Port, Port, Port) {
60        assert!(k >= 1, "a twist needs at least one crossing");
61        let v: Vec<_> = (0..k).map(|_| self.add_crossing(tt)).collect();
62        for w in v.windows(2) {
63            self.connect((w[0], 3), (w[1], 0));
64            self.connect((w[0], 2), (w[1], 1));
65        }
66        ((v[0], 0), (v[0], 1), (v[k - 1], 2), (v[k - 1], 3))
67    }
68
69    // A row of `k ≥ 1` crossings of type `tt`, chained left→right; returns the row's four
70    // corner ports in CCW slot order (SW, SE, NE, NW) — for k = 1 the crossing's own ports.
71    pub fn add_h_twist(&mut self, tt: NodeType, k: usize) -> (Port, Port, Port, Port) {
72        assert!(k >= 1, "a twist needs at least one crossing");
73        let h: Vec<_> = (0..k).map(|_| self.add_crossing(tt)).collect();
74        for w in h.windows(2) {
75            self.connect((w[0], 1), (w[1], 0));
76            self.connect((w[0], 2), (w[1], 3));
77        }
78        ((h[0], 0), (h[k - 1], 1), (h[k - 1], 2), (h[0], 3))
79    }
80
81    // Absorb all nodes, edges and free loops of `l`; returns its node-index → builder-vertex map (so
82    // port `(verts[i], s)` is node i's slot s), for later rewiring.
83    pub fn add_link(&mut self, l: &Link) -> Vec<NodeIndex> {
84        let verts: Vec<NodeIndex> = l.nodes().map(|x| self.add_node(x.node_type())).collect();
85
86        // group the two occurrences of each edge, sorted by edge id for deterministic numbering.
87        let occ = l.nodes().enumerate().flat_map(|(i, x)| {
88            let v = verts[i];
89            Slot::ALL.map(move |s| (x.edge(s), (v, s.index())))
90        }).fold(BTreeMap::<Edge, Vec<Port>>::new(), |mut occ, (e, p)| {
91            occ.entry(e).or_default().push(p);
92            occ
93        });
94
95        for ports in occ.values() {
96            self.connect(ports[0], ports[1]);
97        }
98        for _ in l.loops() {
99            self.add_loop();
100        }
101        verts
102    }
103
104    // Remove the edge incident to port `p` (a connected port has exactly one).
105    pub fn disconnect(&mut self, p: Port) {
106        let e = self.graph.edge_indices().find(|&e| {
107            let (a, b) = self.ends(e);
108            a == p || b == p
109        }).unwrap_or_else(||
110            panic!("port (node {}, slot {}) has no edge to disconnect", p.0.index(), p.1)
111        );
112        self.graph.remove_edge(e);
113    }
114
115    // The `Edge` id `build()` will give the edge incident to port `p` (build() numbers edges 1.. in
116    // `edge_indices()` order). Call after the final wiring so the numbering matches.
117    pub fn edge_at(&self, p: Port) -> Option<Edge> {
118        self.graph.edge_indices().enumerate().find_map(|(i, e)| {
119            let (a, b) = self.ends(e);
120            (a == p || b == p).then_some(i as Edge + 1)
121        })
122    }
123
124    pub fn build(self) -> Result<Link, LinkError> {
125        self.build_with(|_, _| true) // any port may be incoming: each direction is a free choice
126    }
127
128    // Build with orientation knowledge: `is_incoming(i, s)` tells whether slot `s` of node `i`
129    // (in insertion order) receives an incoming strand — see `Link::reorient`.
130    pub fn build_with<F>(self, is_incoming: F) -> Result<Link, LinkError>
131    where F: Fn(usize, usize) -> bool {
132        self.validate()?;
133
134        // number the edges 1.., recording each id at its two ports.
135        let edge_at: HashMap<Port, Edge> = self.graph.edge_indices().enumerate().flat_map(|(i, e)| {
136            let (a, b) = self.ends(e);
137            let id = i as Edge + 1;
138            [(a, id), (b, id)]
139        }).collect();
140
141        let nodes = self.graph.node_indices().map(|v|
142            Node::new(self.graph[v], None, [0, 1, 2, 3].map(|s| edge_at[&(v, s)]))
143        );
144
145        let e0 = self.graph.edge_count() as Edge + 1;
146        let loops = e0 .. e0 + self.loops as Edge;
147
148        let mut l = Link::new(nodes.collect::<Vec<_>>(), loops);
149        l.reorient(|i, s| is_incoming(i, s.index()));
150        Ok(l)
151    }
152
153    // All build-blocking defects, most specific first: doubly-used / open ports, then genus.
154    fn validate(&self) -> Result<(), LinkError> {
155        let alpha = self.edge_pairing()?;
156        if self.is_planar_with(&alpha) {
157            Ok(())
158        } else {
159            Err(LinkError::NonPlanar)
160        }
161    }
162
163    // False if any port is open or doubly connected.
164    pub fn is_planar(&self) -> bool {
165        self.edge_pairing().map(|alpha|
166            self.is_planar_with(&alpha)
167        ).unwrap_or(false)
168    }
169
170    // A planar (genus-0) wiring satisfies V − E + F = 2·#components (Euler, componentwise). The
171    // faces are the orbits of φ = rotate ∘ α on the 4V ports, where α pairs the two ends of each
172    // edge and rotate steps to the next CCW slot.
173    fn is_planar_with(&self, alpha: &HashMap<Port, Port>) -> bool {
174        let (v, e) = (self.graph.node_count(), self.graph.edge_count());
175
176        let dart = |(n, s): &Port| 4 * n.index() + s;
177        let phi = |p: &Port| {
178            let (n, s) = alpha[p];
179            (n, (s + 1) % 4)
180        };
181
182        // count orbits by union-find: faces are the orbits of φ, components those of α (per node).
183        let mut faces = UnionFind::new(4 * v);
184        let mut comps = UnionFind::new(v);
185        for p in alpha.keys() {
186            faces.union(dart(p), dart(&phi(p)));
187            comps.union(p.0.index(), alpha[p].0.index());
188        }
189        let (f, c) = (faces.into_disjoint().len(), comps.into_disjoint().len());
190
191        (v + f) as isize - e as isize == 2 * c as isize
192    }
193
194    // α: pairs each port with the port at the other end of its edge; errs unless every port is
195    // connected exactly once.
196    fn edge_pairing(&self) -> Result<HashMap<Port, Port>, LinkError> {
197        let mut alpha = HashMap::new();
198        for e in self.graph.edge_indices() {
199            let (a, b) = self.ends(e);
200            for (p, q) in [(a, b), (b, a)] {
201                if alpha.insert(p, q).is_some() {
202                    return Err(LinkError::DuplicatePort(p));
203                }
204            }
205        }
206
207        let open = self.graph.node_indices().flat_map(|v|
208            (0..4).map(move |s| (v, s))
209        ).find(|p|
210            !alpha.contains_key(p)
211        );
212        match open {
213            Some(p) => Err(LinkError::UnconnectedPort(p)),
214            None => Ok(alpha),
215        }
216    }
217
218    // The two ports of a graph edge.
219    fn ends(&self, e: EdgeIndex) -> (Port, Port) {
220        let (s, t) = self.graph.edge_endpoints(e).unwrap();
221        let &(fs, ts) = self.graph.edge_weight(e).unwrap();
222        ((s, fs), (t, ts))
223    }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum LinkError {
228    // A port was never connected.
229    UnconnectedPort(Port),
230    // A port was claimed by two edges (e.g. a self-edge with both ends on the same slot).
231    DuplicatePort(Port),
232    // The wiring has positive genus (fails Euler's formula for its ribbon structure).
233    NonPlanar,
234}
235
236impl Display for LinkError {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        match self {
239            LinkError::UnconnectedPort((v, s)) =>
240                write!(f, "port (node {}, slot {s}) is not connected", v.index()),
241            LinkError::DuplicatePort((v, s)) =>
242                write!(f, "port (node {}, slot {s}) is connected more than once", v.index()),
243            LinkError::NonPlanar =>
244                write!(f, "the wiring is non-planar (genus > 0)"),
245        }
246    }
247}
248
249impl std::error::Error for LinkError {}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::misc::jones_polynomial;
255
256    fn rebuild(l: &Link) -> Link {
257        let mut b = LinkBuilder::new();
258        b.add_link(l);
259        b.build().unwrap()
260    }
261
262    #[test]
263    fn round_trip_preserves_knot() {
264        for name in ["3_1", "4_1", "5_2", "6_2"] {
265            let l = Link::test_data(name);
266            let r = rebuild(&l);
267            assert_eq!(r.n_comps(), l.n_comps());
268            assert!(r.is_oriented());
269            assert_eq!(jones_polynomial(&r), jones_polynomial(&l), "round-trip changed {name}");
270        }
271    }
272
273    #[test]
274    fn round_trip_orients_unlink2() {
275        // unlink2 loads unoriented (its over-component has no under-anchor in the PD code), but the
276        // builder orients freely; it also exercises reorient's coverage of never-under components.
277        let l = Link::test_data("unlink2");
278        assert!(!l.is_oriented());
279
280        let r = rebuild(&l);
281        assert_eq!(r.n_comps(), 2);
282        assert!(r.is_oriented());
283        assert_eq!(jones_polynomial(&r), jones_polynomial(&Link::unlink(2)));
284    }
285
286    #[test]
287    fn is_planar_detects_genus() {
288        // a kink (adjacent self-connections) is planar; connecting opposite ports is genus 1.
289        let mut planar = LinkBuilder::new();
290        let x = planar.add_crossing(NodeType::XR);
291        planar.connect((x, 0), (x, 1));
292        planar.connect((x, 2), (x, 3));
293        assert!(planar.is_planar());
294
295        let mut torus = LinkBuilder::new();
296        let y = torus.add_crossing(NodeType::XR);
297        torus.connect((y, 0), (y, 2));
298        torus.connect((y, 1), (y, 3));
299        assert!(!torus.is_planar());
300        assert!(matches!(torus.build(), Err(LinkError::NonPlanar)));
301    }
302
303    #[test]
304    fn is_planar_accepts_real_knots() {
305        // every from_pd_code diagram is genuinely planar, so is_planar must accept all of them.
306        for name in ["3_1", "4_1", "5_1", "5_2", "6_1", "6_2", "6_3", "7_1", "7_2"] {
307            let mut b = LinkBuilder::new();
308            b.add_link(&Link::test_data(name));
309            assert!(b.is_planar(), "{name} is planar but is_planar returned false");
310        }
311    }
312
313    #[test]
314    fn build_with_free_loop() {
315        let mut b = LinkBuilder::new();
316        b.add_loop();
317        let l = b.build().unwrap();
318        assert_eq!(l.n_comps(), 1);
319        assert_eq!(l.n_crossings(), 0);
320    }
321
322    #[test]
323    fn unconnected_port_errors() {
324        let mut b = LinkBuilder::new();
325        b.add_crossing(NodeType::XR);
326        assert!(matches!(b.build(), Err(LinkError::UnconnectedPort(_))));
327    }
328}