diff --git a/src/EpsilonOptimization/EpsilonOptimization.jl b/src/EpsilonOptimization/EpsilonOptimization.jl index a880a00..54ef2f5 100644 --- a/src/EpsilonOptimization/EpsilonOptimization.jl +++ b/src/EpsilonOptimization/EpsilonOptimization.jl @@ -31,6 +31,7 @@ export AlphaBranchAndBound include("types.jl") +include("utils.jl") include("core.jl") include("solvers/lipschitz_branch_and_bound.jl") diff --git a/src/EpsilonOptimization/solvers/alpha_branch_and_bound.jl b/src/EpsilonOptimization/solvers/alpha_branch_and_bound.jl index 855a631..7a5f9bd 100644 --- a/src/EpsilonOptimization/solvers/alpha_branch_and_bound.jl +++ b/src/EpsilonOptimization/solvers/alpha_branch_and_bound.jl @@ -11,9 +11,25 @@ """ struct AlphaBranchAndBound <: AbstractEpsilonSolver epsilon::Real - threshold::Union{<:Real,Nothing} + target::Union{<:Real,Nothing} max_iterations::Union{<:Integer,Nothing} alpha::Real + + function AlphaBranchAndBound( + epsilon::Real, + alpha::Real; + target::Union{<:Real,Nothing}=nothing, + max_iterations::Union{<:Integer,Nothing}=nothing, + ) + solver = new(epsilon, target, max_iterations, alpha) + validate_solver_params(solver) + + if alpha <= 0 + throw(ArgumentError("Alpha parameter must be positive, got $alpha")) + end + + return solver + end end """ @@ -30,7 +46,7 @@ struct ABBHyperrectangle{Tx<:AbstractVector{<:Real},Tf<:Real} function ABBHyperrectangle( lower::Tx, upper::Tx, f::Function, alpha::Real ) where {Tx<:AbstractVector{<:Real}} - f_convex(x::Tx) = f(x) + alpha * sum((x .- lower) .* (upper .- x)) + f_convex(x::Tx) = f(x) - alpha * sum((x .- lower) .* (upper .- x)) x0 = lower .+ (upper .- lower) ./ 2 res = optimize(f_convex, lower, upper, x0, Fminbox(LBFGS())) @@ -51,10 +67,10 @@ function _epsilon_minimize_impl( epsilon = solver.epsilon alpha = solver.alpha - if isnothing(solver.threshold) - threshold = -Inf + if isnothing(solver.target) + target = -Inf else - threshold = solver.threshold + target = solver.target end if isnothing(solver.max_iterations) @@ -76,9 +92,9 @@ function _epsilon_minimize_impl( iterations < max_iterations && !isempty(rects_cand) && ( - isinf(threshold) || ( - min(lower_bound, first(rects_cand).lower_bound) <= threshold && - minimum - threshold >= epsilon + isinf(target) || ( + min(lower_bound, first(rects_cand).lower_bound) <= target && + minimum - target >= epsilon ) ) ) @@ -123,8 +139,8 @@ function _epsilon_minimize_impl( minimum, ( epsilon_optimal=minimum - lower_bound < epsilon, - threshold_reached=minimum - threshold < epsilon, - threshold_unreachable=-Inf < threshold < lower_bound, + target_reached=minimum - target < epsilon, + target_unreachable=-Inf < target < lower_bound, iterations=iterations >= max_iterations, ), ) diff --git a/src/EpsilonOptimization/solvers/lipschitz_branch_and_bound.jl b/src/EpsilonOptimization/solvers/lipschitz_branch_and_bound.jl index 2e33fc4..182a4d0 100644 --- a/src/EpsilonOptimization/solvers/lipschitz_branch_and_bound.jl +++ b/src/EpsilonOptimization/solvers/lipschitz_branch_and_bound.jl @@ -11,9 +11,29 @@ """ struct LipschitzBranchAndBound <: AbstractEpsilonSolver epsilon::Real - threshold::Union{<:Real,Nothing} + target::Union{<:Real,Nothing} max_iterations::Union{<:Integer,Nothing} lipschitz_constant::Real + + function LipschitzBranchAndBound( + epsilon::Real, + lipschitz_constant::Real; + target::Union{<:Real,Nothing}=nothing, + max_iterations::Union{<:Integer,Nothing}=nothing, + ) + solver = new(epsilon, target, max_iterations, lipschitz_constant) + validate_solver_params(solver) + + if lipschitz_constant <= 0 + throw( + ArgumentError( + "Lipschitz constant must be positive, got $lipschitz_constant" + ), + ) + end + + return solver + end end """ @@ -49,10 +69,10 @@ function _epsilon_minimize_impl( epsilon = solver.epsilon lipschitz_constant = solver.lipschitz_constant - if isnothing(solver.threshold) - threshold = -Inf + if isnothing(solver.target) + target = -Inf else - threshold = solver.threshold + target = solver.target end if isnothing(solver.max_iterations) @@ -74,9 +94,9 @@ function _epsilon_minimize_impl( iterations < max_iterations && !isempty(rects_cand) && ( - isinf(threshold) || ( - min(lower_bound, first(rects_cand).lower_bound) <= threshold && - minimum - threshold >= epsilon + isinf(target) || ( + min(lower_bound, first(rects_cand).lower_bound) <= target && + minimum - target >= epsilon ) ) ) @@ -118,8 +138,8 @@ function _epsilon_minimize_impl( minimum, ( epsilon_optimal=minimum - lower_bound < epsilon, - threshold_reached=minimum - threshold < epsilon, - threshold_unreachable=-Inf < threshold < lower_bound, + target_reached=minimum - target < epsilon, + target_unreachable=-Inf < target < lower_bound, iterations=iterations >= max_iterations, ), ) diff --git a/src/EpsilonOptimization/types.jl b/src/EpsilonOptimization/types.jl index 5de5ad7..a28517d 100644 --- a/src/EpsilonOptimization/types.jl +++ b/src/EpsilonOptimization/types.jl @@ -16,9 +16,9 @@ Concrete subtypes of `AbstractEpsilonSolver` must implement the following method Concrete subtypes of `AbstractEpsilonSolver` must also have the following fields: - `epsilon::Real`: The tolerance for ``ϵ``-convergence. -- `threshold::Union{<:Real,Nothing}`: An optional threshold value resulting in termination - once either (1) a function value less than `threshold + epsilon` is found or (2) it is - determined that no function value less than or equal to `threshold` exists. +- `target::Union{<:Real,Nothing}`: An optional threshold value resulting in termination once + either (1) a function value less than `target + epsilon` is found or (2) it is + determined that no function value less than or equal to `target` exists. - `max_iterations::Union{<:Integer,Nothing}`: An optional maximum number of iterations after which the algorithm will terminate. """ @@ -38,10 +38,10 @@ Output struct for ``ϵ``-optimization results. - `minimum::Tf<:Real`: The estimated global minimum function value. - `stopped_by::NamedTuple`: The reason(s) for termination, namely: - `:epsilon_optimal::Bool`: Whether ``ϵ``-optimality was achieved. - - `:threshold_reached::Bool`: Whether ta function value less than `threshold + epsilon` - was found for some optionally provided `threshold`. - - `:threshold_unreachable::Bool`: Whether it was determined that no function value less - than or equal to `threshold` existed for some optionally provided `threshold`. + - `:target_reached::Bool`: Whether ta function value less than `target + epsilon` was + found for some optionally provided `target`. + - `:target_unreachable::Bool`: Whether it was determined that no function value less + than or equal to `target` existed for some optionally provided `target`. - `:iterations::Bool`: Whether termination occurred due to reaching the maximum number of iterations for some optionally provided `max_iterations`. """ @@ -53,17 +53,16 @@ struct EpsilonMinimizationResult{Tx<:AbstractVector{<:Real},Tf<:Real} minimizer::Tx minimum::Tf stopped_by::NamedTuple{ - (:epsilon_optimal, :threshold_reached, :threshold_unreachable, :iterations), + (:epsilon_optimal, :target_reached, :target_unreachable, :iterations), Tuple{Bool,Bool,Bool,Bool}, } end -# The `Base.show` override here takes heavy inspiration from the Optim.jl package function Base.show(io::IO, res::EpsilonMinimizationResult) println(io, "Results of Epsilon Minimization Algorithm") print(io, " * Status: ") - if res.stopped_by.epsilon_optimal || res.stopped_by.threshold_reached + if res.stopped_by.epsilon_optimal || res.stopped_by.target_reached println(io, "success") else println(io, "failure") @@ -73,8 +72,8 @@ function Base.show(io::IO, res::EpsilonMinimizationResult) println(io, " * Lower bounds: $(res.lower)") println(io, " * Upper bounds: $(res.upper)") println(io, " * Epsilon tolerance: $(res.epsilon)") - println(io, " * Estimated minimizer: $(res.minimizer)") - println(io, " * Estimated minimum: $(res.minimum)") + println(io, " * Minimizer: $(res.minimizer)") + println(io, " * Minimum: $(res.minimum)") return nothing end diff --git a/src/EpsilonOptimization/utils.jl b/src/EpsilonOptimization/utils.jl new file mode 100644 index 0000000..2b5413f --- /dev/null +++ b/src/EpsilonOptimization/utils.jl @@ -0,0 +1,37 @@ +# Copyright 2025 Luis M. B. Varona and Nathaniel Johnston +# +# Licensed under the MIT license . This file may not be copied, modified, or +# distributed except according to those terms. + +""" + validate_solver_params(solver) + +[TODO: Write here] + +# Arguments +[TODO: Write here] + +# Raises +[TODO: Write here] + +# Returns +[TODO: Write here] +""" +function validate_solver_params(solver::AbstractEpsilonSolver) + epsilon = solver.epsilon + + if epsilon <= 0 + throw(ArgumentError("Epsilon parameter must be positive, got $epsilon")) + end + + max_iterations = solver.max_iterations + + if !isnothing(max_iterations) && max_iterations <= 0 + throw( + ArgumentError( + "Max iterations must be positive or nothing, got $(max_iterations)" + ), + ) + end +end diff --git a/src/QuantumStateTransfer.jl b/src/QuantumStateTransfer.jl index 8b36be3..7e396e3 100644 --- a/src/QuantumStateTransfer.jl +++ b/src/QuantumStateTransfer.jl @@ -22,9 +22,12 @@ include("types.jl") include("EpsilonOptimization/EpsilonOptimization.jl") using .EpsilonOptimization -include("core.jl") +include("state_transfer.jl") +include("uniform_mixing.jl") +include("fractional_revival.jl") -# TODO: Exports +# TODO: Exports (add more later) +export max_state_transfer, check_state_transfer include("startup.jl") diff --git a/src/core.jl b/src/fractional_revival.jl similarity index 100% rename from src/core.jl rename to src/fractional_revival.jl diff --git a/src/state_transfer.jl b/src/state_transfer.jl new file mode 100644 index 0000000..b616831 --- /dev/null +++ b/src/state_transfer.jl @@ -0,0 +1,379 @@ +# Copyright 2025 Luis M. B. Varona and Nathaniel Johnston +# +# +# Licensed under the MIT license . This file may not be copied, modified, or +# distributed except according to those terms. + +""" + StateTransferMaximizationResult{Tn,Ts} + +[TODO: Write here] +""" +struct StateTransferMaximizationResult{ + Tn<:Union{AbstractGraph,Matrix{Float64}},Ts<:Union{Int,Tuple{Int,Int}} +} + network::Tn + src::Ts + dst::Ts + t_lower::Float64 + t_upper::Float64 + epsilon::Float64 + maximizer::Float64 + max_fidelity::Float64 +end + +function Base.show(io::IO, res::StateTransferMaximizationResult{Tn,Int}) where {Tn} + println(io, "Vertex State Transfer Maximization:") + println(io, " * Network: $(summary(res.network))") + println(io, " * Source vertex: $(res.src)") + println(io, " * Destination vertex: $(res.dst)") + println(io, " * Time interval: [$(res.t_lower), $(res.t_upper)]") + println(io, " * Epsilon tolerance: $(res.epsilon)") + println(io, " * Maximizing time: $(res.maximizer)") + println(io, " * Maximum fidelity: $(res.max_fidelity)") + + return nothing +end + +function Base.show( + io::IO, res::StateTransferMaximizationResult{Tn,Tuple{Int,Int}} +) where {Tn} + println(io, "Pair State Transfer Maximization:") + println(io, " * Network: $(summary(res.network))") + println(io, " * Source vertices: $(res.src)") + println(io, " * Destination vertices: $(res.dst)") + println(io, " * Time interval: [$(res.t_lower), $(res.t_upper)]") + println(io, " * Epsilon tolerance: $(res.epsilon)") + println(io, " * Maximizing time: $(res.maximizer)") + println(io, " * Maximum fidelity: $(res.max_fidelity)") + + return nothing +end + +""" + StateTransferRecognitionResult{Tn,Ts} + +[TODO: Write here] +""" +struct StateTransferRecognitionResult{ + Tn<:Union{AbstractGraph,Matrix{Float64}},Ts<:Union{Int,Tuple{Int,Int}} +} + network::Tn + src::Ts + dst::Ts + t_lower::Float64 + t_upper::Float64 + epsilon::Float64 + target_fidelity::Float64 + achieved::Bool + time_achieved::Union{Nothing,Float64} + fidelity_achieved::Union{Nothing,Float64} +end + +function Base.show(io::IO, res::StateTransferRecognitionResult{Tn,Int}) where {Tn} + println(io, "Vertex State Transfer Recognition:") + println(io, " * Network: $(summary(res.network))") + println(io, " * Source vertex: $(res.src)") + println(io, " * Destination vertex: $(res.dst)") + println(io, " * Time interval: [$(res.t_lower), $(res.t_upper)]") + println(io, " * Epsilon tolerance: $(res.epsilon)") + println(io, " * Target fidelity: $(res.target_fidelity)") + println(io, " * Achieved: $(res.achieved)") + + if res.achieved + println(io, " * Time achieved: $(res.time_achieved)") + println(io, " * Fidelity achieved: $(res.fidelity_achieved)") + end + + return nothing +end + +function Base.show( + io::IO, res::StateTransferRecognitionResult{Tn,Tuple{Int,Int}} +) where {Tn} + println(io, "Pair State Transfer Recognition:") + println(io, " * Network: $(summary(res.network))") + println(io, " * Source vertices: $(res.src)") + println(io, " * Destination vertices: $(res.dst)") + println(io, " * Time interval: [$(res.t_lower), $(res.t_upper)]") + println(io, " * Epsilon tolerance: $(res.epsilon)") + println(io, " * Target fidelity: $(res.target_fidelity)") + println(io, " * Achieved: $(res.achieved)") + + if res.achieved + println(io, " * Time achieved: $(res.time_achieved)") + println(io, " * Fidelity achieved: $(res.fidelity_achieved)") + end + + return nothing +end + +""" + max_state_transfer(g::AbstractGraph, args...) -> StateTransferMaximizationResult + max_state_transfer(A::AbstractMatrix{<:Real}, args...) -> StateTransferMaximizationResult + +[TODO: Write here] + +# Arguments +[TODO: Write here] + +# Optional Arguments +[TODO: Write here] + +# Raises +[TODO: Write here] + +# Returns +[TODO: Write here] + +# Examples +[TODO: Write here] + +# Notes +[TODO: Write here. Proof sketch of bounds on Lipschitz and alpha constants, plus further +relevant references?] +""" +function max_state_transfer(g::AbstractGraph, args...) + if !is_simple(g) + throw(ArgumentError("Graph must be undirected with no self-loops")) + end + + return max_state_transfer(adjacency_matrix(g), args...) +end + +function max_state_transfer( + A::AbstractMatrix{<:Real}, + src::Tl, + dst::Tl, + t_lower::Real, + t_upper::Real, + epsilon::Real, + method::Symbol=:lipschitz_bb, +) where {Tl<:Union{Integer,Tuple{Integer,Integer}}} + if !is_zero_diagonal_symmetric(A) + throw(ArgumentError("Matrix must be symmetric with zero diagonal")) + end + + _validate_state_transfer_params(t_lower, t_upper, epsilon, method) + + input = _preprocess_state_transfer_input(A, src, dst, t_lower, t_upper, epsilon, method) + res = _optimize_state_transfer_impl(input) + + return StateTransferMaximizationResult( + input.A, + input.src, + input.dst, + input.t_lower, + input.t_upper, + input.epsilon, + res.minimizer[1], + 1 - res.minimum, + ) +end + +""" + check_state_transfer(g::AbstractGraph, args...) -> StateTransferRecognitionResult + check_state_transfer(A::AbstractMatrix{<:Real}, args...) -> StateTransferRecognitionResult + +[TODO: Write here] + +# Arguments +[TODO: Write here] + +# Optional Arguments +[TODO: Write here] + +# Raises +[TODO: Write here] + +# Returns +[TODO: Write here] + +# Examples +[TODO: Write here] + +# Notes +[TODO: Write here. Proof sketch of bounds on Lipschitz and alpha constants, plus further +relevant references?] +""" +function check_state_transfer(g::AbstractGraph, args...) + if !is_simple(g) + throw(ArgumentError("Graph must be undirected with no self-loops")) + end + + return check_state_transfer(adjacency_matrix(g), args...) +end + +function check_state_transfer( + A::AbstractMatrix{<:Real}, + src::Tl, + dst::Tl, + t_lower::Real, + t_upper::Real, + target_fidelity::Real, + epsilon::Real, + method::Symbol=:lipschitz_bb, +) where {Tl<:Union{Integer,Tuple{Integer,Integer}}} + if !is_zero_diagonal_symmetric(A) + throw(ArgumentError("Matrix must be symmetric with zero diagonal")) + end + + _validate_state_transfer_params(t_lower, t_upper, epsilon, method, target_fidelity) + + input = _preprocess_state_transfer_input( + A, src, dst, t_lower, t_upper, epsilon, method, target_fidelity + ) + res = _optimize_state_transfer_impl(input) + + achieved = res.target_reached + + if achieved + time_achieved = res.minimizer[1] + fidelity_achieved = 1 - res.minimum + else + time_achieved = nothing + fidelity_achieved = nothing + end + + return StateTransferRecognitionResult( + input.A, + input.src, + input.dst, + input.t_lower, + input.t_upper, + input.epsilon, + target_fidelity, + achieved, + time_achieved, + fidelity_achieved, + ) +end + +struct _StateTransferProblemInput{Tl<:Union{Int,Tuple{Int,Int}}} + A::Matrix{Float64} + src::Tl + dst::Tl + t_lower::Float64 + t_upper::Float64 + epsilon::Float64 + method::Symbol + target_fidelity::Union{Nothing,Float64} +end + +function _validate_state_transfer_params( + t_lower::Real, + t_upper::Real, + epsilon::Real, + method::Symbol, + target_fidelity::Union{Nothing,Float64}=nothing, +) + if t_lower > t_upper + throw( + ArgumentError( + "Lower time bound must be less than or equal to upper bound, got [$t_lower, $t_upper]", + ), + ) + end + + if epsilon <= 0 + throw(ArgumentError("Epsilon tolerance must be positive, got $epsilon")) + end + + if !(method in (:lipschitz_bb, :alpha_bb)) + throw( + ArgumentError( + "Unsupported epsilon-convergent optimization method; must be `:lipschitz_bb` or `:alpha_bb`, got $method", + ), + ) + end + + if !isnothing(target_fidelity) + if !(0 < target_fidelity <= 1) + throw( + ArgumentError( + "Target fidelity must be in the interval (0, 1], got $target_fidelity" + ), + ) + end + end +end + +function _preprocess_state_transfer_input( + A::AbstractMatrix{<:Real}, + src::Tl, + dst::Tl, + t_lower::Real, + t_upper::Real, + epsilon::Real, + method::Symbol, + target_fidelity::Union{Nothing,Float64}=nothing, +) where {Tl<:Union{Integer,Tuple{Integer,Integer}}} + A = Matrix{Float64}(A) + src = _preprocess_label(src) + dst = _preprocess_label(dst) + t_lower = Float64(t_lower) + t_upper = Float64(t_upper) + epsilon = Float64(epsilon) + + return _StateTransferProblemInput( + A, src, dst, t_lower, t_upper, epsilon, method, target_fidelity + ) +end + +_preprocess_label(label::Integer) = Int(label) +_preprocess_label(label::Tuple{Integer,Integer}) = Tuple{Int,Int}(label) + +_label_to_state(label::Int, buf::UnitRange{Int}) = buf .== label + +function _label_to_state(label::Tuple{Int,Int}, buf::UnitRange{Int}) + if allequal(label) + throw(ArgumentError("Vertex pairs must consist of distinct vertices")) + end + + return ((buf .== label[1]) - (buf .== label[2])) / sqrt(2) +end + +function _optimize_state_transfer_impl(input::_StateTransferProblemInput) + A = input.A + t_lower = input.t_lower + t_upper = input.t_upper + method = input.method + epsilon = input.epsilon + + buf = 1:size(A, 1) + state_src = _label_to_state(input.src, buf) + state_dst = _label_to_state(input.dst, buf) + + if isnothing(input.target_fidelity) + target_infidelity = nothing + else + target_infidelity = 1 - input.target_fidelity + end + + if method == :lipschitz_bb + lipschitz_constant = maximum(norm.(eachcol(A))) + solver = LipschitzBranchAndBound( + epsilon, lipschitz_constant; target=target_infidelity + ) + elseif method == :alpha_bb + alpha = maximum(norm.(eachcol(A^2))) / 2 + solver = AlphaBranchAndBound(epsilon, alpha; target=target_infidelity) + else + throw( + ArgumentError( + "Unsupported epsilon-convergent optimization method; must be `:lipschitz_bb` or `:alpha_bb`", + ), + ) + end + + eigenvals, eigenvecs = eigen(A) + left = state_src' * eigenvecs + right = eigenvecs' * state_dst + + function infidelity(t::Vector{Float64}) + return 1 - abs2(left * Diagonal(exp.(im * t[1] * eigenvals)) * right) + end + + return epsilon_minimize(infidelity, [t_lower], [t_upper], solver) +end diff --git a/src/types.jl b/src/types.jl index 018e7fc..fce311c 100644 --- a/src/types.jl +++ b/src/types.jl @@ -38,7 +38,9 @@ called on a newly created subtype for which no method has been defined. `NotImplementedError` <: `Exception` # Notes -This struct was taken from one of the authors' other packages, MatrixBandwidth.jl. +This struct was taken from one of the authors' other packages, +[MatrixBandwidth.jl](https://github.com/Luis-Varona/MatrixBandwidth.jl). To avoid adding an +unnecessary dependency, it has been copied here verbatim. """ struct NotImplementedError{T<:Union{Nothing,Symbol}} <: Exception f::Function diff --git a/src/uniform_mixing.jl b/src/uniform_mixing.jl new file mode 100644 index 0000000..fa351df --- /dev/null +++ b/src/uniform_mixing.jl @@ -0,0 +1,7 @@ +# Copyright 2025 Luis M. B. Varona and Nathaniel Johnston +# +# Licensed under the MIT license . This file may not be copied, modified, or +# distributed except according to those terms. + +# TODO: Write here diff --git a/src/utils.jl b/src/utils.jl index fa351df..4ea82a3 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -4,4 +4,53 @@ # http://opensource.org/licenses/MIT>. This file may not be copied, modified, or # distributed except according to those terms. -# TODO: Write here +""" + is_zero_diagonal_symmetric(A) -> Bool + +Check whether a matrix `A` is symmetric with a zero diagonal. + +Supposing that `A` is the adjacency matrix of a graph representing a quantum spin network, a +nonzero diagonal would indicate couplings between qubits and themselves, which is physically +nonsensical. On the other hand, symmetry (or rather Hermicity in the general case, but we +only consider here real-valued adjacency matrices) is required for the walk Hamiltonian +``eᶦᵗᴬ`` to be unitary. + +# Arguments +- `A::AbstractMatrix{<:Real}`: The matrix to check. + +# Returns +- `::Bool`: `true` if `A` is symmetric with a zero diagonal, and `false` otherwise. + +# Examples +[TODO: Write here] +""" +function is_zero_diagonal_symmetric(A::AbstractMatrix{<:Real}) + (m, n) = size(A) + + return m == n && # Square + all(iszero, Iterators.map(i -> A[i, i], 1:n)) && # Zero diagonal + all(A[i, j] == A[j, i] for i in 1:(n - 1) for j in (i + 1):n) # Symmetric +end + +""" + is_simple(g) -> Bool + +Check whether a graph `g` is simple (i.e., undirected with no self-loops). + +Supposing that `g` represents a quantum spin network, self-loops would indicate couplings +between qubits and themselves, which is physically nonsensical. On the other hand, +undirectedness is required for the walk Hamiltonian ``eᶦᵗᴬ`` to be unitary, where `A` is the +adjacency matrix of `g`. + +# Arguments +- `g::AbstractGraph`: The graph to check. + +# Returns +- `::Bool`: `true` if `g` is undirected with no self-loops, and `false` otherwise. + +# Examples +[TODO: Write here] +""" +function is_simple(g::AbstractGraph) + return !isdirected(g) && !has_self_loops(g) +end