Skip to main content

yui_link/link/
link_ops.rs

1//! Operations *on* a link — deriving a new diagram or a combinatorial object from the one at hand:
2//! crossing changes, resolutions, and Seifert's algorithm. Contrast with [`crate::link::construct`],
3//! which builds links out of patterns.
4
5use petgraph::Graph;
6use yui_core::num::Sign;
7use yui_core::ext::CloneAnd;
8use yui_core::bitseq::Bit;
9
10use super::{Edge, Link, LinkBuilder, NodeType, Path, Slot, State};
11
12impl Link {
13    // Connected sum at the two base points (a PD-built link defaults to its minimal edge).
14    pub fn conn_sum(&self, other: &Link) -> Link {
15        let self_e = self.base_pt().expect("self needs a base point");
16        let other_e = other.base_pt().expect("other needs a base point");
17        self.conn_sum_at(other, self_e, other_e)
18    }
19
20    // Connected sum at edges `self_e`/`other_e`: import both links, then re-splice the two edges
21    // tail-to-head (self out → other in, other out → self in), so the orientations are compatible.
22    pub fn conn_sum_at(&self, other: &Link, self_e: Edge, other_e: Edge) -> Link {
23        assert!(self.is_oriented(),  "conn_sum requires an oriented link (self)");
24        assert!(other.is_oriented(), "conn_sum requires an oriented link (other)");
25        assert!(
26            !self.loops().contains(&self_e) && !other.loops().contains(&other_e),
27            "connected sum on free loops is not supported yet"
28        );
29
30        let mut b = LinkBuilder::new();
31        let v1 = b.add_link(self);
32        let v2 = b.add_link(other);
33
34        // the (tail, head) ends of each spliced edge, as builder ports.
35        let port = |verts: &[_], (i, s): (usize, Slot)| (verts[i], s.index());
36        let (t1, h1) = self.edge_ends(self_e, true);
37        let (t2, h2) = other.edge_ends(other_e, true);
38        let (t1, h1) = (port(&v1, t1), port(&v1, h1));
39        let (t2, h2) = (port(&v2, t2), port(&v2, h2));
40
41        // The builder reuses freed edge slots last-in-first-out, so the *second* `connect` takes
42        // the slot freed *first*: the incoming band edge gets the lower id. With both summands
43        // numbered along the strand from edge 1, the sum comes out numbered the same way — band in
44        // as 1, self's own 2..n1, band out as n1 + 1, then other's.
45        b.disconnect(h1);   // free self_e's two ports
46        b.disconnect(h2);   // free other_e's two ports
47        b.connect(t1, h2);  // self tail → other head (outgoing from self)
48        b.connect(t2, h1);  // other tail → self head (incoming to self)
49
50        // A base point elsewhere survives; one on the consumed `self_e` moves to the band edge
51        // entering `self`, so the traversal covers `self` first. Read the ids before `build` renumbers.
52        let base = self.base_pt().map(|e|
53            if e != self_e {
54                b.edge_at(port(&v1, self.edge_ends(e, false).0)).unwrap()
55            } else {
56                b.edge_at(h1).unwrap()
57            }
58        );
59
60        // Each node keeps its own summand's incoming slots; plain `build` would be free to reverse
61        // the whole diagram.
62        let n1 = self.n_nodes();
63        let sum = b.build_with(|i, s| {
64            let (l, i) = if i < n1 { (self, i) } else { (other, i - n1) };
65            l.node(i).is_incoming(Slot::from(s))
66        }).unwrap();
67
68        match base {
69            Some(e) => sum.with_base_pt(e),
70            None => sum,
71        }
72    }
73
74    pub fn mirror(&self) -> Self {
75        let l = Self::new(
76            self.nodes().map(|x| x.mirror()),
77            self.loops().iter().copied(),
78        );
79        // `new` defaults the base point to the minimal edge; mirroring preserves the edge set,
80        // so re-imposing the original one is always valid.
81        match self.base_pt() {
82            Some(e) => l.with_base_pt(e),
83            None => l,
84        }
85    }
86
87    // Reverse the orientation. Both strands of every crossing turn around together, so the signs
88    // — hence the writhe — are unchanged; only the direction of travel is.
89    pub fn reversed(&self) -> Self {
90        let l = Self::new(
91            self.nodes().map(|x| x.reversed()),
92            self.loops().iter().copied(),
93        );
94        // `new` defaults the base point to the minimal edge; reversing keeps the edge set, so the
95        // original one is still valid.
96        match self.base_pt() {
97            Some(e) => l.with_base_pt(e),
98            None => l,
99        }
100    }
101
102    pub fn cc_at(&self, i: usize) -> Self {
103        assert!(self.node(i).is_crossing());
104        self.clone_and(|l|
105            *l.node_mut(i) = l.node(i).mirror()
106        )
107    }
108
109    pub fn resolve_at(&self, i: usize, r: Bit) -> Self {
110        assert!(self.node(i).is_crossing());
111        self.clone_and(|l| {
112            *l.node_mut(i) = l.node(i).resolve(r);
113            l.normalize_ori();
114        })
115    }
116
117    pub fn resolve_by(&self, s: &State) -> Self {
118        assert!(s.len() == self.n_crossings());
119
120        let n = self.n_nodes();
121        let itr = (0..n).filter(|&i| self.node(i).is_crossing());
122
123        self.clone_and(|l| {
124            for (i, r) in Iterator::zip(itr, s.iter()) {
125                *l.node_mut(i) = self.node(i).resolve(r);
126            }
127            l.normalize_ori();
128        })
129    }
130
131    pub fn seifert_state(&self) -> State {
132        assert!(self.is_oriented());
133
134        let seq = self.crossings().map(|x|
135            match x.sign() {
136                Some(Sign::Pos) => 0,
137                Some(Sign::Neg) => 1,
138                None => panic!("Impossible.")
139            }
140        );
141        State::from_iter(seq)
142    }
143
144    pub fn seifert_circles(&self) -> Vec<Path> {
145        self.resolve_by(&self.seifert_state()).comps()
146    }
147
148    pub fn seifert_graph(&self) -> Graph<Path, usize> {
149        assert!(self.is_oriented());
150
151        type G = Graph<Path, usize>;
152
153        let s0 = self.seifert_state();
154        let l0 = self.resolve_by(&s0);
155        let mut graph = Graph::new();
156
157        // Vertices = Seifert circles (and free loops contribute their own circles).
158        for c in l0.comps() {
159            graph.add_node(c);
160        }
161
162        let find_node = |graph: &G, e| {
163            graph.node_indices().find(|&i|
164                graph[i].contains(e)
165            )
166        };
167
168        // Edges = one per original crossing (now resolved into a V/H smoothing).
169        // Free loops have no nodes, so they remain isolated vertices.
170        for (i, x) in l0.nodes().enumerate() {
171            let (e1, e2) = if x.node_type() == NodeType::V {
172                (x.edge(Slot::SW), x.edge(Slot::SE))
173            } else {
174                (x.edge(Slot::SW), x.edge(Slot::NE))
175            };
176            let n1 = find_node(&graph, e1).unwrap();
177            let n2 = find_node(&graph, e2).unwrap();
178            graph.add_edge(n1, n2, i);
179        }
180
181        graph
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::{Braid, Node};
189    use crate::NodeType::{XL, XR};
190    use crate::misc::jones_polynomial;
191
192    #[test]
193    fn link_reversed() {
194        let l = Link::test_data("3_1");
195        let r = l.reversed();
196
197        assert!(r.is_oriented());
198        assert_eq!(r.writhe(), l.writhe(), "reversing a knot keeps every crossing sign");
199        assert_eq!(r.base_pt(), l.base_pt());
200
201        // the underlying diagram is untouched — only the direction of travel changes.
202        for (x, y) in Iterator::zip(l.nodes(), r.nodes()) {
203            assert_eq!(y.node_type(), x.node_type());
204            assert_eq!(y.edges(), x.edges());
205        }
206        assert_eq!(r.reversed(), l, "reversing twice is the identity");
207
208        // the two incoming slots move to the far end of their own strands.
209        for (x, y) in Iterator::zip(l.nodes(), r.nodes()) {
210            let (p, q) = x.incoming().unwrap();
211            assert_eq!(y.incoming(), Some((x.paired_slot(p).min(x.paired_slot(q)),
212                                           x.paired_slot(p).max(x.paired_slot(q)))));
213        }
214    }
215
216    #[test]
217    fn link_mirror() {
218        let l = Link::test_data("unknot_l_twist");
219        assert_eq!(l.node(0).node_type(), XL);
220
221        let l = l.mirror();
222        assert_eq!(l.node(0).node_type(), XR);
223    }
224
225    #[test]
226    fn mirror_preserves_loops() {
227        let l = Link::unlink(1).mirror();
228        assert_eq!(l.n_loops(), 1);
229        assert_eq!(l.loops(), &[1]);
230    }
231
232    #[test]
233    fn mirror_preserves_base_pt() {
234        let l = Link::test_data("3_1").with_base_pt(3).mirror();
235        assert_eq!(l.base_pt(), Some(3));
236    }
237
238    #[test]
239    fn crossing_change() {
240        let l = Link::test_data("3_1");
241        let l2 = l.cc_at(1);
242
243        assert_eq!(l.node(1),  &Node::new(XL, Some((Slot::SW, Slot::NW)), [3,1,4,6]));
244        assert_eq!(l2.node(1), &Node::new(XR, Some((Slot::SW, Slot::NW)), [3,1,4,6]));
245    }
246
247    #[test]
248    fn resolve_drops_the_orientation_wholesale() {
249        // a smoothing that does not respect the orientation costs the whole diagram its own; only
250        // the Seifert state keeps it.
251        let l = Link::test_data("3_1");
252        assert_eq!(l.seifert_state(), State::from([0, 0, 0]));
253
254        assert!(l.resolve_by(&State::from([0, 0, 0])).is_oriented());
255        for st in [[0, 0, 1], [0, 1, 1], [1, 1, 1]] {
256            let r = l.resolve_by(&State::from(st));
257            assert!(!r.is_oriented(), "{st:?} left a partial orientation");
258            r.verify_ori();
259        }
260    }
261
262    #[test]
263    fn link_resolve() {
264        let s = State::from([0, 0, 0]);
265        let l = Link::test_data("3_1").resolve_by(&s);
266
267        let comps = l.comps();
268        assert_eq!(comps.len(), 2);
269        assert!(comps.iter().all(|c| c.is_circle()));
270
271        let s = State::from([1, 1, 1]);
272        let l = Link::test_data("3_1").resolve_by(&s);
273
274        let comps = l.comps();
275        assert_eq!(comps.len(), 3);
276        assert!(comps.iter().all(|c| c.is_circle()));
277    }
278
279    #[test]
280    fn seifert_graph_trefoil() {
281        let l = Link::test_data("3_1");
282        let g = l.seifert_graph();
283        assert_eq!(g.node_count(), 2);
284        assert_eq!(g.edge_count(), 3);
285    }
286
287    #[test]
288    fn seifert_graph_unlink() {
289        // Free loops contribute isolated vertices and no edges.
290        let l = Link::unlink(3);
291        let g = l.seifert_graph();
292        assert_eq!(g.node_count(), 3);
293        assert_eq!(g.edge_count(), 0);
294    }
295
296    #[test]
297    fn conn_sum_is_jones_multiplicative() {
298        // unreduced Jones: Ṽ(K1 # K2) · Ṽ(unknot) = Ṽ(K1) · Ṽ(K2)
299        let k1 = Link::test_data("3_1");
300        let k2 = Link::test_data("4_1");
301        let cs = k1.conn_sum(&k2); // at the default base points (edge 1 each)
302        assert_eq!(cs.n_comps(), 1);
303        assert_eq!(cs.n_crossings(), k1.n_crossings() + k2.n_crossings());
304        assert!(cs.is_oriented());
305
306        let (vcs, vu) = (jones_polynomial(&cs), jones_polynomial(&Link::unknot()));
307        let (v1, v2) = (jones_polynomial(&k1), jones_polynomial(&k2));
308        assert_eq!(&vcs * &vu, &v1 * &v2);
309
310        // the connected sum does not depend on the chosen edges.
311        let cs2 = k1.conn_sum_at(&k2, 4, 6);
312        assert_eq!(jones_polynomial(&cs2), jones_polynomial(&cs));
313    }
314
315    #[test]
316    fn conn_sum_of_braid_closures() {
317        // braid closures orient downward, exercising the non-PD arms of `edge_ends`.
318        let k1 = Braid::from([1, 1, 1]).closure();      // 3_1
319        let k2 = Braid::from([1, -2, 1, -2]).closure(); // 4_1
320        let cs = k1.conn_sum(&k2);
321        assert_eq!(cs.n_comps(), 1);
322        assert!(cs.is_oriented());
323
324        let (vcs, vu) = (jones_polynomial(&cs), jones_polynomial(&Link::unknot()));
325        let (v1, v2) = (jones_polynomial(&k1), jones_polynomial(&k2));
326        assert_eq!(&vcs * &vu, &v1 * &v2);
327    }
328
329    // The four curl sums below need no reindexing: the band edges are 1 and 3, each curl keeps
330    // its own loop (2 and 4), and the base point moves to the band edge entering `self`.
331
332    #[test]
333    fn conn_sum_of_two_curls() {
334        // The 1-crossing left curl with itself: the two-curl unknot diagram.
335        let k = Link::test_data("unknot_l_twist");
336        let cs = k.conn_sum(&k);
337        assert_eq!(cs.pd_code(), [[1,3,2,2],[3,1,4,4]]);
338        assert_eq!(cs.base_pt(), Some(1));
339    }
340
341    #[test]
342    fn conn_sum_of_a_curl_and_its_reverse() {
343        // Reversing the second curl enters the band by its other end: the curls sit head-to-head.
344        let k = Link::test_data("unknot_l_twist");
345        let cs = k.conn_sum(&k.reversed());
346        assert_eq!(cs.pd_code(), [[1,3,2,2],[4,4,1,3]]);
347        assert_eq!(cs.base_pt(), Some(1));
348    }
349
350    #[test]
351    fn conn_sum_of_a_curl_and_its_mirror() {
352        // Mirroring flips the sign but not the direction: the R2-cancelling pair.
353        let k = Link::test_data("unknot_l_twist");
354        let cs = k.conn_sum(&k.mirror());
355        assert_eq!(cs.pd_code(), [[1,3,2,2],[4,3,1,4]]);
356        assert_eq!(cs.base_pt(), Some(1));
357    }
358
359    #[test]
360    fn conn_sum_of_a_curl_and_its_concordance_inverse() {
361        // Mirror *and* reverse: the fourth gluing, and the fourth distinct diagram.
362        let k = Link::test_data("unknot_l_twist");
363        let cs = k.conn_sum(&k.mirror().reversed());
364        assert_eq!(cs.pd_code(), [[1,3,2,2],[3,4,4,1]]);
365        assert_eq!(cs.base_pt(), Some(1));
366    }
367
368    #[test]
369    fn conn_sum_at_of_two_curls_away_from_the_base_pt() {
370        // Splicing at edge 2 — the curl's own loop — spares edge 1, so the base point stays there
371        // and the bands take the freed ids 2 and 4, the incoming one getting the lower.
372        let k = Link::test_data("unknot_l_twist");
373        let cs = k.conn_sum_at(&k, 2, 2);
374        assert_eq!(cs.pd_code(), [[1,1,4,2],[3,3,2,4]]);
375        assert_eq!(cs.base_pt(), Some(1));
376
377        // Splicing off the base leaves the traversal numbering, so this is `unknot_l_twist2` only
378        // after renumbering.
379        assert_eq!(cs.reindexed(1, 1).pd_code(), Link::test_data("unknot_l_twist2").pd_code());
380    }
381
382    #[test]
383    fn conn_sum_at_of_two_curls_on_different_edges() {
384        // Asymmetric splice: self's loop (edge 2) to other's base edge (edge 1). The bands take
385        // the freed ids 2 and 3, the incoming one getting the lower.
386        let k = Link::test_data("unknot_l_twist");
387        let cs = k.conn_sum_at(&k, 2, 1);
388        assert_eq!(cs.pd_code(), [[1,1,3,2],[3,2,4,4]]);
389        assert_eq!(cs.base_pt(), Some(1));
390
391        // the same diagram as [[1,1,2,3],[2,3,4,4]], which neither code is numbered along.
392        let expected = Link::from_pd_code([[1,1,2,3],[2,3,4,4]]);
393        assert_eq!(cs.reindexed(1, 1).pd_code(), expected.reindexed(1, 1).pd_code());
394    }
395
396    #[test]
397    fn dms_construction() {
398        // The `2K # (-2K)` diagram of Dai-Mallick-Stoffregen (`references/DMS.pdf` §1.2), built one
399        // splice at a time. `pd_code` lists crossings in traversal order, so each target goes
400        // through `from_pd_code` first — the listing order is not part of the diagram.
401        let k = Link::from_pd_code([[1,4,2,5],[5,2,6,3],[3,6,4,1]]);
402        let mk = k.mirror();
403
404        // `2K`: the band entering takes edge 1, K keeps 2..6, the band leaving takes 7, and the
405        // second copy follows as 8..12.
406        let k2 = k.conn_sum(&k);
407        assert_eq!(k2.pd_code(), Link::from_pd_code([
408            [1,4,2,5],[5,2,6,3],[3,6,4,7],[7,10,8,11],[11,8,12,9],[9,12,10,1],
409        ]).pd_code());
410        assert_eq!(k2.base_pt(), Some(1));
411
412        // `2K # K` at edge 7, away from the base: the band entering keeps 7, the band leaving takes
413        // the other summand's spliced edge, 12 + 1 = 13.
414        let with_k = k2.conn_sum_at(&k, 7, 1);
415        assert_eq!(with_k.pd_code(), Link::from_pd_code([
416            [1,4,2,5],[5,2,6,3],[3,6,4,13],[7,10,8,11],[11,8,12,9],[9,12,10,1],
417            [13,16,14,17],[17,14,18,15],[15,18,16,7],
418        ]).pd_code());
419        assert_eq!(with_k.base_pt(), Some(1));
420
421        // `2K # m(K)` — the same splice mirrored: the band edges are unchanged, only the three
422        // added crossings switch handedness.
423        let with_mk = k2.conn_sum_at(&mk, 7, 1);
424        assert_eq!(with_mk.pd_code(), Link::from_pd_code([
425            [1,4,2,5],[5,2,6,3],[3,6,4,13],[7,10,8,11],[11,8,12,9],[9,12,10,1],
426            [16,14,17,13],[14,18,15,17],[18,16,7,15],
427        ]).pd_code());
428        assert_eq!(with_mk.base_pt(), Some(1));
429
430        // `2K # m(K) # r(m(K))` at edge 16: the band entering keeps 16, the band leaving takes 19.
431        let dms = with_mk.conn_sum_at(&mk.reversed(), 16, 1);
432        assert_eq!(dms.pd_code(), Link::from_pd_code([
433            [1,4,2,5],[5,2,6,3],[3,6,4,13],[7,10,8,11],[11,8,12,9],[9,12,10,1],
434            [16,14,17,13],[14,18,15,17],[18,19,7,15],
435            [19,21,24,22],[21,23,20,24],[23,16,22,20],
436        ]).pd_code());
437        assert_eq!(dms.base_pt(), Some(1));
438    }
439
440    #[test]
441    fn conn_sum_keeps_each_summand_orientation() {
442        // Every node keeps its own summand's incoming slots (`add_link` appends self's nodes,
443        // then other's). Reversing catches it: a PD diagram has node 0's SW incoming anyway.
444        for (a, b) in [("3_1", "4_1"), ("4_1", "3_1"), ("unknot_l_twist", "3_1"), ("6_2", "5_1")] {
445            for (rev1, rev2) in [(false, false), (true, false), (false, true), (true, true)] {
446                let k1 = Link::test_data(a).clone_and(|l| if rev1 { *l = l.reversed() });
447                let k2 = Link::test_data(b).clone_and(|l| if rev2 { *l = l.reversed() });
448                let cs = k1.conn_sum(&k2);
449                let n1 = k1.n_nodes();
450                let case = format!("{a}{} # {b}{}", if rev1 { "*" } else { "" }, if rev2 { "*" } else { "" });
451
452                for (i, x) in k1.nodes().enumerate() {
453                    assert_eq!(cs.node(i).incoming(), x.incoming(), "{case}: node {i} of {a}");
454                }
455                for (i, x) in k2.nodes().enumerate() {
456                    assert_eq!(cs.node(n1 + i).incoming(), x.incoming(), "{case}: node {i} of {b}");
457                }
458            }
459        }
460    }
461
462    #[test]
463    fn conn_sum_of_based_summands_is_traversal_numbered() {
464        // Summands numbered along the strand from edge 1: the band entering K1 takes edge 1, K1
465        // keeps 2..n1, the band leaving takes n1 + 1, and K2's 2..n2 follow — so the sum is
466        // numbered along the strand from its own base point too.
467        for (a, b) in [("3_1", "4_1"), ("4_1", "3_1"), ("5_1", "6_2"), ("unknot_l_twist", "3_1")] {
468            let (k1, k2) = (Link::test_data(a), Link::test_data(b));
469            assert_eq!(k1.pd_code(), k1.reindexed(1, 1).pd_code(), "{a} is not traversal-numbered");
470            assert_eq!(k2.pd_code(), k2.reindexed(1, 1).pd_code(), "{b} is not traversal-numbered");
471
472            let cs = k1.conn_sum(&k2);
473            assert_eq!(cs.base_pt(), Some(1), "{a} # {b}");
474            assert_eq!(cs.pd_code(), cs.reindexed(1, 1).pd_code(), "{a} # {b}");
475
476            // Edge 1 is the band entering K1, edge n1 + 1 the one leaving it.
477            let (n1, x1) = (k1.n_edges() as Edge, k1.n_nodes());
478            let (tail, head) = cs.edge_ends(1, true);
479            assert!(tail.0 >= x1 && head.0 < x1, "{a} # {b}: edge 1 must enter K1");
480            let (tail, head) = cs.edge_ends(n1 + 1, true);
481            assert!(tail.0 < x1 && head.0 >= x1, "{a} # {b}: edge {} must leave K1", n1 + 1);
482        }
483    }
484
485    #[test]
486    #[should_panic(expected = "free loops is not supported")]
487    fn conn_sum_rejects_a_free_loop_base_pt() {
488        // K # unknot = K mathematically, but the splice needs a base point sitting on a crossing.
489        let _ = Link::unknot().conn_sum(&Link::test_data("3_1"));
490    }
491}