From 16f1c1969ecb01406b6271d35581c61f183d75d5 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 08:24:36 +0100 Subject: [PATCH 01/19] basic inefficient implementation --- src/lax/hypergraph.rs | 56 ++++++++++++++++++++++++++++++++++++++ src/lax/open_hypergraph.rs | 7 +++++ tests/lax/hypergraph.rs | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/lax/hypergraph.rs b/src/lax/hypergraph.rs index 3d709ba..c8115cc 100644 --- a/src/lax/hypergraph.rs +++ b/src/lax/hypergraph.rs @@ -334,6 +334,62 @@ impl Hypergraph { } self.quotient = (quotient_left, quotient_right); } + + /// Returns true if there is no directed path from any node to itself. + /// + pub fn is_acyclic(&self) -> bool { + let node_count = self.nodes.len(); + if node_count == 0 { + return true; + } + + let mut adjacency = vec![Vec::::new(); node_count]; + for edge in &self.adjacency { + for &source in &edge.sources { + for &target in &edge.targets { + adjacency[source.0].push(target.0); + } + } + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum VisitState { + Unvisited, + Visiting, + Done, + } + + // run a DFS cycle check over the nodes + let mut state = vec![VisitState::Unvisited; node_count]; + + fn visit(v: usize, adjacency: &[Vec], state: &mut [VisitState]) -> bool { + if state[v] == VisitState::Visiting { + return false; + } + if state[v] == VisitState::Done { + return true; + } + state[v] = VisitState::Visiting; + for &next in &adjacency[v] { + if state[next] == VisitState::Visiting { + return false; + } + if state[next] == VisitState::Unvisited && !visit(next, adjacency, state) { + return false; + } + } + state[v] = VisitState::Done; + true + } + + for v in 0..node_count { + if state[v] == VisitState::Unvisited && !visit(v, &adjacency, &mut state) { + return false; + } + } + + true + } } impl Hypergraph { diff --git a/src/lax/open_hypergraph.rs b/src/lax/open_hypergraph.rs index 8e63243..fd866c5 100644 --- a/src/lax/open_hypergraph.rs +++ b/src/lax/open_hypergraph.rs @@ -133,6 +133,13 @@ impl OpenHypergraph { hypergraph: self.hypergraph.map_edges(f), } } + + /// Returns true if there is no directed path from any node to itself. + /// + /// This forwards to the internal hypergraph. + pub fn is_acyclic(&self) -> bool { + self.hypergraph.is_acyclic() + } } impl OpenHypergraph { diff --git a/tests/lax/hypergraph.rs b/tests/lax/hypergraph.rs index d61be88..010adbc 100644 --- a/tests/lax/hypergraph.rs +++ b/tests/lax/hypergraph.rs @@ -199,3 +199,54 @@ fn test_delete_edge_panics_on_out_of_bounds() { h.delete_edge(&[EdgeId(1)]); } + +#[test] +fn test_is_acyclic_true() { + let mut h = Hypergraph::empty(); + h.nodes = vec![0, 1, 2]; + h.edges = vec![10, 11]; + h.adjacency = vec![ + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(2)], + }, + ]; + + assert!(h.is_acyclic()); +} + +#[test] +fn test_is_acyclic_false_cycle() { + let mut h = Hypergraph::empty(); + h.nodes = vec![0, 1]; + h.edges = vec![10, 11]; + h.adjacency = vec![ + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(0)], + }, + ]; + + assert!(!h.is_acyclic()); +} + +#[test] +fn test_is_acyclic_false_self_loop() { + let mut h = Hypergraph::empty(); + h.nodes = vec![0]; + h.edges = vec![10]; + h.adjacency = vec![Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(0)], + }]; + + assert!(!h.is_acyclic()); +} From 88ccdf55de1cac57292e501d3c3809d32a53e6b3 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 08:30:19 +0100 Subject: [PATCH 02/19] add tests --- tests/lax/open_hypergraph.rs | 57 ++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/lax/open_hypergraph.rs b/tests/lax/open_hypergraph.rs index 188f033..c45101b 100644 --- a/tests/lax/open_hypergraph.rs +++ b/tests/lax/open_hypergraph.rs @@ -1,5 +1,5 @@ -use open_hypergraphs::category::Arrow; -use open_hypergraphs::lax::{NodeId, OpenHypergraph}; +use open_hypergraphs::category::{Arrow, SymmetricMonoidal}; +use open_hypergraphs::lax::{Hyperedge, Hypergraph, NodeId, OpenHypergraph}; #[derive(Clone, Debug, PartialEq)] enum Obj { @@ -45,3 +45,56 @@ fn test_compose_matches_lax_compose_on_equal_boundaries() { assert_eq!(strict, lax); } + +#[test] +fn test_identity_is_acyclic() { + let f = OpenHypergraph::::identity(vec![Obj::A, Obj::B]); + assert!(f.is_acyclic()); +} + +#[test] +fn test_symmetry_is_acyclic() { + let f = OpenHypergraph::::twist(vec![Obj::A], vec![Obj::B]); + assert!(f.is_acyclic()); +} + +#[test] +fn test_composition_of_acyclic_can_be_cyclic() { + // f has targets t1,t2 and a single edge t1 -> t2. + let mut f_h = Hypergraph::empty(); + f_h.nodes = vec![Obj::A, Obj::A]; + f_h.edges = vec![Op::F]; + f_h.adjacency = vec![Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }]; + let f = OpenHypergraph { + sources: vec![], + targets: vec![NodeId(0), NodeId(1)], + hypergraph: f_h, + }; + + // g has sources s1,s2 and a single edge s2 -> s1. + let mut g_h = Hypergraph::empty(); + g_h.nodes = vec![Obj::A, Obj::A]; + g_h.edges = vec![Op::G]; + g_h.adjacency = vec![Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(0)], + }]; + let g = OpenHypergraph { + sources: vec![NodeId(0), NodeId(1)], + targets: vec![], + hypergraph: g_h, + }; + + assert!(f.is_acyclic()); + assert!(g.is_acyclic()); + + let mut composed = f.lax_compose(&g).expect("arity matches"); + composed.quotient(); + + // After quotienting, t1=s1 and t2=s2, so we have t1 -> t2 (from f) + // and t2 -> t1 (from g), creating a 2-cycle. + assert!(!composed.is_acyclic()); +} From dd8e6fe2129466218380804093e9fe75b5d8b87f Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 10:34:49 +0100 Subject: [PATCH 03/19] move to strict --- src/lax/hypergraph.rs | 55 ----------------- src/lax/open_hypergraph.rs | 6 -- src/strict/hypergraph/object.rs | 61 ++++++++++++++++++- src/strict/open_hypergraph/arrow.rs | 8 +++ tests/hypergraph/test_hypergraph.rs | 35 +++++++++++ tests/lax/hypergraph.rs | 51 ---------------- tests/lax/open_hypergraph.rs | 57 +----------------- tests/open_hypergraph/test_acyclic.rs | 85 +++++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 168 deletions(-) create mode 100644 tests/open_hypergraph/test_acyclic.rs diff --git a/src/lax/hypergraph.rs b/src/lax/hypergraph.rs index c8115cc..4b4b737 100644 --- a/src/lax/hypergraph.rs +++ b/src/lax/hypergraph.rs @@ -335,61 +335,6 @@ impl Hypergraph { self.quotient = (quotient_left, quotient_right); } - /// Returns true if there is no directed path from any node to itself. - /// - pub fn is_acyclic(&self) -> bool { - let node_count = self.nodes.len(); - if node_count == 0 { - return true; - } - - let mut adjacency = vec![Vec::::new(); node_count]; - for edge in &self.adjacency { - for &source in &edge.sources { - for &target in &edge.targets { - adjacency[source.0].push(target.0); - } - } - } - - #[derive(Clone, Copy, PartialEq, Eq)] - enum VisitState { - Unvisited, - Visiting, - Done, - } - - // run a DFS cycle check over the nodes - let mut state = vec![VisitState::Unvisited; node_count]; - - fn visit(v: usize, adjacency: &[Vec], state: &mut [VisitState]) -> bool { - if state[v] == VisitState::Visiting { - return false; - } - if state[v] == VisitState::Done { - return true; - } - state[v] = VisitState::Visiting; - for &next in &adjacency[v] { - if state[next] == VisitState::Visiting { - return false; - } - if state[next] == VisitState::Unvisited && !visit(next, adjacency, state) { - return false; - } - } - state[v] = VisitState::Done; - true - } - - for v in 0..node_count { - if state[v] == VisitState::Unvisited && !visit(v, &adjacency, &mut state) { - return false; - } - } - - true - } } impl Hypergraph { diff --git a/src/lax/open_hypergraph.rs b/src/lax/open_hypergraph.rs index fd866c5..6206313 100644 --- a/src/lax/open_hypergraph.rs +++ b/src/lax/open_hypergraph.rs @@ -134,12 +134,6 @@ impl OpenHypergraph { } } - /// Returns true if there is no directed path from any node to itself. - /// - /// This forwards to the internal hypergraph. - pub fn is_acyclic(&self) -> bool { - self.hypergraph.is_acyclic() - } } impl OpenHypergraph { diff --git a/src/strict/hypergraph/object.rs b/src/strict/hypergraph/object.rs index 23eb987..a555840 100644 --- a/src/strict/hypergraph/object.rs +++ b/src/strict/hypergraph/object.rs @@ -1,4 +1,4 @@ -use crate::array::{Array, ArrayKind, NaturalArray}; +use crate::array::{vec::VecKind, Array, ArrayKind, NaturalArray}; use crate::category::*; use crate::finite_function::{coequalizer_universal, FiniteFunction}; use crate::indexed_coproduct::*; @@ -168,6 +168,65 @@ where } } +impl Hypergraph { + /// Returns true if there is no directed path from any node to itself. + /// + /// This treats each hyperedge as directed connections from every source node to every target + /// node, and runs a DFS cycle check over the induced node-level adjacency. + pub fn is_acyclic(&self) -> bool { + let node_count = self.w.0.len(); + if node_count == 0 { + return true; + } + + let mut adjacency = vec![Vec::::new(); node_count]; + for (sources, targets) in self.s.clone().into_iter().zip(self.t.clone().into_iter()) { + for &source in sources.table.iter() { + for &target in targets.table.iter() { + adjacency[source].push(target); + } + } + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum VisitState { + Unvisited, + Visiting, + Done, + } + + let mut state = vec![VisitState::Unvisited; node_count]; + + fn visit(v: usize, adjacency: &[Vec], state: &mut [VisitState]) -> bool { + if state[v] == VisitState::Visiting { + return false; + } + if state[v] == VisitState::Done { + return true; + } + state[v] = VisitState::Visiting; + for &next in &adjacency[v] { + if state[next] == VisitState::Visiting { + return false; + } + if state[next] == VisitState::Unvisited && !visit(next, adjacency, state) { + return false; + } + } + state[v] = VisitState::Done; + true + } + + for v in 0..node_count { + if state[v] == VisitState::Unvisited && !visit(v, &adjacency, &mut state) { + return false; + } + } + + true + } +} + // NOTE: manual Debug required because we need to specify array bounds. impl Debug for Hypergraph where diff --git a/src/strict/open_hypergraph/arrow.rs b/src/strict/open_hypergraph/arrow.rs index 8692dc5..631445a 100644 --- a/src/strict/open_hypergraph/arrow.rs +++ b/src/strict/open_hypergraph/arrow.rs @@ -1,4 +1,5 @@ use crate::array::*; +use crate::array::vec::VecKind; use crate::category::*; use crate::finite_function::*; use crate::operations::*; @@ -289,3 +290,10 @@ where .finish() } } + +impl OpenHypergraph { + /// Returns true if there is no directed path from any node to itself. + pub fn is_acyclic(&self) -> bool { + self.h.is_acyclic() + } +} diff --git a/tests/hypergraph/test_hypergraph.rs b/tests/hypergraph/test_hypergraph.rs index a745576..45eadbf 100644 --- a/tests/hypergraph/test_hypergraph.rs +++ b/tests/hypergraph/test_hypergraph.rs @@ -1,4 +1,5 @@ use open_hypergraphs::array::vec::*; +use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; use open_hypergraphs::strict::hypergraph::*; use super::strategy::{DiscreteSpan, Labels}; @@ -87,3 +88,37 @@ fn test_empty() { assert_eq!(e.w.len(), 0); assert_eq!(e.x.len(), 0); } + +fn build_hypergraph( + node_count: usize, + edges: &[(Vec, Vec)], +) -> Hypergraph { + let mut h = LaxHypergraph::empty(); + h.nodes = vec![0; node_count]; + for (sources, targets) in edges { + let edge = Hyperedge { + sources: sources.iter().map(|&i| NodeId(i)).collect(), + targets: targets.iter().map(|&i| NodeId(i)).collect(), + }; + h.new_edge(0, edge); + } + h.to_hypergraph() +} + +#[test] +fn test_is_acyclic_true() { + let h = build_hypergraph(3, &[(vec![0], vec![1]), (vec![1], vec![2])]); + assert!(h.is_acyclic()); +} + +#[test] +fn test_is_acyclic_false_cycle() { + let h = build_hypergraph(2, &[(vec![0], vec![1]), (vec![1], vec![0])]); + assert!(!h.is_acyclic()); +} + +#[test] +fn test_is_acyclic_false_self_loop() { + let h = build_hypergraph(1, &[(vec![0], vec![0])]); + assert!(!h.is_acyclic()); +} diff --git a/tests/lax/hypergraph.rs b/tests/lax/hypergraph.rs index 010adbc..d61be88 100644 --- a/tests/lax/hypergraph.rs +++ b/tests/lax/hypergraph.rs @@ -199,54 +199,3 @@ fn test_delete_edge_panics_on_out_of_bounds() { h.delete_edge(&[EdgeId(1)]); } - -#[test] -fn test_is_acyclic_true() { - let mut h = Hypergraph::empty(); - h.nodes = vec![0, 1, 2]; - h.edges = vec![10, 11]; - h.adjacency = vec![ - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(2)], - }, - ]; - - assert!(h.is_acyclic()); -} - -#[test] -fn test_is_acyclic_false_cycle() { - let mut h = Hypergraph::empty(); - h.nodes = vec![0, 1]; - h.edges = vec![10, 11]; - h.adjacency = vec![ - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(0)], - }, - ]; - - assert!(!h.is_acyclic()); -} - -#[test] -fn test_is_acyclic_false_self_loop() { - let mut h = Hypergraph::empty(); - h.nodes = vec![0]; - h.edges = vec![10]; - h.adjacency = vec![Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(0)], - }]; - - assert!(!h.is_acyclic()); -} diff --git a/tests/lax/open_hypergraph.rs b/tests/lax/open_hypergraph.rs index c45101b..188f033 100644 --- a/tests/lax/open_hypergraph.rs +++ b/tests/lax/open_hypergraph.rs @@ -1,5 +1,5 @@ -use open_hypergraphs::category::{Arrow, SymmetricMonoidal}; -use open_hypergraphs::lax::{Hyperedge, Hypergraph, NodeId, OpenHypergraph}; +use open_hypergraphs::category::Arrow; +use open_hypergraphs::lax::{NodeId, OpenHypergraph}; #[derive(Clone, Debug, PartialEq)] enum Obj { @@ -45,56 +45,3 @@ fn test_compose_matches_lax_compose_on_equal_boundaries() { assert_eq!(strict, lax); } - -#[test] -fn test_identity_is_acyclic() { - let f = OpenHypergraph::::identity(vec![Obj::A, Obj::B]); - assert!(f.is_acyclic()); -} - -#[test] -fn test_symmetry_is_acyclic() { - let f = OpenHypergraph::::twist(vec![Obj::A], vec![Obj::B]); - assert!(f.is_acyclic()); -} - -#[test] -fn test_composition_of_acyclic_can_be_cyclic() { - // f has targets t1,t2 and a single edge t1 -> t2. - let mut f_h = Hypergraph::empty(); - f_h.nodes = vec![Obj::A, Obj::A]; - f_h.edges = vec![Op::F]; - f_h.adjacency = vec![Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }]; - let f = OpenHypergraph { - sources: vec![], - targets: vec![NodeId(0), NodeId(1)], - hypergraph: f_h, - }; - - // g has sources s1,s2 and a single edge s2 -> s1. - let mut g_h = Hypergraph::empty(); - g_h.nodes = vec![Obj::A, Obj::A]; - g_h.edges = vec![Op::G]; - g_h.adjacency = vec![Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(0)], - }]; - let g = OpenHypergraph { - sources: vec![NodeId(0), NodeId(1)], - targets: vec![], - hypergraph: g_h, - }; - - assert!(f.is_acyclic()); - assert!(g.is_acyclic()); - - let mut composed = f.lax_compose(&g).expect("arity matches"); - composed.quotient(); - - // After quotienting, t1=s1 and t2=s2, so we have t1 -> t2 (from f) - // and t2 -> t1 (from g), creating a 2-cycle. - assert!(!composed.is_acyclic()); -} diff --git a/tests/open_hypergraph/test_acyclic.rs b/tests/open_hypergraph/test_acyclic.rs new file mode 100644 index 0000000..f6f55c4 --- /dev/null +++ b/tests/open_hypergraph/test_acyclic.rs @@ -0,0 +1,85 @@ +use open_hypergraphs::array::vec::{VecArray, VecKind}; +use open_hypergraphs::category::SymmetricMonoidal; +use open_hypergraphs::strict::hypergraph::Hypergraph; +use open_hypergraphs::strict::open_hypergraph::OpenHypergraph; +use open_hypergraphs::strict::vec::{FiniteFunction, IndexedCoproduct, SemifiniteFunction}; + +use crate::theory::meaningless::{Arr, Obj}; + +fn build_hypergraph( + node_count: usize, + edges: &[(Vec, Vec)], +) -> Hypergraph { + let w = SemifiniteFunction(VecArray(vec![0; node_count])); + let x = SemifiniteFunction(VecArray(vec![0; edges.len()])); + + let mut source_lengths = Vec::with_capacity(edges.len()); + let mut source_values = Vec::new(); + let mut target_lengths = Vec::with_capacity(edges.len()); + let mut target_values = Vec::new(); + + for (sources, targets) in edges { + source_lengths.push(sources.len()); + source_values.extend_from_slice(sources); + target_lengths.push(targets.len()); + target_values.extend_from_slice(targets); + } + + let s = IndexedCoproduct::from_semifinite( + SemifiniteFunction(VecArray(source_lengths)), + FiniteFunction::new(VecArray(source_values), node_count).unwrap(), + ) + .unwrap(); + + let t = IndexedCoproduct::from_semifinite( + SemifiniteFunction(VecArray(target_lengths)), + FiniteFunction::new(VecArray(target_values), node_count).unwrap(), + ) + .unwrap(); + + Hypergraph::new(s, t, w, x).unwrap() +} + +#[test] +fn test_identity_is_acyclic() { + let w = SemifiniteFunction(VecArray(vec![0, 1])); + let f: OpenHypergraph = OpenHypergraph::identity(w); + assert!(f.is_acyclic()); +} + +#[test] +fn test_symmetry_is_acyclic() { + let a = SemifiniteFunction(VecArray(vec![0])); + let b = SemifiniteFunction(VecArray(vec![1])); + let f: OpenHypergraph = OpenHypergraph::twist(a, b); + assert!(f.is_acyclic()); +} + +#[test] +fn test_composition_of_acyclic_can_be_cyclic() { + // f has targets t1,t2 and a single edge t1 -> t2. + let f_h = build_hypergraph(2, &[(vec![0], vec![1])]); + let f = OpenHypergraph::new( + FiniteFunction::initial(2), + FiniteFunction::new(VecArray(vec![0, 1]), 2).unwrap(), + f_h, + ) + .unwrap(); + + // g has sources s1,s2 and a single edge s2 -> s1. + let g_h = build_hypergraph(2, &[(vec![1], vec![0])]); + let g = OpenHypergraph::new( + FiniteFunction::new(VecArray(vec![0, 1]), 2).unwrap(), + FiniteFunction::initial(2), + g_h, + ) + .unwrap(); + + assert!(f.is_acyclic()); + assert!(g.is_acyclic()); + + // After composition, t1=s1 and t2=s2, so we have t1 -> t2 (from f) + // and t2 -> t1 (from g), creating a 2-cycle. + let composed = (&f >> &g).unwrap(); + assert!(!composed.is_acyclic()); +} From 7df84ba59111f98d2726a02a4748541c7a02e611 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 10:38:04 +0100 Subject: [PATCH 04/19] format --- src/lax/hypergraph.rs | 1 - src/lax/open_hypergraph.rs | 1 - src/strict/open_hypergraph/arrow.rs | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lax/hypergraph.rs b/src/lax/hypergraph.rs index 4b4b737..3d709ba 100644 --- a/src/lax/hypergraph.rs +++ b/src/lax/hypergraph.rs @@ -334,7 +334,6 @@ impl Hypergraph { } self.quotient = (quotient_left, quotient_right); } - } impl Hypergraph { diff --git a/src/lax/open_hypergraph.rs b/src/lax/open_hypergraph.rs index 6206313..8e63243 100644 --- a/src/lax/open_hypergraph.rs +++ b/src/lax/open_hypergraph.rs @@ -133,7 +133,6 @@ impl OpenHypergraph { hypergraph: self.hypergraph.map_edges(f), } } - } impl OpenHypergraph { diff --git a/src/strict/open_hypergraph/arrow.rs b/src/strict/open_hypergraph/arrow.rs index 631445a..e28053d 100644 --- a/src/strict/open_hypergraph/arrow.rs +++ b/src/strict/open_hypergraph/arrow.rs @@ -1,5 +1,5 @@ -use crate::array::*; use crate::array::vec::VecKind; +use crate::array::*; use crate::category::*; use crate::finite_function::*; use crate::operations::*; From 141c07eaa57d6bcd589f7bc9bc94b0cc6f3d14bc Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 10:46:14 +0100 Subject: [PATCH 05/19] simplify tests --- tests/open_hypergraph/test_acyclic.rs | 87 +++++++++------------------ 1 file changed, 29 insertions(+), 58 deletions(-) diff --git a/tests/open_hypergraph/test_acyclic.rs b/tests/open_hypergraph/test_acyclic.rs index f6f55c4..50b72d7 100644 --- a/tests/open_hypergraph/test_acyclic.rs +++ b/tests/open_hypergraph/test_acyclic.rs @@ -1,85 +1,56 @@ -use open_hypergraphs::array::vec::{VecArray, VecKind}; use open_hypergraphs::category::SymmetricMonoidal; -use open_hypergraphs::strict::hypergraph::Hypergraph; -use open_hypergraphs::strict::open_hypergraph::OpenHypergraph; -use open_hypergraphs::strict::vec::{FiniteFunction, IndexedCoproduct, SemifiniteFunction}; +use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId, OpenHypergraph}; use crate::theory::meaningless::{Arr, Obj}; -fn build_hypergraph( - node_count: usize, - edges: &[(Vec, Vec)], -) -> Hypergraph { - let w = SemifiniteFunction(VecArray(vec![0; node_count])); - let x = SemifiniteFunction(VecArray(vec![0; edges.len()])); - - let mut source_lengths = Vec::with_capacity(edges.len()); - let mut source_values = Vec::new(); - let mut target_lengths = Vec::with_capacity(edges.len()); - let mut target_values = Vec::new(); - +fn build_lax_hypergraph(node_count: usize, edges: &[(Vec, Vec)]) -> LaxHypergraph { + let mut h = LaxHypergraph::empty(); + h.nodes = vec![0; node_count]; for (sources, targets) in edges { - source_lengths.push(sources.len()); - source_values.extend_from_slice(sources); - target_lengths.push(targets.len()); - target_values.extend_from_slice(targets); + let edge = Hyperedge { + sources: sources.iter().map(|&i| NodeId(i)).collect(), + targets: targets.iter().map(|&i| NodeId(i)).collect(), + }; + h.new_edge(0, edge); } - - let s = IndexedCoproduct::from_semifinite( - SemifiniteFunction(VecArray(source_lengths)), - FiniteFunction::new(VecArray(source_values), node_count).unwrap(), - ) - .unwrap(); - - let t = IndexedCoproduct::from_semifinite( - SemifiniteFunction(VecArray(target_lengths)), - FiniteFunction::new(VecArray(target_values), node_count).unwrap(), - ) - .unwrap(); - - Hypergraph::new(s, t, w, x).unwrap() + h } #[test] fn test_identity_is_acyclic() { - let w = SemifiniteFunction(VecArray(vec![0, 1])); - let f: OpenHypergraph = OpenHypergraph::identity(w); - assert!(f.is_acyclic()); + let lax = OpenHypergraph::::identity(vec![0, 1]); + assert!(lax.to_strict().is_acyclic()); } #[test] fn test_symmetry_is_acyclic() { - let a = SemifiniteFunction(VecArray(vec![0])); - let b = SemifiniteFunction(VecArray(vec![1])); - let f: OpenHypergraph = OpenHypergraph::twist(a, b); - assert!(f.is_acyclic()); + let lax = OpenHypergraph::::twist(vec![0], vec![1]); + assert!(lax.to_strict().is_acyclic()); } #[test] fn test_composition_of_acyclic_can_be_cyclic() { // f has targets t1,t2 and a single edge t1 -> t2. - let f_h = build_hypergraph(2, &[(vec![0], vec![1])]); - let f = OpenHypergraph::new( - FiniteFunction::initial(2), - FiniteFunction::new(VecArray(vec![0, 1]), 2).unwrap(), - f_h, - ) - .unwrap(); + let f_h = build_lax_hypergraph(2, &[(vec![0], vec![1])]); + let f = OpenHypergraph { + sources: vec![], + targets: vec![NodeId(0), NodeId(1)], + hypergraph: f_h, + }; // g has sources s1,s2 and a single edge s2 -> s1. - let g_h = build_hypergraph(2, &[(vec![1], vec![0])]); - let g = OpenHypergraph::new( - FiniteFunction::new(VecArray(vec![0, 1]), 2).unwrap(), - FiniteFunction::initial(2), - g_h, - ) - .unwrap(); + let g_h = build_lax_hypergraph(2, &[(vec![1], vec![0])]); + let g = OpenHypergraph { + sources: vec![NodeId(0), NodeId(1)], + targets: vec![], + hypergraph: g_h, + }; - assert!(f.is_acyclic()); - assert!(g.is_acyclic()); + assert!(f.to_strict().is_acyclic()); + assert!(g.to_strict().is_acyclic()); // After composition, t1=s1 and t2=s2, so we have t1 -> t2 (from f) // and t2 -> t1 (from g), creating a 2-cycle. - let composed = (&f >> &g).unwrap(); + let composed = (&f >> &g).unwrap().to_strict(); assert!(!composed.is_acyclic()); } From 2a9b2e0c69a953c5c94db4bc0dc0d0b0743c1779 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 16:56:00 +0100 Subject: [PATCH 06/19] reuse existing gadgetry for detecting cycles --- src/strict/eval.rs | 3 +- src/strict/graph.rs | 241 ++++++++++++++++++++++++ src/strict/hypergraph/acyclic.rs | 22 +++ src/strict/hypergraph/mod.rs | 1 + src/strict/hypergraph/object.rs | 60 +----- src/strict/layer.rs | 278 +--------------------------- src/strict/mod.rs | 1 + src/strict/open_hypergraph/arrow.rs | 7 +- tests/graph/mod.rs | 1 + tests/graph/test_graph.rs | 25 +++ tests/layer/test_layer.rs | 3 +- tests/lib.rs | 1 + 12 files changed, 306 insertions(+), 337 deletions(-) create mode 100644 src/strict/graph.rs create mode 100644 src/strict/hypergraph/acyclic.rs create mode 100644 tests/graph/mod.rs create mode 100644 tests/graph/test_graph.rs diff --git a/src/strict/eval.rs b/src/strict/eval.rs index 291b0f2..1e658bc 100644 --- a/src/strict/eval.rs +++ b/src/strict/eval.rs @@ -5,7 +5,8 @@ use crate::finite_function::*; use crate::indexed_coproduct::*; use crate::semifinite::*; -use crate::strict::layer::{converse, layer}; +use crate::strict::graph::converse; +use crate::strict::layer::layer; use crate::strict::open_hypergraph::*; use num_traits::Zero; diff --git a/src/strict/graph.rs b/src/strict/graph.rs new file mode 100644 index 0000000..e71d825 --- /dev/null +++ b/src/strict/graph.rs @@ -0,0 +1,241 @@ +//////////////////////////////////////////////////////////////////////////////// +// Graph methods + +use crate::array::{Array, ArrayKind, NaturalArray, OrdArray}; +use crate::category::Arrow; +use crate::finite_function::FiniteFunction; +use crate::indexed_coproduct::HasLen; +use crate::indexed_coproduct::IndexedCoproduct; +use crate::strict::hypergraph::Hypergraph; +use crate::strict::open_hypergraph::OpenHypergraph; +use num_traits::{One, Zero}; + +/// Compute the *converse* of an [`IndexedCoproduct`] thought of as a "multirelation". +/// +/// An [`IndexedCoproduct`] `c : Σ_{x ∈ X} s(x) → Q` can equivalently be thought of as `c : X → +/// Q*`, i.e. a mapping from X to finite lists of elements in Q. +/// +/// Such a list defines a (multi-)relation as the multiset of pairs +/// +/// `R = { ( x, f(x)_i ) | x ∈ X, i ∈ len(f(x)) }` +/// +/// This function computes the *converse* of that relation as an indexed coproduct +/// `converse(c) : Q → X*`, or more precisely +/// `converse(c) : Σ_{q ∈ Q} s(q) → X`. +/// +/// NOTE: An indexed coproduct does not uniquely represent a 'multirelation', since *order* of the +/// elements matters. +/// The result of this function is only unique up to permutation of the sublists. +pub fn converse( + r: &IndexedCoproduct>, +) -> IndexedCoproduct> +where + K::Type: NaturalArray, +{ + let values_table = { + let arange = K::Index::arange(&K::I::zero(), &r.sources.len()); + let unsorted_values = r.sources.table.repeat(arange.get_range(..)); + unsorted_values.sort_by(&r.values.table) + }; + + let sources_table = + (r.values.table.as_ref() as &K::Type).bincount(r.values.target.clone()); + + let sources = FiniteFunction::new(sources_table, r.values.table.len() + K::I::one()).unwrap(); + let values = FiniteFunction::new(values_table, r.len()).unwrap(); + + IndexedCoproduct::new(sources, values).unwrap() +} + +/// Return the adjacency map for an [`OpenHypergraph`] `f`. +/// +/// If `X` is the finite set of operations in `f`, then `operation_adjacency(f)` computes the +/// indexed coproduct `adjacency : X → X*`, where the list `adjacency(x)` is all operations reachable in +/// a single step from operation `x`. +pub fn operation_adjacency( + f: &OpenHypergraph, +) -> IndexedCoproduct> +where + K::Type: NaturalArray, +{ + f.h.t.flatmap(&converse(&f.h.s)) +} + +/// Return the node-level adjacency map for a [`Hypergraph`]. +/// +/// If `W` is the finite set of nodes in `h`, then `node_adjacency(h)` computes the +/// indexed coproduct `adjacency : W → W*`, where the list `adjacency(w)` is all nodes reachable in +/// a single step from node `w`. +pub fn node_adjacency( + h: &Hypergraph, +) -> IndexedCoproduct> +where + K::Type: NaturalArray, +{ + converse(&h.s).flatmap(&h.t) +} + +/// A kahn-ish algorithm for topological sorting of an adjacency relation, encoded as an +/// [`IndexedCoproduct`] (see [`converse`] for details) +/// +pub fn kahn( + adjacency: &IndexedCoproduct>, +) -> (K::Index, K::Type) +where + K::Type: NaturalArray, +{ + // The layering assignment to each node. + // A mutable array of length n with values in {0..n} + let mut order: K::Type = K::Type::::fill(K::I::zero(), adjacency.len()); + // Predicate determining if a node has been visited. + // 1 = unvisited + // 0 = visited + // NOTE: we store this as "NOT visited" so we can efficiently filter using "repeat". + let mut unvisited: K::Type = K::Type::::fill(K::I::one(), adjacency.len()); + // Indegree of each node. + let mut indegree = indegree(adjacency); + // the set of nodes on the frontier, initialized to those with zero indegree. + let mut frontier: K::Index = zero(&indegree); + + // Loop until frontier is empty, or at max possible layering depth. + let mut depth = K::I::zero(); + + // Implementation outline: + // 1. Compute *sparse* relative indegree, which is: + // - idxs of reachable nodes + // - counts of reachability from a given set + // 2. Subtract from global indegree array using scatter_sub_assign + // - scatter_sub_assign::(&mut indegree.table, &reachable_ix, &reachable_count.table); + // 3. Compute new frontier: + // - Numpy-esque: `reachable_ix[indegree[reachable_ix] == 0 && unvisited[reachable_ix]]` + while !frontier.is_empty() && depth <= adjacency.len() { + // Mark nodes in the current frontier as visited + // unvisited[frontier] = 0; + unvisited.scatter_assign_constant(&frontier, K::I::zero()); + // Set the order of nodes in the frontier to the current depth. + // order[frontier] = depth; + order.scatter_assign_constant(&frontier, depth.clone()); + + // For each node, compute the number of incoming edges from nodes in the frontier, + // and count paths to each. + let (reachable_ix, reachable_count) = sparse_relative_indegree( + adjacency, + &FiniteFunction::new(frontier, adjacency.len()).unwrap(), + ); + + // indegree = indegree - dense_relative_indegree(a, f) + indegree + .table + .as_mut() + .scatter_sub_assign(&reachable_ix.table, &reachable_count.table); + + // Reachable nodes with zero indegree... + // frontier = reachable_ix[indegree[reachable_ix] == 0] + frontier = { + // *indices* i of reachable_ix such that indegree[reachable_ix[i]] == 0 + let reachable_ix_indegree_zero_ix = indegree + .table + .gather(reachable_ix.table.get_range(..)) + .zero(); + + // only nodes in reachable_ix with indegree 0 + reachable_ix + .table + .gather(reachable_ix_indegree_zero_ix.get_range(..)) + }; + + // .. and filter out those which have been visited. + // frontier = frontier[unvisited[frontier]] + frontier = filter::( + &frontier, + &unvisited.as_ref().gather(frontier.get_range(..)), + ); + + // Increment depth + depth = depth + K::I::one(); + } + + (order.into(), unvisited) +} + +pub fn indegree( + adjacency: &IndexedCoproduct>, +) -> FiniteFunction +where + K::Type: NaturalArray, +{ + // Indegree is *relative* indegree with respect to all nodes. + // PERFORMANCE: can compute this more efficiently by just bincounting adjacency directly. + dense_relative_indegree(adjacency, &FiniteFunction::::identity(adjacency.len())) +} + +pub fn dense_relative_indegree( + adjacency: &IndexedCoproduct>, + f: &FiniteFunction, +) -> FiniteFunction +where + K::Type: NaturalArray, +{ + // Must have that the number of nodes `adjacency.len()` + assert_eq!(adjacency.len(), f.target()); + + // Operations reachable from those in the set f. + let reached = adjacency.indexed_values(f).unwrap(); + // target is +1 because all edges could point to the same operation, so its indegree will be + // adjacency.len(). + let target = adjacency.len() + K::I::one(); + let table = (reached.table.as_ref() as &K::Type).bincount(adjacency.len()); + FiniteFunction::new(table, target).unwrap() +} + +pub fn sparse_relative_indegree( + a: &IndexedCoproduct>, + f: &FiniteFunction, +) -> (FiniteFunction, FiniteFunction) +where + K::Type: NaturalArray, +{ + // Must have that the number of nodes `adjacency.len()` + assert_eq!(a.len(), f.target()); + + // Indices of operations reachable from those in the set f. + // Indices may appear more than once. + let g = a.indexed_values(f).unwrap(); + let (i, c) = g.table.sparse_bincount(); + let target = a.len() + K::I::one(); + + ( + FiniteFunction::new(i, a.len()).unwrap(), + FiniteFunction::new(c, target).unwrap(), + ) +} + +/// Given: +/// +/// - `values : K → N` +/// - `predicate : K → 2` +/// +/// Return the subset of `values` for which `predicate(i) = 1` +pub fn filter(values: &K::Index, predicate: &K::Index) -> K::Index { + predicate.repeat(values.get_range(..)) +} + +// FiniteFunction helpers +pub fn zero(f: &FiniteFunction) -> K::Index +where + K::Type: NaturalArray, +{ + (f.table.as_ref() as &K::Type).zero() +} + +/// Given a FiniteFunction `X → L`, compute its converse, +/// a relation `r : L → X*`, and return the result as an array of arrays, +/// where `r_i` is the list of elements in `X` mapping to i. +pub fn converse_iter(order: FiniteFunction) -> impl Iterator +where + K::Type: NaturalArray, + K::I: Into, +{ + let c = converse(&IndexedCoproduct::elements(order)); + c.into_iter().map(|x| x.table) +} diff --git a/src/strict/hypergraph/acyclic.rs b/src/strict/hypergraph/acyclic.rs new file mode 100644 index 0000000..c563c66 --- /dev/null +++ b/src/strict/hypergraph/acyclic.rs @@ -0,0 +1,22 @@ +use crate::array::{Array, ArrayKind, NaturalArray}; +use crate::strict::graph; +use crate::strict::hypergraph::Hypergraph; +use num_traits::Zero; + +impl Hypergraph +where + K::Type: NaturalArray, + K::Type: Array, +{ + /// Returns true if there is no directed path from any node to itself. + /// + pub fn is_acyclic(&self) -> bool { + if self.w.len() == K::I::zero() { + return true; + } + + let adjacency = graph::node_adjacency(self); + let (_order, unvisited) = graph::kahn(&adjacency); + unvisited.sum() == K::I::zero() + } +} diff --git a/src/strict/hypergraph/mod.rs b/src/strict/hypergraph/mod.rs index 836341d..28191fa 100644 --- a/src/strict/hypergraph/mod.rs +++ b/src/strict/hypergraph/mod.rs @@ -1,6 +1,7 @@ //! The category of hypergraphs has objects represented by [`Hypergraph`] //! and arrows by [`arrow::HypergraphArrow`]. pub mod arrow; +mod acyclic; mod object; pub use object::*; diff --git a/src/strict/hypergraph/object.rs b/src/strict/hypergraph/object.rs index a555840..535e800 100644 --- a/src/strict/hypergraph/object.rs +++ b/src/strict/hypergraph/object.rs @@ -1,4 +1,4 @@ -use crate::array::{vec::VecKind, Array, ArrayKind, NaturalArray}; +use crate::array::{Array, ArrayKind, NaturalArray}; use crate::category::*; use crate::finite_function::{coequalizer_universal, FiniteFunction}; use crate::indexed_coproduct::*; @@ -168,64 +168,6 @@ where } } -impl Hypergraph { - /// Returns true if there is no directed path from any node to itself. - /// - /// This treats each hyperedge as directed connections from every source node to every target - /// node, and runs a DFS cycle check over the induced node-level adjacency. - pub fn is_acyclic(&self) -> bool { - let node_count = self.w.0.len(); - if node_count == 0 { - return true; - } - - let mut adjacency = vec![Vec::::new(); node_count]; - for (sources, targets) in self.s.clone().into_iter().zip(self.t.clone().into_iter()) { - for &source in sources.table.iter() { - for &target in targets.table.iter() { - adjacency[source].push(target); - } - } - } - - #[derive(Clone, Copy, PartialEq, Eq)] - enum VisitState { - Unvisited, - Visiting, - Done, - } - - let mut state = vec![VisitState::Unvisited; node_count]; - - fn visit(v: usize, adjacency: &[Vec], state: &mut [VisitState]) -> bool { - if state[v] == VisitState::Visiting { - return false; - } - if state[v] == VisitState::Done { - return true; - } - state[v] = VisitState::Visiting; - for &next in &adjacency[v] { - if state[next] == VisitState::Visiting { - return false; - } - if state[next] == VisitState::Unvisited && !visit(next, adjacency, state) { - return false; - } - } - state[v] = VisitState::Done; - true - } - - for v in 0..node_count { - if state[v] == VisitState::Unvisited && !visit(v, &adjacency, &mut state) { - return false; - } - } - - true - } -} // NOTE: manual Debug required because we need to specify array bounds. impl Debug for Hypergraph diff --git a/src/strict/layer.rs b/src/strict/layer.rs index 4a0a38f..0566147 100644 --- a/src/strict/layer.rs +++ b/src/strict/layer.rs @@ -1,14 +1,12 @@ //! A [Coffman-Graham](https://en.wikipedia.org/wiki/Coffman%E2%80%93Graham_algorithm)-inspired //! layering algorithm. use crate::array::*; -use crate::category::*; use crate::finite_function::*; -use crate::indexed_coproduct::*; +use crate::strict::graph; +use crate::strict::graph::converse_iter; use crate::strict::open_hypergraph::*; -use num_traits::{One, Zero}; - /// Compute a *layering* of an [`OpenHypergraph`]: a mapping `layer : X → K` from operations to /// integers compatible with the partial ordering on `X` induced by hypergraph structure. /// @@ -24,8 +22,8 @@ where K::Type: Array, K::Type: NaturalArray, { - let a = operation_adjacency(f); - let (ordering, completed) = kahn(&a); + let a = graph::operation_adjacency(f); + let (ordering, completed) = graph::kahn(&a); ( FiniteFunction::new(ordering, f.h.x.0.len()).unwrap(), completed, @@ -50,271 +48,3 @@ where let (order, unvisited) = layer(f); (converse_iter(order).collect(), unvisited.into()) } - -/// A kahn-ish algorithm for topological sorting of an adjacency relation, encoded as an -/// [`IndexedCoproduct`] (see [`converse`] for details) -fn kahn( - adjacency: &IndexedCoproduct>, -) -> (K::Index, K::Type) -where - K::Type: NaturalArray, -{ - // The layering assignment to each node. - // A mutable array of length n with values in {0..n} - let mut order: K::Type = K::Type::::fill(K::I::zero(), adjacency.len()); - - // Predicate determining if a node has been visited. - // 1 = unvisited - // 0 = visited - // NOTE: we store this as "NOT visited" so we can efficiently filter using "repeat". - let mut unvisited: K::Type = K::Type::::fill(K::I::one(), adjacency.len()); - - // Indegree of each node. - let mut indegree = indegree(adjacency); - - // the set of nodes on the frontier, initialized to those with zero indegree. - let mut frontier: K::Index = zero(&indegree); - - // Loop until frontier is empty, or at max possible layering depth. - let mut depth = K::I::zero(); - - // Implementation outline: - // 1. Compute *sparse* relative indegree, which is: - // - idxs of reachable nodes - // - counts of reachability from a given set - // 2. Subtract from global indegree array using scatter_sub_assign - // - scatter_sub_assign::(&mut indegree.table, &reachable_ix, &reachable_count.table); - // 3. Compute new frontier: - // - Numpy-esque: `reachable_ix[indegree[reachable_ix] == 0 && unvisited[reachable_ix]]` - while !frontier.is_empty() && depth <= adjacency.len() { - // Mark nodes in the current frontier as visited - // unvisited[frontier] = 0; - unvisited.scatter_assign_constant(&frontier, K::I::zero()); - - // Set the order of nodes in the frontier to the current depth. - // order[frontier] = depth; - order.scatter_assign_constant(&frontier, depth.clone()); - - // For each node, compute the number of incoming edges from nodes in the frontier, - // and count paths to each. - let (reachable_ix, reachable_count) = sparse_relative_indegree( - adjacency, - &FiniteFunction::new(frontier, adjacency.len()).unwrap(), - ); - - // indegree = indegree - dense_relative_indegree(a, f) - indegree - .table - .as_mut() - .scatter_sub_assign(&reachable_ix.table, &reachable_count.table); - - // Reachable nodes with zero indegree... - // frontier = reachable_ix[indegree[reachable_ix] == 0] - frontier = { - // *indices* i of reachable_ix such that indegree[reachable_ix[i]] == 0 - let reachable_ix_indegree_zero_ix = indegree - .table - .gather(reachable_ix.table.get_range(..)) - .zero(); - - // only nodes in reachable_ix with indegree 0 - reachable_ix - .table - .gather(reachable_ix_indegree_zero_ix.get_range(..)) - }; - - // .. and filter out those which have been visited. - // frontier = frontier[unvisited[frontier]] - frontier = filter::( - &frontier, - &unvisited.as_ref().gather(frontier.get_range(..)), - ); - - // Increment depth - depth = depth + K::I::one(); - } - - (order.into(), unvisited) -} - -/// Given: -/// -/// - `values : K → N` -/// - `predicate : K → 2` -/// -/// Return the subset of `values` for which `predicate(i) = 1` -fn filter(values: &K::Index, predicate: &K::Index) -> K::Index { - predicate.repeat(values.get_range(..)) -} - -/// Given an array of indices `values` in `{0..N}` and a predicate `N → 2`, select select values `i` for -/// which `predicate(i) = 1`. -#[allow(dead_code)] -fn filter_by_dense(values: &K::Index, predicate: &K::Index) -> K::Index -where - K::Type: NaturalArray, -{ - predicate - .gather(values.get_range(..)) - .repeat(values.get_range(..)) -} - -//////////////////////////////////////////////////////////////////////////////// -// Graph methods - -/// Using the adjacency information in `adjacency`, compute the indegree of all nodes reachable from `f`. -/// -/// More formally, let: -/// -/// - `a : Σ_{n ∈ A} s(n) → N` denote the adjacency information of each -/// - `f : K → N` be a subset of `K` nodes -/// -/// Then `sparse_relative_indegree(a, f)` computes: -/// -/// - `g : R → N`, the subset of (R)eachable nodes reachable from `f` -/// - `i : R → E+1`, the *indegree* of nodes in `R`. -/// -fn sparse_relative_indegree( - a: &IndexedCoproduct>, - f: &FiniteFunction, -) -> (FiniteFunction, FiniteFunction) -where - K::Type: NaturalArray, -{ - // Must have that the number of nodes `adjacency.len()` - assert_eq!(a.len(), f.target()); - - // Indices of operations reachable from those in the set f. - // Indices may appear more than once. - let g = a.indexed_values(f).unwrap(); - let (i, c) = g.table.sparse_bincount(); - let target = a.len() + K::I::one(); - - ( - FiniteFunction::new(i, a.len()).unwrap(), - FiniteFunction::new(c, target).unwrap(), - ) -} - -/// Using the adjacency information in `adjacency`, compute the indegree of all nodes reachable from `f`. -/// -/// More formally, define: -/// -/// ```text -/// a : Σ_{n ∈ A} s(n) → N // the adjacency information of each -/// f : K → N // a subset of `K` nodes -/// ``` -/// -/// Then `dense_relative_indegree(a, f)` computes the indegree from `f` of all `N` nodes. -/// -/// # Returns -/// -/// A finite function `N → E+1` denoting indegree of each node in `N` relative to `f`. -fn dense_relative_indegree( - adjacency: &IndexedCoproduct>, - f: &FiniteFunction, -) -> FiniteFunction -where - K::Type: NaturalArray, -{ - // Must have that the number of nodes `adjacency.len()` - assert_eq!(adjacency.len(), f.target()); - - // Operations reachable from those in the set f. - let reached = adjacency.indexed_values(f).unwrap(); - - // target is +1 because all edges could point to the same operation, so its indegree will be - // adjacency.len(). - let target = adjacency.len() + K::I::one(); - let table = (reached.table.as_ref() as &K::Type).bincount(adjacency.len()); - FiniteFunction::new(table, target).unwrap() -} - -/// Compute indegree of all nodes in a multigraph. -pub fn indegree( - adjacency: &IndexedCoproduct>, -) -> FiniteFunction -where - K::Type: NaturalArray, -{ - // Indegree is *relative* indegree with respect to all nodes. - // PERFORMANCE: can compute this more efficiently by just bincounting adjacency directly. - dense_relative_indegree(adjacency, &FiniteFunction::::identity(adjacency.len())) -} - -/// Return the adjacency map for an [`OpenHypergraph`] `f`. -/// -/// If `X` is the finite set of operations in `f`, then `operation_adjacency(f)` computes the -/// indexed coproduct `adjacency : X → X*`, where the list `adjacency(x)` is all operations reachable in -/// a single step from operation `x`. -pub fn operation_adjacency( - f: &OpenHypergraph, -) -> IndexedCoproduct> -where - K::Type: NaturalArray, -{ - f.h.t.flatmap(&converse(&f.h.s)) -} - -/// Compute the *converse* of an [`IndexedCoproduct`] thought of as a "multirelation". -/// -/// An [`IndexedCoproduct`] `c : Σ_{x ∈ X} s(x) → Q` can equivalently be thought of as `c : X → -/// Q*`, i.e. a mapping from X to finite lists of elements in Q. -/// -/// Such a list defines a (multi-)relation as the multiset of pairs -/// -/// `R = { ( x, f(x)_i ) | x ∈ X, i ∈ len(f(x)) }` -/// -/// This function computes the *converse* of that relation as an indexed coproduct -/// `converse(c) : Q → X*`, or more precisely -/// `converse(c) : Σ_{q ∈ Q} s(q) → X`. -/// -/// NOTE: An indexed coproduct does not uniquely represent a 'multirelation', since *order* of the -/// elements matters. -/// The result of this function is only unique up to permutation of the sublists. -pub fn converse( - r: &IndexedCoproduct>, -) -> IndexedCoproduct> -where - K::Type: NaturalArray, -{ - // Create the 'values' array of the resulting [`IndexedCoproduct`] - // Sort segmented_arange(r.sources.table) by the *values* of r. - let values_table = { - let arange = K::Index::arange(&K::I::zero(), &r.sources.len()); - let unsorted_values = r.sources.table.repeat(arange.get_range(..)); - unsorted_values.sort_by(&r.values.table) - }; - - // Create the "sources" array of the result - let sources_table = - (r.values.table.as_ref() as &K::Type).bincount(r.values.target.clone()); - - let sources = FiniteFunction::new(sources_table, r.values.table.len() + K::I::one()).unwrap(); - let values = FiniteFunction::new(values_table, r.len()).unwrap(); - - IndexedCoproduct::new(sources, values).unwrap() -} - -/// Given a FiniteFunction `X → L`, compute its converse, -/// a relation `r : L → X*`, and return the result as an array of arrays, -/// where `r_i` is the list of elements in `X` mapping to i. -pub fn converse_iter(order: FiniteFunction) -> impl Iterator -where - K::Type: NaturalArray, - K::I: Into, -{ - let c = converse(&IndexedCoproduct::elements(order)); - c.into_iter().map(|x| x.table) -} - -//////////////////////////////////////////////////////////////////////////////// -// Array trait helpers - -// FiniteFunction helpers -fn zero(f: &FiniteFunction) -> K::Index -where - K::Type: NaturalArray, -{ - (f.table.as_ref() as &K::Type).zero() -} diff --git a/src/strict/mod.rs b/src/strict/mod.rs index 126c036..c57f10c 100644 --- a/src/strict/mod.rs +++ b/src/strict/mod.rs @@ -9,6 +9,7 @@ pub mod open_hypergraph; pub mod eval; pub mod functor; pub mod layer; +pub mod graph; pub use crate::array::*; pub use crate::category::*; diff --git a/src/strict/open_hypergraph/arrow.rs b/src/strict/open_hypergraph/arrow.rs index e28053d..0c59f09 100644 --- a/src/strict/open_hypergraph/arrow.rs +++ b/src/strict/open_hypergraph/arrow.rs @@ -1,4 +1,3 @@ -use crate::array::vec::VecKind; use crate::array::*; use crate::category::*; use crate::finite_function::*; @@ -291,7 +290,11 @@ where } } -impl OpenHypergraph { +impl OpenHypergraph +where + K::Type: NaturalArray, + K::Type: Array, +{ /// Returns true if there is no directed path from any node to itself. pub fn is_acyclic(&self) -> bool { self.h.is_acyclic() diff --git a/tests/graph/mod.rs b/tests/graph/mod.rs new file mode 100644 index 0000000..c6f1985 --- /dev/null +++ b/tests/graph/mod.rs @@ -0,0 +1 @@ +pub mod test_graph; diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs new file mode 100644 index 0000000..e5cc06b --- /dev/null +++ b/tests/graph/test_graph.rs @@ -0,0 +1,25 @@ +use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; +use open_hypergraphs::strict::graph; + +#[test] +fn test_node_adjacency_simple_example() { + // Nodes: 0, 1, 2 + // Edge e0: sources [0, 1] -> targets [2] + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1, 2]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0), NodeId(1)], + targets: vec![NodeId(2)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::node_adjacency(&strict); + + let got: Vec> = adjacency.into_iter().map(|ff| ff.table.0).collect(); + + let expected = vec![vec![2], vec![2], vec![]]; + assert_eq!(got, expected); +} diff --git a/tests/layer/test_layer.rs b/tests/layer/test_layer.rs index e4aeacf..80f783f 100644 --- a/tests/layer/test_layer.rs +++ b/tests/layer/test_layer.rs @@ -2,7 +2,8 @@ use open_hypergraphs::array::vec::*; use open_hypergraphs::finite_function::*; use open_hypergraphs::indexed_coproduct::*; use open_hypergraphs::semifinite::*; -use open_hypergraphs::strict::layer::{converse, indegree, layer, operation_adjacency}; +use open_hypergraphs::strict::graph::{converse, indegree, operation_adjacency}; +use open_hypergraphs::strict::layer::layer; use open_hypergraphs::strict::open_hypergraph::*; #[derive(Clone, PartialEq, Debug)] diff --git a/tests/lib.rs b/tests/lib.rs index a38ebda..555f164 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -13,5 +13,6 @@ pub mod hypergraph; pub mod open_hypergraph; pub mod eval; +pub mod graph; pub mod lax; pub mod layer; From 116ab034cda40ca21a747108130d6a66c3a87433 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:14:36 +0100 Subject: [PATCH 07/19] refactor --- src/strict/graph.rs | 57 ++++++++++++++++++++++++++++----- src/strict/hypergraph/object.rs | 1 - src/strict/layer.rs | 2 +- tests/layer/test_layer.rs | 16 ++++----- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/strict/graph.rs b/src/strict/graph.rs index e71d825..8c42580 100644 --- a/src/strict/graph.rs +++ b/src/strict/graph.rs @@ -7,7 +7,6 @@ use crate::finite_function::FiniteFunction; use crate::indexed_coproduct::HasLen; use crate::indexed_coproduct::IndexedCoproduct; use crate::strict::hypergraph::Hypergraph; -use crate::strict::open_hypergraph::OpenHypergraph; use num_traits::{One, Zero}; /// Compute the *converse* of an [`IndexedCoproduct`] thought of as a "multirelation". @@ -47,18 +46,18 @@ where IndexedCoproduct::new(sources, values).unwrap() } -/// Return the adjacency map for an [`OpenHypergraph`] `f`. +/// Return the adjacency map for a [`Hypergraph`] `h`. /// -/// If `X` is the finite set of operations in `f`, then `operation_adjacency(f)` computes the +/// If `X` is the finite set of operations in `h`, then `operation_adjacency(h)` computes the /// indexed coproduct `adjacency : X → X*`, where the list `adjacency(x)` is all operations reachable in /// a single step from operation `x`. pub fn operation_adjacency( - f: &OpenHypergraph, + h: &Hypergraph, ) -> IndexedCoproduct> where K::Type: NaturalArray, { - f.h.t.flatmap(&converse(&f.h.s)) + h.t.flatmap(&converse(&h.s)) } /// Return the node-level adjacency map for a [`Hypergraph`]. @@ -158,6 +157,7 @@ where (order.into(), unvisited) } +/// Compute indegree of all nodes in a multigraph. pub fn indegree( adjacency: &IndexedCoproduct>, ) -> FiniteFunction @@ -169,6 +169,20 @@ where dense_relative_indegree(adjacency, &FiniteFunction::::identity(adjacency.len())) } +/// Using the adjacency information in `adjacency`, compute the indegree of all nodes reachable from `f`. +/// +/// More formally, define: +/// +/// ```text +/// a : Σ_{n ∈ A} s(n) → N // the adjacency information of each +/// f : K → N // a subset of `K` nodes +/// ``` +/// +/// Then `dense_relative_indegree(a, f)` computes the indegree from `f` of all `N` nodes. +/// +/// # Returns +/// +/// A finite function `N → E+1` denoting indegree of each node in `N` relative to `f`. pub fn dense_relative_indegree( adjacency: &IndexedCoproduct>, f: &FiniteFunction, @@ -188,6 +202,18 @@ where FiniteFunction::new(table, target).unwrap() } +/// Using the adjacency information in `adjacency`, compute the indegree of all nodes reachable from `f`. +/// +/// More formally, let: +/// +/// - `a : Σ_{n ∈ A} s(n) → N` denote the adjacency information of each +/// - `f : K → N` be a subset of `K` nodes +/// +/// Then `sparse_relative_indegree(a, f)` computes: +/// +/// - `g : R → N`, the subset of (R)eachable nodes reachable from `f` +/// - `i : R → E+1`, the *indegree* of nodes in `R`. +/// pub fn sparse_relative_indegree( a: &IndexedCoproduct>, f: &FiniteFunction, @@ -220,12 +246,16 @@ pub fn filter(values: &K::Index, predicate: &K::Index) -> K::Index predicate.repeat(values.get_range(..)) } -// FiniteFunction helpers -pub fn zero(f: &FiniteFunction) -> K::Index +/// Given an array of indices `values` in `{0..N}` and a predicate `N → 2`, select select values `i` for +/// which `predicate(i) = 1`. +#[allow(dead_code)] +fn filter_by_dense(values: &K::Index, predicate: &K::Index) -> K::Index where K::Type: NaturalArray, { - (f.table.as_ref() as &K::Type).zero() + predicate + .gather(values.get_range(..)) + .repeat(values.get_range(..)) } /// Given a FiniteFunction `X → L`, compute its converse, @@ -239,3 +269,14 @@ where let c = converse(&IndexedCoproduct::elements(order)); c.into_iter().map(|x| x.table) } + +//////////////////////////////////////////////////////////////////////////////// +// Array trait helpers + +// FiniteFunction helpers +pub fn zero(f: &FiniteFunction) -> K::Index +where + K::Type: NaturalArray, +{ + (f.table.as_ref() as &K::Type).zero() +} diff --git a/src/strict/hypergraph/object.rs b/src/strict/hypergraph/object.rs index 535e800..23eb987 100644 --- a/src/strict/hypergraph/object.rs +++ b/src/strict/hypergraph/object.rs @@ -168,7 +168,6 @@ where } } - // NOTE: manual Debug required because we need to specify array bounds. impl Debug for Hypergraph where diff --git a/src/strict/layer.rs b/src/strict/layer.rs index 0566147..d6a9e0e 100644 --- a/src/strict/layer.rs +++ b/src/strict/layer.rs @@ -22,7 +22,7 @@ where K::Type: Array, K::Type: NaturalArray, { - let a = graph::operation_adjacency(f); + let a = graph::operation_adjacency(&f.h); let (ordering, completed) = graph::kahn(&a); ( FiniteFunction::new(ordering, f.h.x.0.len()).unwrap(), diff --git a/tests/layer/test_layer.rs b/tests/layer/test_layer.rs index 80f783f..756a584 100644 --- a/tests/layer/test_layer.rs +++ b/tests/layer/test_layer.rs @@ -114,7 +114,7 @@ fn test_indegree() { // └───┘ println!("singleton"); let f = OpenHypergraph::::singleton(F, x.clone(), y.clone()); - let a = operation_adjacency(&f); + let a = operation_adjacency(&f.h); let i = indegree(&a); assert_eq!(i.table, VecArray(vec![0])); @@ -129,7 +129,7 @@ fn test_indegree() { println!("(g | g) >> f"); let g = OpenHypergraph::singleton(G, y.clone(), y.clone()); let h = (&(&g | &g) >> &f).unwrap(); - let a = operation_adjacency(&h); + let a = operation_adjacency(&h.h); let i = indegree(&a); assert_eq!(i.table, VecArray(vec![0, 0, 2])); @@ -144,7 +144,7 @@ fn test_indegree() { println!("f >> f_op"); let f_op = OpenHypergraph::singleton(F, y.clone(), x.clone()); let h = (&f >> &f_op).unwrap(); - let a = operation_adjacency(&h); + let a = operation_adjacency(&h.h); let i = indegree(&a); assert_eq!(i.table, VecArray(vec![0, 1])); @@ -156,7 +156,7 @@ fn test_indegree() { // └───┘ └───┘ println!("f_op >> f"); let h = (&f_op >> &f).unwrap(); - let a = operation_adjacency(&h); + let a = operation_adjacency(&h.h); let i = indegree(&a); assert_eq!(i.table, VecArray(vec![0, 2])); } @@ -191,7 +191,7 @@ fn test_operation_adjacency() { // ●────│ │ // └───┘ let f = OpenHypergraph::::singleton(F, x.clone(), y.clone()); - let result = operation_adjacency::(&f); + let result = operation_adjacency::(&f.h); assert_eq!(result.sources.table, VecArray(vec![0])); assert_eq!(result.values.table, VecArray(vec![])); @@ -205,7 +205,7 @@ fn test_operation_adjacency() { // └───┘ let g = OpenHypergraph::singleton(G, y.clone(), y.clone()); let h = (&(&g | &g) >> &f).unwrap(); - let result = operation_adjacency::(&h); + let result = operation_adjacency::(&h.h); assert_eq!(result.sources.table, VecArray(vec![1, 1, 0])); assert_eq!(result.values.table, VecArray(vec![2, 2])); @@ -219,7 +219,7 @@ fn test_operation_adjacency() { // let f_op = OpenHypergraph::singleton(F, y.clone(), x.clone()); let h = (&f >> &f_op).unwrap(); - let result = operation_adjacency(&h); + let result = operation_adjacency(&h.h); assert_eq!(result.sources.table, VecArray(vec![1, 0])); assert_eq!(result.values.table, VecArray(vec![1])); @@ -230,7 +230,7 @@ fn test_operation_adjacency() { // │ │────●────│ │ // └───┘ └───┘ let h = (&f_op >> &f).unwrap(); - let result = operation_adjacency(&h); + let result = operation_adjacency(&h.h); assert_eq!(result.sources.table, VecArray(vec![2, 0])); assert_eq!(result.values.table, VecArray(vec![1, 1])); } From 405265ca3df00030f2d2210779feae804f8ba81e Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:17:51 +0100 Subject: [PATCH 08/19] more tests --- tests/graph/test_graph.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs index e5cc06b..da80dbc 100644 --- a/tests/graph/test_graph.rs +++ b/tests/graph/test_graph.rs @@ -1,3 +1,4 @@ +use open_hypergraphs::array::vec::VecArray; use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; use open_hypergraphs::strict::graph; @@ -23,3 +24,32 @@ fn test_node_adjacency_simple_example() { let expected = vec![vec![2], vec![2], vec![]]; assert_eq!(got, expected); } + +#[test] +fn test_operation_adjacency_simple_example() { + // Nodes: 0, 1, 2 + // Edge e0: sources [0] -> targets [1] + // Edge e1: sources [1] -> targets [2] + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1, 2]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + ); + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(2)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::operation_adjacency(&strict); + + assert_eq!(adjacency.sources.table, VecArray(vec![1, 0])); + assert_eq!(adjacency.values.table, VecArray(vec![1])); +} From 282afab597f118ff97f28873169573ceffc8f90d Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:21:20 +0100 Subject: [PATCH 09/19] clean code --- src/strict/graph.rs | 3 --- tests/graph/test_graph.rs | 5 +++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/strict/graph.rs b/src/strict/graph.rs index 8c42580..c94055b 100644 --- a/src/strict/graph.rs +++ b/src/strict/graph.rs @@ -1,6 +1,3 @@ -//////////////////////////////////////////////////////////////////////////////// -// Graph methods - use crate::array::{Array, ArrayKind, NaturalArray, OrdArray}; use crate::category::Arrow; use crate::finite_function::FiniteFunction; diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs index da80dbc..2d75d84 100644 --- a/tests/graph/test_graph.rs +++ b/tests/graph/test_graph.rs @@ -50,6 +50,7 @@ fn test_operation_adjacency_simple_example() { let strict = lax.to_hypergraph(); let adjacency = graph::operation_adjacency(&strict); - assert_eq!(adjacency.sources.table, VecArray(vec![1, 0])); - assert_eq!(adjacency.values.table, VecArray(vec![1])); + let got: Vec> = adjacency.into_iter().map(|ff| ff.table.0).collect(); + let expected = vec![vec![1], vec![]]; + assert_eq!(got, expected); } From 5a8e8312b7778a63899ff5f3049842c5ed74dd06 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:23:24 +0100 Subject: [PATCH 10/19] format --- src/strict/hypergraph/mod.rs | 2 +- src/strict/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strict/hypergraph/mod.rs b/src/strict/hypergraph/mod.rs index 28191fa..a62e188 100644 --- a/src/strict/hypergraph/mod.rs +++ b/src/strict/hypergraph/mod.rs @@ -1,7 +1,7 @@ //! The category of hypergraphs has objects represented by [`Hypergraph`] //! and arrows by [`arrow::HypergraphArrow`]. -pub mod arrow; mod acyclic; +pub mod arrow; mod object; pub use object::*; diff --git a/src/strict/mod.rs b/src/strict/mod.rs index c57f10c..e20e535 100644 --- a/src/strict/mod.rs +++ b/src/strict/mod.rs @@ -8,8 +8,8 @@ pub mod open_hypergraph; pub mod eval; pub mod functor; -pub mod layer; pub mod graph; +pub mod layer; pub use crate::array::*; pub use crate::category::*; From 6db788aee34bff613076d74beac66c12aff63859 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:25:44 +0100 Subject: [PATCH 11/19] remove unused --- tests/graph/test_graph.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs index 2d75d84..92ebb31 100644 --- a/tests/graph/test_graph.rs +++ b/tests/graph/test_graph.rs @@ -1,4 +1,3 @@ -use open_hypergraphs::array::vec::VecArray; use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; use open_hypergraphs::strict::graph; From afb5b89e772e830732e2dbd2f021cc52b3e2d671 Mon Sep 17 00:00:00 2001 From: mstn Date: Thu, 5 Feb 2026 17:36:35 +0100 Subject: [PATCH 12/19] more tests --- tests/graph/test_graph.rs | 101 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs index 92ebb31..30c0ae2 100644 --- a/tests/graph/test_graph.rs +++ b/tests/graph/test_graph.rs @@ -53,3 +53,104 @@ fn test_operation_adjacency_simple_example() { let expected = vec![vec![1], vec![]]; assert_eq!(got, expected); } + +#[test] +fn test_kahn_acyclic_chain() { + // 0 -> 1 -> 2 + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1, 2]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + ); + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(2)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::node_adjacency(&strict); + let (_order, unvisited) = graph::kahn(&adjacency); + + assert_eq!(unvisited.0, vec![0, 0, 0]); +} + +#[test] +fn test_kahn_cyclic() { + // 0 -> 1 -> 0 + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + ); + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(0)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::node_adjacency(&strict); + let (_order, unvisited) = graph::kahn(&adjacency); + + assert_eq!(unvisited.0, vec![1, 1]); +} + +#[test] +fn test_kahn_disconnected_acyclic() { + // 0 -> 1 and 2 isolated + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1, 2]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::node_adjacency(&strict); + let (_order, unvisited) = graph::kahn(&adjacency); + + assert_eq!(unvisited.0, vec![0, 0, 0]); +} + +#[test] +fn test_kahn_disconnected_with_cycle() { + // 0 -> 1 -> 0 and 2 isolated + let mut lax = LaxHypergraph::empty(); + lax.nodes = vec![0, 1, 2]; + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(0)], + targets: vec![NodeId(1)], + }, + ); + lax.new_edge( + 0, + Hyperedge { + sources: vec![NodeId(1)], + targets: vec![NodeId(0)], + }, + ); + + let strict = lax.to_hypergraph(); + let adjacency = graph::node_adjacency(&strict); + let (_order, unvisited) = graph::kahn(&adjacency); + + assert_eq!(unvisited.0, vec![1, 1, 0]); +} From 018ce822dcf6174a57daa660244ee905c9509438 Mon Sep 17 00:00:00 2001 From: mstn Date: Fri, 6 Feb 2026 09:04:50 +0100 Subject: [PATCH 13/19] add is_convex_subgraph --- src/finite_function/arrow.rs | 16 +++ src/strict/graph.rs | 16 ++- src/strict/hypergraph/arrow.rs | 162 ++++++++++++++++++++++++++++- tests/hypergraph/mod.rs | 1 + tests/hypergraph/test_subobject.rs | 78 ++++++++++++++ 5 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 tests/hypergraph/test_subobject.rs diff --git a/src/finite_function/arrow.rs b/src/finite_function/arrow.rs index cb3467d..88aa5f8 100644 --- a/src/finite_function/arrow.rs +++ b/src/finite_function/arrow.rs @@ -214,6 +214,22 @@ impl FiniteFunction { let table = Array::from_slice(extended_table.get_range(..self.source())); FiniteFunction { table, target } } + +} + +impl FiniteFunction +where + K::Type: NaturalArray, +{ + /// Check if this finite function is injective. + pub fn is_injective(&self) -> bool { + if self.source().is_zero() { + return true; + } + + let counts = self.table.bincount(self.target.clone()); + counts.max().map_or(true, |m| m <= K::I::one()) + } } /// Compute the universal map for a coequalizer `q : B → Q` and arrow `f : B → T`, generalised to diff --git a/src/strict/graph.rs b/src/strict/graph.rs index c94055b..64f91e1 100644 --- a/src/strict/graph.rs +++ b/src/strict/graph.rs @@ -68,7 +68,21 @@ pub fn node_adjacency( where K::Type: NaturalArray, { - converse(&h.s).flatmap(&h.t) + node_adjacency_from_incidence(&h.s, &h.t) +} + +/// Return the node-level adjacency map from incidence data. +/// +/// If `W` is the finite set of nodes, then the result is an indexed coproduct +/// `adjacency : W → W*`, where `adjacency(w)` is all nodes reachable in a single step from `w`. +pub fn node_adjacency_from_incidence( + s: &IndexedCoproduct>, + t: &IndexedCoproduct>, +) -> IndexedCoproduct> +where + K::Type: NaturalArray, +{ + converse(s).flatmap(t) } /// A kahn-ish algorithm for topological sorting of an adjacency relation, encoded as an diff --git a/src/strict/hypergraph/arrow.rs b/src/strict/hypergraph/arrow.rs index 9f8a676..2ada38a 100644 --- a/src/strict/hypergraph/arrow.rs +++ b/src/strict/hypergraph/arrow.rs @@ -1,8 +1,54 @@ use super::object::Hypergraph; -use crate::array::{Array, ArrayKind}; +use crate::array::{Array, ArrayKind, NaturalArray}; use crate::finite_function::FiniteFunction; +use crate::indexed_coproduct::IndexedCoproduct; +use crate::strict::graph::{ + node_adjacency, node_adjacency_from_incidence, sparse_relative_indegree, +}; use core::fmt::Debug; +use num_traits::{One, Zero}; + +fn successors( + adjacency: &IndexedCoproduct>, + frontier: &K::Index, +) -> K::Index +where + K::Type: NaturalArray, +{ + if frontier.is_empty() { + return K::Index::empty(); + } + + let f = FiniteFunction::new(frontier.clone(), adjacency.len()).unwrap(); + let (g, _) = sparse_relative_indegree(adjacency, &f); + g.table +} + +fn filter_unvisited(visited: &K::Index, candidates: &K::Index) -> K::Index +where + K::Type: NaturalArray, +{ + if candidates.is_empty() { + return K::Index::empty(); + } + + let visited_on_candidates = visited.gather(candidates.get_range(..)); + let unvisited_ix = visited_on_candidates.zero(); + candidates.gather(unvisited_ix.get_range(..)) +} + +fn all_in_mask(mask: &K::Index, values: &K::Index) -> bool +where + K::Type: NaturalArray, +{ + if values.is_empty() { + return true; + } + + let hits = mask.gather(values.get_range(..)); + hits.sum() == values.len() +} #[derive(Debug)] pub enum InvalidHypergraphArrow { @@ -72,6 +118,120 @@ where //assert_eq!(g.s.values >> g.w, h.s.indexed_values(f.x) >> h.w); //assert_eq!(g.t.values >> g.w, h.t.indexed_values(f.x) >> h.w); } + + /// True when this arrow is injective on both nodes and edges. + pub fn is_monomorphism(&self) -> bool + where + K::Type: NaturalArray, + { + self.w.is_injective() && self.x.is_injective() + } + + /// True when this arrow is injective on nodes and edges and has no dangling edges in the image. + pub fn is_subobject(&self) -> bool + where + K::Type: NaturalArray, + { + if !self.is_monomorphism() { + return false; + } + + let g = &self.target; + // node_mask[i] = 1 iff node i is in the image + let mut node_mask = K::Index::fill(K::I::zero(), g.w.len()); + node_mask.scatter_assign_constant(&self.w.table, K::I::one()); + + // restrict target incidence to selected edges + let s_in = g.s.map_indexes(&self.x).unwrap(); + let t_in = g.t.map_indexes(&self.x).unwrap(); + let s_vals = s_in.values.table; + let t_vals = t_in.values.table; + + all_in_mask::(&node_mask, &s_vals) && all_in_mask::(&node_mask, &t_vals) + } + + /// Check convexity of a subgraph `H → G`. + /// + pub fn is_convex_subgraph(&self) -> bool + where + K::Type: NaturalArray, + { + if !self.is_subobject() { + return false; + } + + let g = &self.target; + let n_nodes = g.w.len(); + let n_edges = g.x.len(); + + // Build the complement set of edges (those not in the image of x). + let mut edge_mask = K::Index::fill(K::I::zero(), n_edges.clone()); + edge_mask.scatter_assign_constant(&self.x.table, K::I::one()); + let outside_edge_ix = edge_mask.zero(); + let outside_edges = FiniteFunction::new(outside_edge_ix, n_edges).unwrap(); + + // Adjacency restricted to edges in the subobject (inside edges). + let s_in = g.s.map_indexes(&self.x).unwrap(); + let t_in = g.t.map_indexes(&self.x).unwrap(); + let adj_in = node_adjacency_from_incidence(&s_in, &t_in); + + // Adjacency restricted to edges outside the subobject. + let s_out = g.s.map_indexes(&outside_edges).unwrap(); + let t_out = g.t.map_indexes(&outside_edges).unwrap(); + let adj_out = node_adjacency_from_incidence(&s_out, &t_out); + + // Full adjacency (used after we've already left the subobject). + let adj_all = node_adjacency(g); + + // Two-layer reachability: + // - layer 0: paths that have used only inside edges + // - layer 1: paths that have used at least one outside edge + // + // Convexity fails iff some selected node is reachable in layer 1. + let mut visited0 = K::Index::fill(K::I::zero(), n_nodes.clone()); + let mut visited1 = K::Index::fill(K::I::zero(), n_nodes.clone()); + let mut frontier0 = self.w.table.clone(); + let mut frontier1 = K::Index::empty(); + + // Seed search from the selected nodes. + visited0.scatter_assign_constant(&frontier0, K::I::one()); + + while !frontier0.is_empty() || !frontier1.is_empty() { + // From layer 0, inside edges stay in layer 0. + let next0: K::Index = successors::(&adj_in, &frontier0); + // From layer 0, outside edges move to layer 1. + let next1_from0 = successors::(&adj_out, &frontier0); + // From layer 1, any edge keeps you in layer 1. + let next1_from1 = successors::(&adj_all, &frontier1); + + // Avoid revisiting nodes we've already seen in the same layer. + let next0: K::Index = filter_unvisited::(&visited0, &next0); + + let next1: K::Index = { + let merged = next1_from0.concatenate(&next1_from1); + if merged.is_empty() { + K::Index::empty() + } else { + let (unique, _) = merged.sparse_bincount(); + filter_unvisited::(&visited1, &unique) + } + }; + + if next0.is_empty() && next1.is_empty() { + break; + } + + // Mark and advance frontiers. + visited0.scatter_assign_constant(&next0, K::I::one()); + visited1.scatter_assign_constant(&next1, K::I::one()); + frontier0 = next0; + frontier1 = next1; + } + + // If any selected node is reachable in layer 1, it's not convex. + let reached_selected = visited1.gather(self.w.table.get_range(..)); + !reached_selected.max().map_or(false, |m| m >= K::I::one()) + } } impl Debug for HypergraphArrow diff --git a/tests/hypergraph/mod.rs b/tests/hypergraph/mod.rs index 31e4727..20a48e4 100644 --- a/tests/hypergraph/mod.rs +++ b/tests/hypergraph/mod.rs @@ -1,3 +1,4 @@ pub mod equality; pub mod strategy; pub mod test_hypergraph; +pub mod test_subobject; diff --git a/tests/hypergraph/test_subobject.rs b/tests/hypergraph/test_subobject.rs new file mode 100644 index 0000000..270803f --- /dev/null +++ b/tests/hypergraph/test_subobject.rs @@ -0,0 +1,78 @@ +use open_hypergraphs::array::vec::*; +use open_hypergraphs::finite_function::FiniteFunction; +use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; +use open_hypergraphs::strict::hypergraph::arrow::HypergraphArrow; +use open_hypergraphs::strict::hypergraph::Hypergraph; + +type Obj = usize; +type Arr = usize; + +fn build_hypergraph( + node_count: usize, + edges: &[(Vec, Vec)], +) -> Hypergraph { + let mut h = LaxHypergraph::empty(); + h.nodes = vec![0; node_count]; + for (sources, targets) in edges { + let edge = Hyperedge { + sources: sources.iter().map(|&i| NodeId(i)).collect(), + targets: targets.iter().map(|&i| NodeId(i)).collect(), + }; + h.new_edge(0, edge); + } + h.to_hypergraph() +} + +fn ff(table: Vec, target: usize) -> FiniteFunction { + FiniteFunction::new(VecArray(table), target).unwrap() +} + +#[test] +fn test_is_monomorphism_false_for_non_injective_w() { + let h = build_hypergraph(2, &[]); + let g = build_hypergraph(1, &[]); + let w = ff(vec![0, 0], 1); + let x = ff(vec![], 0); + let arrow = HypergraphArrow::new(h, g, w, x).unwrap(); + assert!(!arrow.is_monomorphism()); +} + +#[test] +fn test_convex_subgraph_false_with_shortcut() { + let g = build_hypergraph( + 3, + &[(vec![0], vec![1]), (vec![1], vec![2]), (vec![0], vec![2])], + ); + let h = build_hypergraph(3, &[(vec![0], vec![1]), (vec![1], vec![2])]); + + let w = ff(vec![0, 1, 2], 3); + let x = ff(vec![0, 1], 3); + let arrow = HypergraphArrow::new(h, g, w, x).unwrap(); + assert!(!arrow.is_convex_subgraph()); +} + +#[test] +fn test_convex_subgraph_true_identity() { + let g = build_hypergraph( + 3, + &[(vec![0], vec![1]), (vec![1], vec![2]), (vec![0], vec![2])], + ); + let w = ff(vec![0, 1, 2], 3); + let x = ff(vec![0, 1, 2], 3); + let arrow = HypergraphArrow::new(g.clone(), g, w, x).unwrap(); + assert!(arrow.is_convex_subgraph()); +} + +#[test] +fn test_convex_subgraph_true_small() { + let g = build_hypergraph( + 3, + &[(vec![0], vec![1]), (vec![1], vec![2]), (vec![0], vec![2])], + ); + let h = build_hypergraph(2, &[(vec![0], vec![1])]); + + let w = ff(vec![0, 1], 3); + let x = ff(vec![0], 3); + let arrow = HypergraphArrow::new(h, g, w, x).unwrap(); + assert!(arrow.is_convex_subgraph()); +} From c2c9f20bb42e539244a774a2693c96158468cda0 Mon Sep 17 00:00:00 2001 From: mstn Date: Fri, 6 Feb 2026 09:13:10 +0100 Subject: [PATCH 14/19] format --- src/finite_function/arrow.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/finite_function/arrow.rs b/src/finite_function/arrow.rs index 88aa5f8..359ec67 100644 --- a/src/finite_function/arrow.rs +++ b/src/finite_function/arrow.rs @@ -214,7 +214,6 @@ impl FiniteFunction { let table = Array::from_slice(extended_table.get_range(..self.source())); FiniteFunction { table, target } } - } impl FiniteFunction From ae7acdb3164339178a1cdb68de085b23e8bfa6b2 Mon Sep 17 00:00:00 2001 From: mstn Date: Sat, 28 Feb 2026 07:46:51 +0100 Subject: [PATCH 15/19] validate naturality sources/targets --- src/strict/hypergraph/arrow.rs | 41 ++++++++++++++++++++++++++++------ tests/hypergraph/strategy.rs | 15 +++++-------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/strict/hypergraph/arrow.rs b/src/strict/hypergraph/arrow.rs index 2ada38a..837cfa9 100644 --- a/src/strict/hypergraph/arrow.rs +++ b/src/strict/hypergraph/arrow.rs @@ -54,6 +54,8 @@ where pub enum InvalidHypergraphArrow { NotNaturalW, NotNaturalX, + NotNaturalS, + NotNaturalT, } pub struct HypergraphArrow { @@ -81,7 +83,10 @@ where target: Hypergraph, w: FiniteFunction, x: FiniteFunction, - ) -> Result { + ) -> Result + where + K::Type: NaturalArray, + { HypergraphArrow { source, target, @@ -92,7 +97,10 @@ where } /// Check validity of a HypergraphArrow. - pub fn validate(self) -> Result { + pub fn validate(self) -> Result + where + K::Type: NaturalArray, + { // for self : g → h let g = &self.source; let h = &self.target; @@ -111,12 +119,31 @@ where return Err(InvalidHypergraphArrow::NotNaturalX); } - Ok(self) + // Check naturality of incidence (sources): + // g.s; w = h.s reindexed along x. + let s_lhs = + g.s.map_values(&self.w) + .ok_or(InvalidHypergraphArrow::NotNaturalS)?; + let s_rhs = + h.s.map_indexes(&self.x) + .ok_or(InvalidHypergraphArrow::NotNaturalS)?; + if s_lhs != s_rhs { + return Err(InvalidHypergraphArrow::NotNaturalS); + } - // TODO: add this check. - // Types of operations are also preserved under w and x. - //assert_eq!(g.s.values >> g.w, h.s.indexed_values(f.x) >> h.w); - //assert_eq!(g.t.values >> g.w, h.t.indexed_values(f.x) >> h.w); + // Check naturality of incidence (targets). + // g.t; w = h.t reindexed along x. + let t_lhs = + g.t.map_values(&self.w) + .ok_or(InvalidHypergraphArrow::NotNaturalT)?; + let t_rhs = + h.t.map_indexes(&self.x) + .ok_or(InvalidHypergraphArrow::NotNaturalT)?; + if t_lhs != t_rhs { + return Err(InvalidHypergraphArrow::NotNaturalT); + } + + Ok(self) } /// True when this arrow is injective on both nodes and edges. diff --git a/tests/hypergraph/strategy.rs b/tests/hypergraph/strategy.rs index 8022f2a..a198b24 100644 --- a/tests/hypergraph/strategy.rs +++ b/tests/hypergraph/strategy.rs @@ -174,15 +174,12 @@ pub fn arb_inclusion< labels: Labels, g: Hypergraph, ) -> BoxedStrategy> { - // We have an arbitrary hypergraph g, and some arbitrary label arrays. - let h_labels = Labels { - w: g.w.clone() + labels.w, - x: g.x.clone() + labels.x, - }; - arb_hypergraph(h_labels) - .prop_flat_map(move |h| { - let w = FiniteFunction::inj0(g.w.len(), h.w.len() - g.w.len()); - let x = FiniteFunction::inj0(g.x.len(), h.x.len() - g.x.len()); + // Build target as a coproduct g + k so inj0 is incidence-natural by construction. + arb_hypergraph(labels) + .prop_flat_map(move |k| { + let h = g.coproduct(&k); + let w = FiniteFunction::inj0(g.w.len(), k.w.len()); + let x = FiniteFunction::inj0(g.x.len(), k.x.len()); Just(HypergraphArrow::new(g.clone(), h, w, x).expect("valid HypergraphArrow")) }) From 4dae8d740fdba8f41e50495b7a0a89ffc30c7a10 Mon Sep 17 00:00:00 2001 From: mstn Date: Sat, 28 Feb 2026 07:49:37 +0100 Subject: [PATCH 16/19] remove is_subobject --- src/strict/hypergraph/arrow.rs | 37 +--------------------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/src/strict/hypergraph/arrow.rs b/src/strict/hypergraph/arrow.rs index 837cfa9..3fa6dd1 100644 --- a/src/strict/hypergraph/arrow.rs +++ b/src/strict/hypergraph/arrow.rs @@ -38,18 +38,6 @@ where candidates.gather(unvisited_ix.get_range(..)) } -fn all_in_mask(mask: &K::Index, values: &K::Index) -> bool -where - K::Type: NaturalArray, -{ - if values.is_empty() { - return true; - } - - let hits = mask.gather(values.get_range(..)); - hits.sum() == values.len() -} - #[derive(Debug)] pub enum InvalidHypergraphArrow { NotNaturalW, @@ -154,36 +142,13 @@ where self.w.is_injective() && self.x.is_injective() } - /// True when this arrow is injective on nodes and edges and has no dangling edges in the image. - pub fn is_subobject(&self) -> bool - where - K::Type: NaturalArray, - { - if !self.is_monomorphism() { - return false; - } - - let g = &self.target; - // node_mask[i] = 1 iff node i is in the image - let mut node_mask = K::Index::fill(K::I::zero(), g.w.len()); - node_mask.scatter_assign_constant(&self.w.table, K::I::one()); - - // restrict target incidence to selected edges - let s_in = g.s.map_indexes(&self.x).unwrap(); - let t_in = g.t.map_indexes(&self.x).unwrap(); - let s_vals = s_in.values.table; - let t_vals = t_in.values.table; - - all_in_mask::(&node_mask, &s_vals) && all_in_mask::(&node_mask, &t_vals) - } - /// Check convexity of a subgraph `H → G`. /// pub fn is_convex_subgraph(&self) -> bool where K::Type: NaturalArray, { - if !self.is_subobject() { + if !self.is_monomorphism() { return false; } From 75755a9015dddc720946e29fbd62bcf653cd34e1 Mon Sep 17 00:00:00 2001 From: mstn Date: Sat, 28 Feb 2026 07:55:35 +0100 Subject: [PATCH 17/19] reorg tests; add more for naturality --- tests/hypergraph/mod.rs | 3 +- tests/hypergraph/test_arrow.rs | 80 +++++++++++++++++++ .../{test_subobject.rs => test_monogamous.rs} | 0 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/hypergraph/test_arrow.rs rename tests/hypergraph/{test_subobject.rs => test_monogamous.rs} (100%) diff --git a/tests/hypergraph/mod.rs b/tests/hypergraph/mod.rs index 20a48e4..6fb56c2 100644 --- a/tests/hypergraph/mod.rs +++ b/tests/hypergraph/mod.rs @@ -1,4 +1,5 @@ pub mod equality; pub mod strategy; +pub mod test_arrow; pub mod test_hypergraph; -pub mod test_subobject; +pub mod test_monogamous; diff --git a/tests/hypergraph/test_arrow.rs b/tests/hypergraph/test_arrow.rs new file mode 100644 index 0000000..80aa163 --- /dev/null +++ b/tests/hypergraph/test_arrow.rs @@ -0,0 +1,80 @@ +use open_hypergraphs::array::vec::*; +use open_hypergraphs::finite_function::FiniteFunction; +use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; +use open_hypergraphs::strict::hypergraph::arrow::{HypergraphArrow, InvalidHypergraphArrow}; +use open_hypergraphs::strict::hypergraph::Hypergraph; + +type Obj = usize; +type Arr = usize; + +fn build_hypergraph( + node_labels: Vec, + edges: &[(usize, Vec, Vec)], +) -> Hypergraph { + let mut h = LaxHypergraph::empty(); + h.nodes = node_labels; + for (label, sources, targets) in edges { + let edge = Hyperedge { + sources: sources.iter().map(|&i| NodeId(i)).collect(), + targets: targets.iter().map(|&i| NodeId(i)).collect(), + }; + h.new_edge(*label, edge); + } + h.to_hypergraph() +} + +fn ff(table: Vec, target: usize) -> FiniteFunction { + FiniteFunction::new(VecArray(table), target).unwrap() +} + +#[test] +fn test_validate_rejects_non_natural_w() { + let source = build_hypergraph(vec![0], &[]); + let target = build_hypergraph(vec![1], &[]); + let w = ff(vec![0], 1); + let x = ff(vec![], 0); + let err = HypergraphArrow::new(source, target, w, x).unwrap_err(); + assert!(matches!(err, InvalidHypergraphArrow::NotNaturalW)); +} + +#[test] +fn test_validate_rejects_non_natural_x() { + let source = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + let target = build_hypergraph(vec![0, 0], &[(1, vec![0], vec![1])]); + let w = ff(vec![0, 1], 2); + let x = ff(vec![0], 1); + let err = HypergraphArrow::new(source, target, w, x).unwrap_err(); + assert!(matches!(err, InvalidHypergraphArrow::NotNaturalX)); +} + +#[test] +fn test_validate_accepts_incidence_natural_arrow() { + let source = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + let target = build_hypergraph(vec![0, 0], &[(0, vec![1], vec![1])]); + + let w = ff(vec![1, 1], 2); + let x = ff(vec![0], 1); + assert!(HypergraphArrow::new(source, target, w, x).is_ok()); +} + +#[test] +fn test_validate_rejects_non_natural_s() { + let source = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + let target = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + + let w = ff(vec![1, 1], 2); + let x = ff(vec![0], 1); + let err = HypergraphArrow::new(source, target, w, x).unwrap_err(); + assert!(matches!(err, InvalidHypergraphArrow::NotNaturalS)); +} + +#[test] +fn test_validate_rejects_non_natural_t() { + let source = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + let target = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); + + let w = ff(vec![0, 0], 2); + let x = ff(vec![0], 1); + let err = HypergraphArrow::new(source, target, w, x).unwrap_err(); + assert!(matches!(err, InvalidHypergraphArrow::NotNaturalT)); +} diff --git a/tests/hypergraph/test_subobject.rs b/tests/hypergraph/test_monogamous.rs similarity index 100% rename from tests/hypergraph/test_subobject.rs rename to tests/hypergraph/test_monogamous.rs From 9f1630fe960c5104e72c94408cedb402dec2cf9f Mon Sep 17 00:00:00 2001 From: mstn Date: Sat, 28 Feb 2026 08:13:47 +0100 Subject: [PATCH 18/19] dont panic if composition fails --- src/strict/hypergraph/arrow.rs | 14 ++++++++++---- tests/hypergraph/test_arrow.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/strict/hypergraph/arrow.rs b/src/strict/hypergraph/arrow.rs index 3fa6dd1..2fcffbd 100644 --- a/src/strict/hypergraph/arrow.rs +++ b/src/strict/hypergraph/arrow.rs @@ -40,6 +40,8 @@ where #[derive(Debug)] pub enum InvalidHypergraphArrow { + TypeMismatchW, + TypeMismatchX, NotNaturalW, NotNaturalX, NotNaturalS, @@ -97,13 +99,17 @@ where // wire labels, operation labels, and operation types should be preserved under the natural // transformations w and x. - // Check naturality of w - if g.w != (&self.w >> &h.w).unwrap() { + // Check naturality on node labels: + // g.w = w ; h.w + let composed_w = (&self.w >> &h.w).ok_or(InvalidHypergraphArrow::TypeMismatchW)?; + if g.w != composed_w { return Err(InvalidHypergraphArrow::NotNaturalW); } - // Check naturality of x - if g.x != (&self.x >> &h.x).unwrap() { + // Check naturality on operation labels: + // g.x = x ; h.x + let composed_x = (&self.x >> &h.x).ok_or(InvalidHypergraphArrow::TypeMismatchX)?; + if g.x != composed_x { return Err(InvalidHypergraphArrow::NotNaturalX); } diff --git a/tests/hypergraph/test_arrow.rs b/tests/hypergraph/test_arrow.rs index 80aa163..d19557b 100644 --- a/tests/hypergraph/test_arrow.rs +++ b/tests/hypergraph/test_arrow.rs @@ -47,6 +47,18 @@ fn test_validate_rejects_non_natural_x() { assert!(matches!(err, InvalidHypergraphArrow::NotNaturalX)); } +#[test] +fn test_validate_rejects_undefined_w_composition() { + let source = build_hypergraph(vec![0], &[]); + let target = build_hypergraph(vec![0], &[]); + // valid finite function, but codomain size (2) does not match h.w.len() (1), + // so w ; h.w is undefined and validate should return TypeMismatchW. + let w = ff(vec![0], 2); + let x = ff(vec![], 0); + let err = HypergraphArrow::new(source, target, w, x).unwrap_err(); + assert!(matches!(err, InvalidHypergraphArrow::TypeMismatchW)); +} + #[test] fn test_validate_accepts_incidence_natural_arrow() { let source = build_hypergraph(vec![0, 0], &[(0, vec![0], vec![1])]); From 18c73b16ebbd3ee5f795c7fa74eda5c573dfbe1c Mon Sep 17 00:00:00 2001 From: mstn Date: Sat, 28 Feb 2026 08:29:15 +0100 Subject: [PATCH 19/19] removed unused tests --- tests/graph/mod.rs | 1 - tests/graph/test_graph.rs | 156 -------------------------------------- tests/lib.rs | 1 - 3 files changed, 158 deletions(-) delete mode 100644 tests/graph/mod.rs delete mode 100644 tests/graph/test_graph.rs diff --git a/tests/graph/mod.rs b/tests/graph/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/tests/graph/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/graph/test_graph.rs b/tests/graph/test_graph.rs deleted file mode 100644 index 30c0ae2..0000000 --- a/tests/graph/test_graph.rs +++ /dev/null @@ -1,156 +0,0 @@ -use open_hypergraphs::lax::{Hyperedge, Hypergraph as LaxHypergraph, NodeId}; -use open_hypergraphs::strict::graph; - -#[test] -fn test_node_adjacency_simple_example() { - // Nodes: 0, 1, 2 - // Edge e0: sources [0, 1] -> targets [2] - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1, 2]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0), NodeId(1)], - targets: vec![NodeId(2)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::node_adjacency(&strict); - - let got: Vec> = adjacency.into_iter().map(|ff| ff.table.0).collect(); - - let expected = vec![vec![2], vec![2], vec![]]; - assert_eq!(got, expected); -} - -#[test] -fn test_operation_adjacency_simple_example() { - // Nodes: 0, 1, 2 - // Edge e0: sources [0] -> targets [1] - // Edge e1: sources [1] -> targets [2] - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1, 2]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - ); - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(2)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::operation_adjacency(&strict); - - let got: Vec> = adjacency.into_iter().map(|ff| ff.table.0).collect(); - let expected = vec![vec![1], vec![]]; - assert_eq!(got, expected); -} - -#[test] -fn test_kahn_acyclic_chain() { - // 0 -> 1 -> 2 - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1, 2]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - ); - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(2)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::node_adjacency(&strict); - let (_order, unvisited) = graph::kahn(&adjacency); - - assert_eq!(unvisited.0, vec![0, 0, 0]); -} - -#[test] -fn test_kahn_cyclic() { - // 0 -> 1 -> 0 - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - ); - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(0)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::node_adjacency(&strict); - let (_order, unvisited) = graph::kahn(&adjacency); - - assert_eq!(unvisited.0, vec![1, 1]); -} - -#[test] -fn test_kahn_disconnected_acyclic() { - // 0 -> 1 and 2 isolated - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1, 2]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::node_adjacency(&strict); - let (_order, unvisited) = graph::kahn(&adjacency); - - assert_eq!(unvisited.0, vec![0, 0, 0]); -} - -#[test] -fn test_kahn_disconnected_with_cycle() { - // 0 -> 1 -> 0 and 2 isolated - let mut lax = LaxHypergraph::empty(); - lax.nodes = vec![0, 1, 2]; - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(0)], - targets: vec![NodeId(1)], - }, - ); - lax.new_edge( - 0, - Hyperedge { - sources: vec![NodeId(1)], - targets: vec![NodeId(0)], - }, - ); - - let strict = lax.to_hypergraph(); - let adjacency = graph::node_adjacency(&strict); - let (_order, unvisited) = graph::kahn(&adjacency); - - assert_eq!(unvisited.0, vec![1, 1, 0]); -} diff --git a/tests/lib.rs b/tests/lib.rs index 555f164..a38ebda 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -13,6 +13,5 @@ pub mod hypergraph; pub mod open_hypergraph; pub mod eval; -pub mod graph; pub mod lax; pub mod layer;