diff --git a/deps/src/CMakeLists.txt b/deps/src/CMakeLists.txt index da75f48..20b51a5 100644 --- a/deps/src/CMakeLists.txt +++ b/deps/src/CMakeLists.txt @@ -55,6 +55,7 @@ add_library(libsemigroups_julia SHARED transf.cpp word-graph.cpp word-range.cpp + paths.cpp presentation.cpp presentation-examples.cpp knuth-bendix.cpp diff --git a/deps/src/libsemigroups_julia.cpp b/deps/src/libsemigroups_julia.cpp index 2a1bacd..7eab2d7 100644 --- a/deps/src/libsemigroups_julia.cpp +++ b/deps/src/libsemigroups_julia.cpp @@ -28,9 +28,8 @@ namespace libsemigroups_julia { JLCXX_MODULE define_julia_module(jl::Module& mod) { - mod.method("libsemigroups_version", []() -> std::string { - return LIBSEMIGROUPS_VERSION; - }); + mod.method("libsemigroups_version", + []() -> std::string { return LIBSEMIGROUPS_VERSION; }); // Define constants first (UNDEFINED, POSITIVE_INFINITY, etc.) define_constants(mod); @@ -49,6 +48,7 @@ namespace libsemigroups_julia { define_order(mod); define_word_range(mod); define_word_graph(mod); + define_paths(mod); define_froidure_pin_base(mod); define_froidure_pin(mod); define_presentation(mod); diff --git a/deps/src/libsemigroups_julia.hpp b/deps/src/libsemigroups_julia.hpp index be064db..026e99c 100644 --- a/deps/src/libsemigroups_julia.hpp +++ b/deps/src/libsemigroups_julia.hpp @@ -63,6 +63,7 @@ namespace libsemigroups_julia { void define_order(jl::Module& mod); void define_word_range(jl::Module& mod); void define_word_graph(jl::Module& mod); + void define_paths(jl::Module& mod); void define_froidure_pin_base(jl::Module& mod); void define_froidure_pin(jl::Module& mod); void define_presentation(jl::Module& mod); diff --git a/deps/src/paths.cpp b/deps/src/paths.cpp new file mode 100644 index 0000000..0266931 --- /dev/null +++ b/deps/src/paths.cpp @@ -0,0 +1,142 @@ +// +// Semigroups.jl +// Copyright (C) 2026, James W. Swent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +#include "libsemigroups_julia.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace jlcxx { + template <> + struct IsMirroredType> : std::false_type {}; +} // namespace jlcxx + +namespace libsemigroups_julia { + + void define_paths(jl::Module& m) { + using Paths_ = libsemigroups::Paths; + using WordGraph_ = libsemigroups::WordGraph; + using libsemigroups::Order; + + // Registered as "PathsCxx" so the public Julia name `Paths` is free for + // the high-level wrapper struct in `src/paths.jl`. + auto type = m.add_type("PathsCxx"); + + // Constructor: Paths(WordGraph const&). The C++ object holds only a raw + // pointer to the WordGraph; lifetime is the Julia wrapper's responsibility + // (the wrapper struct holds a `g::WordGraph` field that pins it for GC). + type.constructor(); + + // Reinitialize: rebind to a new WordGraph and reset all settings. The + // Julia wrapper's `init!` overwrites its `g` field after this call so the + // GC pin matches the new C++ pointer. + type.method("init!", + [](Paths_& self, WordGraph_ const& wg) { self.init(wg); }); + + // --- Intentionally not bound --- + // * `is_finite` / `is_idempotent`: compile-time `static constexpr bool` + // traits for rx::ranges integration; not user-callable methods. + // * `size_hint()`: numerically identical to `count()` but lives only for + // rx::ranges compatibility -- redundant on the Julia side. + // * `cbegin_pilo` / `cbegin_pislo` / `cbegin_pstilo` / `cbegin_pstislo` + // (and matching `cend_*`): low-level iterator factories subsumed by + // the high-level `Paths` range API (`get` / `next!` / `at_end`). + + // --- Validation --- + + type.method("throw_if_source_undefined", + [](Paths_ const& self) { self.throw_if_source_undefined(); }); + + // --- Range / iteration interface --- + + // `get` returns `word_type const&` in C++ (reference to internal iterator + // storage that is invalidated by `next`). Copy at the boundary. + type.method("get", [](Paths_ const& self) -> libsemigroups::word_type { + return self.get(); + }); + + type.method("next!", [](Paths_& self) { self.next(); }); + + type.method("at_end", + [](Paths_ const& self) -> bool { return self.at_end(); }); + + type.method("count", + [](Paths_ const& self) -> uint64_t { return self.count(); }); + + // --- Settings: getter / setter pairs --- + // Same-name different-arity overloads are unreliable in CxxWrap; split + // setters to use the `!` suffix per Julia convention. + + // source + type.method("source", + [](Paths_ const& self) -> uint32_t { return self.source(); }); + type.method("source!", [](Paths_& self, uint32_t n) { self.source(n); }); + + // target — single setter handles both regular and UNDEFINED cases. The + // underlying libsemigroups `target(n)` short-circuits on `n == UNDEFINED` + // (paths.hpp:968-973), accepting it as "any reachable target". The Julia + // wrapper's `target!(p, ::UndefinedType)` arm dispatches into this same + // call after converting UNDEFINED to typemax(uint32_t). + type.method("target", + [](Paths_ const& self) -> uint32_t { return self.target(); }); + type.method("target!", [](Paths_& self, uint32_t n) { self.target(n); }); + + // min + type.method("min", + [](Paths_ const& self) -> std::size_t { return self.min(); }); + type.method("min!", [](Paths_& self, std::size_t val) { self.min(val); }); + + // max + type.method("max", + [](Paths_ const& self) -> std::size_t { return self.max(); }); + type.method("max!", [](Paths_& self, std::size_t val) { self.max(val); }); + + // order + type.method("order", + [](Paths_ const& self) -> Order { return self.order(); }); + type.method("order!", [](Paths_& self, Order val) { self.order(val); }); + + // --- Read-only queries --- + + type.method("current_target", [](Paths_ const& self) -> uint32_t { + return self.current_target(); + }); + + // word_graph returns `WordGraph const&`; the Julia wrapper holds the + // original WordGraph in its `g` field, so this is rarely needed from + // Julia. Bound for parity / completeness. Returned as a reference (the + // caller gets a `CxxBaseRef{WordGraph}`). + type.method("word_graph", [](Paths_ const& self) -> WordGraph_ const& { + return self.word_graph(); + }); + + // --- Display --- + // `to_human_readable_repr` is a free function template in libsemigroups, + // bound at module level (not as `type.method`). + m.method("to_human_readable_repr", [](Paths_ const& p) -> std::string { + return libsemigroups::to_human_readable_repr(p); + }); + } + +} // namespace libsemigroups_julia diff --git a/docs/make.jl b/docs/make.jl index d7cf416..04fb924 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -58,6 +58,7 @@ makedocs(; "Examples" => "data-structures/presentations/examples.md", ], "Word Graphs" => "data-structures/word-graph.md", + "Paths" => "data-structures/paths.md", "Words" => "data-structures/word-range.md", ], "Main Algorithms" => [ diff --git a/docs/src/data-structures/paths.md b/docs/src/data-structures/paths.md new file mode 100644 index 0000000..41ba7eb --- /dev/null +++ b/docs/src/data-structures/paths.md @@ -0,0 +1,90 @@ +# The Paths type + +This page documents the type [`Paths`](@ref Semigroups.Paths), a stateful +range over paths in a [`WordGraph`](@ref Semigroups.WordGraph). A `Paths` +object pins its source word graph for garbage collection and yields paths +as `Vector{Int}` of 1-based edge labels via the standard Julia iteration +protocol or the manual `get` / [`next!`](@ref Semigroups.next!) / +[`at_end`](@ref Semigroups.at_end) interface. + +!!! warning "v1 limitation" + Paths is bound for `WordGraph` only; other Node types follow + when consumers need them. + +```@docs +Semigroups.Paths +``` + +## Usage + +```jldoctest +julia> using Semigroups + +julia> g = WordGraph(3, 2); + +julia> target!(g, 1, 1, 2); target!(g, 1, 2, 3); target!(g, 2, 1, 3); + +julia> p = paths(g; source = 1, max = 3); + +julia> collect(p) +4-element Vector{Vector{Int64}}: + [] + [1] + [2] + [1, 1] +``` + +## Contents + +| Function | Description | +| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| [`Paths`](@ref Semigroups.Paths(::WordGraph)) | Construct a [`Paths`](@ref Semigroups.Paths) over a word graph. | +| [`paths`](@ref Semigroups.paths(::WordGraph)) | Keyword-argument factory for a [`Paths`](@ref Semigroups.Paths). | +| [`init!`](@ref Semigroups.init!(::Paths, ::WordGraph)) | Rebind to a new [`WordGraph`](@ref Semigroups.WordGraph) and reset settings. | +| [`source`](@ref Semigroups.source(::Paths)) | Get the current source node. | +| [`source!`](@ref Semigroups.source!(::Paths, ::Integer)) | Set the source node. | +| [`target`](@ref Semigroups.target(::Paths)) | Get the current target node (or [`UNDEFINED`](@ref Semigroups.UNDEFINED)). | +| [`target!`](@ref Semigroups.target!(::Paths, ::Integer)) | Set the target node, or clear it with [`UNDEFINED`](@ref Semigroups.UNDEFINED). | +| [`min!`](@ref Semigroups.min!(::Paths, ::Integer)) | Set the minimum path length. | +| [`max!`](@ref Semigroups.max!(::Paths, ::Integer)) | Set the maximum path length, or remove the bound with [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY). | +| [`order`](@ref Semigroups.order(::Paths)) | Get the current word [`Order`](@ref Semigroups.Order). | +| [`order!`](@ref Semigroups.order!(::Paths, ::Order)) | Set the word [`Order`](@ref Semigroups.Order). | +| [`current_target`](@ref Semigroups.current_target(::Paths)) | Target node of the path currently labelled by [`Base.get`](@ref Base.get(::Paths)). | +| [`word_graph`](@ref Semigroups.word_graph(::Paths)) | Return the underlying [`WordGraph`](@ref Semigroups.WordGraph). | +| [`next!`](@ref Semigroups.next!(::Paths)) | Advance the range to the next path. | +| [`at_end`](@ref Semigroups.at_end(::Paths)) | Test whether the range is exhausted. | +| [`throw_if_source_undefined`](@ref Semigroups.throw_if_source_undefined(::Paths)) | Throw if [`source`](@ref Semigroups.source(::Paths)) is undefined. | +| [`Base.min`](@ref Base.min(::Paths)) | Get the current minimum path length (qualified-only). | +| [`Base.max`](@ref Base.max(::Paths)) | Get the current maximum path length (qualified-only). | +| [`Base.get`](@ref Base.get(::Paths)) | Get the current path as a `Vector{Int}` (qualified-only). | +| [`Base.count`](@ref Base.count(::Paths)) | Get the number of paths in the range (qualified-only). | + +[`Paths`](@ref Semigroups.Paths) also implements the standard Julia iteration +protocol — `for w in p`, `collect(p)`, etc. — and a `Base.show` method for +human-readable display. Iteration is *destructive*: it advances `p` itself, +leaving `at_end(p)` true on completion. + +## Full API + +```@docs +Semigroups.Paths(::WordGraph) +Semigroups.paths(::WordGraph) +Semigroups.init!(::Paths, ::WordGraph) +Semigroups.source(::Paths) +Semigroups.source!(::Paths, ::Integer) +Semigroups.target(::Paths) +Semigroups.target!(::Paths, ::Integer) +Semigroups.min!(::Paths, ::Integer) +Semigroups.max!(::Paths, ::Integer) +Semigroups.order(::Paths) +Semigroups.order!(::Paths, ::Order) +Semigroups.current_target(::Paths) +Semigroups.word_graph(::Paths) +Semigroups.next!(::Paths) +Semigroups.at_end(::Paths) +Semigroups.throw_if_source_undefined(::Paths) +Base.min(::Paths) +Base.max(::Paths) +Base.get(::Paths) +Base.count(::Paths) +``` diff --git a/src/Semigroups.jl b/src/Semigroups.jl index 5dc8fa8..7a70265 100644 --- a/src/Semigroups.jl +++ b/src/Semigroups.jl @@ -56,11 +56,8 @@ is_debug() = _debug_mode[] include("setup.jl") # Get the library path - this will build if necessary during precompilation -const _libsemigroups_julia = Ref( - Setup.locate_library(; - force_local_build = FORCE_LOCAL_LIBSEMIGROUPS_JULIA_BUILD, - ), -) +const _libsemigroups_julia = + Ref(Setup.locate_library(; force_local_build = FORCE_LOCAL_LIBSEMIGROUPS_JULIA_BUILD)) libsemigroups_julia() = _libsemigroups_julia[] # Low-level CxxWrap bindings @@ -80,6 +77,7 @@ include("runner.jl") include("order.jl") include("word-range.jl") include("word-graph.jl") +include("paths.jl") include("presentation.jl") include("presentation-examples.jl") include("cong-common.jl") @@ -92,7 +90,7 @@ include("transf.jl") # Algorithm types (must come after element types) include("froidure-pin.jl") -function _version_string(v::Union{Nothing, VersionNumber}) +function _version_string(v::Union{Nothing,VersionNumber}) return isnothing(v) ? "unknown" : string(v) end @@ -170,11 +168,19 @@ function _libsemigroups_julia_version() end end -function _version_line(name::AbstractString, version::AbstractString, source::AbstractString) +function _version_line( + name::AbstractString, + version::AbstractString, + source::AbstractString, +) return rpad(name, 21) * " v" * version * " (" * source * ")" end -function _compact_version_line(name::AbstractString, version::AbstractString, source::AbstractString) +function _compact_version_line( + name::AbstractString, + version::AbstractString, + source::AbstractString, +) return name * " v" * version * " (" * source * ")" end @@ -193,12 +199,21 @@ function _print_banner() println(raw" |____/ \___|_| |_| |_|_|\__, |_| \___/ \__,_| .__/|___/") println(raw" |___/ |_|") println(" Semigroups.jl v$semigroups_version") - println(" " * _version_line("libsemigroups", libsemigroups_version, libsemigroups_source)) - println(" " * _version_line("libsemigroups_julia", bindings_version, bindings_source)) + println( + " " * + _version_line("libsemigroups", libsemigroups_version, libsemigroups_source), + ) + println( + " " * _version_line("libsemigroups_julia", bindings_version, bindings_source), + ) else println( "Semigroups.jl v$semigroups_version | " * - _compact_version_line("libsemigroups", libsemigroups_version, libsemigroups_source) * + _compact_version_line( + "libsemigroups", + libsemigroups_version, + libsemigroups_source, + ) * " | " * _compact_version_line("libsemigroups_julia", bindings_version, bindings_source), ) @@ -209,16 +224,29 @@ function versioninfo(io::IO = stdout) semigroups_version = _version_string(VERSION_NUMBER) println(io, "Semigroups.jl version $semigroups_version") println(io, " loaded:") - println(io, " " * _version_line("libsemigroups", _loaded_libsemigroups_version(), _loaded_libsemigroups_source())) - println(io, " " * _version_line("libsemigroups_julia", _libsemigroups_julia_version(), _libsemigroups_julia_source())) + println( + io, + " " * _version_line( + "libsemigroups", + _loaded_libsemigroups_version(), + _loaded_libsemigroups_source(), + ), + ) + println( + io, + " " * _version_line( + "libsemigroups_julia", + _libsemigroups_julia_version(), + _libsemigroups_julia_source(), + ), + ) end # Module initialization function __init__() # Re-check at runtime because Julia precompilation does not track deps/src. - _libsemigroups_julia[] = Setup.locate_library(; - force_local_build = FORCE_LOCAL_LIBSEMIGROUPS_JULIA_BUILD, - ) + _libsemigroups_julia[] = + Setup.locate_library(; force_local_build = FORCE_LOCAL_LIBSEMIGROUPS_JULIA_BUILD) # Initialize the CxxWrap module LibSemigroups.__init__() @@ -263,6 +291,10 @@ export next!, at_end, valid, init!, size_hint, upper_bound # WordGraph export WordGraph, number_of_nodes, out_degree, target, target!, add_nodes! +# Paths +export Paths, paths, source, source!, min!, max!, order! +export current_target, word_graph, throw_if_source_undefined + # Presentation export Presentation, alphabet, set_alphabet!, alphabet_from_rules! export letter, index_of, in_alphabet diff --git a/src/paths.jl b/src/paths.jl new file mode 100644 index 0000000..009c682 --- /dev/null +++ b/src/paths.jl @@ -0,0 +1,545 @@ +# Copyright (c) 2026, James W. Swent +# +# Distributed under the terms of the GPL license version 3. +# +# The full license is in the file LICENSE, distributed with this software. + +""" +paths.jl - Paths wrapper + +Stateful range over paths in a [`WordGraph`](@ref). The Julia wrapper struct +holds the underlying CxxWrap handle plus a reference to the source `WordGraph`, +which serves as a GC pin: libsemigroups' `Paths` stores only a raw +pointer to the `WordGraph`, so the Julia wrapper is responsible for keeping the +graph alive for as long as the [`Paths`](@ref) exists. + +Index conventions (1-based at the boundary, 0-based in C++) and sentinel +mapping ([`UNDEFINED`](@ref Semigroups.UNDEFINED) <-> `typemax(UInt32)`, +[`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) <-> `typemax(UInt) - 1`) +are handled by the private helpers at the top of this file. +""" + +# ============================================================================ +# Index / sentinel conversion +# ============================================================================ + +# Nodes (uint32_t). UNDEFINED = typemax(UInt32). +@inline _node_to_cpp(x::Integer) = UInt32(x - 1) +@inline _node_to_cpp(::UndefinedType) = typemax(UInt32) +@inline _node_from_cpp(x::Integer) = x == typemax(UInt32) ? UNDEFINED : Int(x) + 1 + +# Path lengths (size_t). POSITIVE_INFINITY = typemax(UInt) - 1. +@inline _length_to_cpp(x::Integer) = UInt(x) +@inline _length_to_cpp(::PositiveInfinityType) = convert(UInt, POSITIVE_INFINITY) +@inline function _length_from_cpp(x::Integer) + x == convert(UInt, POSITIVE_INFINITY) ? POSITIVE_INFINITY : Int(x) +end + +# `_word_from_cpp` (1-based letter conversion) is reused from `src/order.jl`, +# included earlier in `src/Semigroups.jl`. + +# ============================================================================ +# Wrapper struct +# ============================================================================ + +""" + Paths + +Type for a stateful range over paths in a [`WordGraph`](@ref). + +A `Paths` object wraps libsemigroups' `Paths` together with a +reference to the source [`WordGraph`](@ref) that pins it for garbage +collection. The C++ object stores only a raw pointer to the word graph, so +keeping the Julia wrapper alive is what keeps the underlying graph alive. + +Configure with [`source!`](@ref), [`target!`](@ref), [`min!`](@ref), +[`max!`](@ref), [`order!`](@ref), then iterate with the standard Julia +iteration protocol (`for w in p`, `collect(p)`) or via the manual interface +([`Base.get`](@ref Base.get(::Paths)), [`next!`](@ref), [`at_end`](@ref), +[`Base.count`](@ref Base.count(::Paths))). + +# Example +```jldoctest +julia> using Semigroups + +julia> g = WordGraph(3, 2); + +julia> target!(g, 1, 1, 2); target!(g, 1, 2, 3); target!(g, 2, 1, 3); + +julia> p = paths(g; source = 1, max = 3, order = ORDER_SHORTLEX); + +julia> collect(p) +4-element Vector{Vector{Int64}}: + [] + [1] + [2] + [1, 1] +``` +""" +mutable struct Paths + g::WordGraph + cxx::LibSemigroups.PathsCxx +end + +""" + Paths(g::WordGraph) -> Paths + +Construct a new [`Paths`](@ref) over `g`. + +The new range has source and target [`UNDEFINED`](@ref Semigroups.UNDEFINED), +minimum length `0`, maximum length [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY), +and order [`ORDER_SHORTLEX`](@ref). At least the source must be set (via +[`source!`](@ref)) before the range can be iterated. + +# Arguments +- `g::WordGraph`: the word graph. + +See also [`paths`](@ref). +""" +Paths(g::WordGraph) = Paths(g, LibSemigroups.PathsCxx(g)) + +""" + init!(p::Paths, g::WordGraph) -> Paths + +Reinitialize `p` to range over `g`, resetting all settings to their defaults. + +After this call, `p` is in the same state as a freshly constructed +`Paths(g)`: source and target are [`UNDEFINED`](@ref Semigroups.UNDEFINED), +minimum length `0`, maximum length [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY), +and order [`ORDER_SHORTLEX`](@ref). The wrapper's GC pin is updated so the +new word graph is kept alive. + +# Arguments +- `g::WordGraph`: the new word graph. + +See also [`Paths`](@ref). +""" +function init!(p::Paths, g::WordGraph) + GC.@preserve p g begin + LibSemigroups.init!(p.cxx, g) + end + p.g = g + return p +end + +# ============================================================================ +# Read-only getters +# ============================================================================ + +""" + source(p::Paths) -> Union{Int, UndefinedType} + +Return the current source node of `p`. + +Returns [`UNDEFINED`](@ref Semigroups.UNDEFINED) if no source has been set. + +See also [`source!`](@ref). +""" +source(p::Paths) = _node_from_cpp(LibSemigroups.source(p.cxx)) + +""" + target(p::Paths) -> Union{Int, UndefinedType} + +Return the current target node of `p`. + +Returns [`UNDEFINED`](@ref Semigroups.UNDEFINED) if no specific target has been +set, in which case the range yields paths to *any* reachable node. + +See also [`target!`](@ref). +""" +target(p::Paths) = _node_from_cpp(LibSemigroups.target(p.cxx)) + +""" + Base.min(p::Paths) -> Int + +Return the current minimum path length of `p`. + +This getter is not exported; call as `Semigroups.min(p)` or `Base.min(p)`. + +See also [`min!`](@ref). +""" +Base.min(p::Paths) = _length_from_cpp(LibSemigroups.min(p.cxx)) + +""" + Base.max(p::Paths) -> Union{Int, PositiveInfinityType} + +Return the current maximum path length of `p`. + +Returns [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) if no upper +bound is set. This getter is not exported; call as `Semigroups.max(p)` or +`Base.max(p)`. + +See also [`max!`](@ref). +""" +Base.max(p::Paths) = _length_from_cpp(LibSemigroups.max(p.cxx)) + +""" + order(p::Paths) -> Order + +Return the current word [`Order`](@ref) of `p`. + +The value is either [`ORDER_SHORTLEX`](@ref) or [`ORDER_LEX`](@ref). + +See also [`order!`](@ref). +""" +AbstractAlgebra.order(p::Paths) = LibSemigroups.order(p.cxx) + +""" + current_target(p::Paths) -> Union{Int, UndefinedType} + +Return the current target node of the path labelled by +[`Base.get`](@ref Base.get(::Paths)). + +If there is no such path (for example because [`source`](@ref) is undefined), +returns [`UNDEFINED`](@ref Semigroups.UNDEFINED). +""" +current_target(p::Paths) = _node_from_cpp(LibSemigroups.current_target(p.cxx)) + +""" + word_graph(p::Paths) -> WordGraph + +Return the [`WordGraph`](@ref) over which `p` ranges. + +This is the Julia wrapper's pinned graph reference; no C++ trip is required. +""" +word_graph(p::Paths) = p.g + +# ============================================================================ +# Setters +# ============================================================================ + +""" + source!(p::Paths, n::Integer) -> Paths + +Set the source node of `p` to `n`. + +# Arguments +- `n::Integer`: the source node (1-based). + +# Throws +- `LibsemigroupsError`: if `n` is not a node of [`word_graph`](@ref)`(p)`. +- `InexactError`: if `n` is zero or negative (the 1-based guard fires before + the call reaches C++). + +See also [`source`](@ref). +""" +function source!(p::Paths, n::Integer) + s = _node_to_cpp(n) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.source!(p.cxx, s) + end + return p +end + +""" + target!(p::Paths, n::Integer) -> Paths + target!(p::Paths, ::UndefinedType) -> Paths + +Set the target node of `p` to `n`, or clear it (yielding "any reachable +target") with [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +# Arguments +- `n`: the target node (1-based), or [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +# Throws +- `LibsemigroupsError`: if `n` is not a node of [`word_graph`](@ref)`(p)`. +- `InexactError`: if `n` is zero or negative. + +See also [`target`](@ref). +""" +function target!(p::Paths, n::Integer) + t = _node_to_cpp(n) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.target!(p.cxx, t) + end + return p +end + +function target!(p::Paths, ::UndefinedType) + t = _node_to_cpp(UNDEFINED) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.target!(p.cxx, t) + end + return p +end + +""" + min!(p::Paths, n::Integer) -> Paths + +Set the minimum path length of `p` to `n`. + +# Arguments +- `n::Integer`: the new minimum length (non-negative). + +# Throws +- `InexactError`: if `n` is negative (the unsigned-cast guard fires before + the call reaches C++). + +See also [`Base.min`](@ref). +""" +function min!(p::Paths, n::Integer) + v = _length_to_cpp(n) + # No `@wrap_libsemigroups_call`: the underlying C++ setter is `noexcept` + # (paths.hpp:1014), so wrapping would hide nothing. The asymmetry with + # `source!` / `target!` / `order!` mirrors the libsemigroups noexcept + # annotations. + GC.@preserve p begin + LibSemigroups.min!(p.cxx, v) + end + return p +end + +""" + max!(p::Paths, n::Integer) -> Paths + max!(p::Paths, ::PositiveInfinityType) -> Paths + +Set the maximum path length of `p` to `n`, or remove the upper bound by +passing [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY). + +# Arguments +- `n`: the new maximum length, or + [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY). + +# Throws +- `InexactError`: if `n` is negative (the unsigned-cast guard fires before + the call reaches C++). + +See also [`Base.max`](@ref). +""" +function max!(p::Paths, n::Integer) + v = _length_to_cpp(n) + # See `min!` re: noexcept setter; no `@wrap_libsemigroups_call` needed. + GC.@preserve p begin + LibSemigroups.max!(p.cxx, v) + end + return p +end + +function max!(p::Paths, ::PositiveInfinityType) + v = _length_to_cpp(POSITIVE_INFINITY) + GC.@preserve p begin + LibSemigroups.max!(p.cxx, v) + end + return p +end + +""" + order!(p::Paths, o::Order) -> Paths + +Set the word ordering of `p` to `o`. + +# Arguments +- `o::Order`: the ordering; must be [`ORDER_SHORTLEX`](@ref) or + [`ORDER_LEX`](@ref). + +# Throws +- `LibsemigroupsError`: if `o` is neither [`ORDER_SHORTLEX`](@ref) nor + [`ORDER_LEX`](@ref). + +See also [`order`](@ref). +""" +function order!(p::Paths, o::Order) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.order!(p.cxx, o) + end + return p +end + +# ============================================================================ +# Validation +# ============================================================================ + +""" + throw_if_source_undefined(p::Paths) -> nothing + +Throw if the source node of `p` has not been set. + +This function is the Julia mirror of libsemigroups' +`Paths::throw_if_source_undefined()`. It is called automatically by +[`Base.get`](@ref Base.get(::Paths)), [`next!`](@ref), [`at_end`](@ref), and +[`Base.count`](@ref Base.count(::Paths)) since the underlying C++ implementation does *not* +internally guard those operations. + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). +""" +function throw_if_source_undefined(p::Paths) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.throw_if_source_undefined(p.cxx) + end + return nothing +end + +# ============================================================================ +# Range / iteration interface +# ============================================================================ + +""" + Base.get(p::Paths) -> Vector{Int} + +Get the current path in the range as a vector of 1-based edge labels. + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +See also [`next!`](@ref), [`at_end`](@ref). +""" +function Base.get(p::Paths) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.throw_if_source_undefined(p.cxx) + w = @wrap_libsemigroups_call LibSemigroups.get(p.cxx) + end + return _word_from_cpp(w) +end + +""" + next!(p::Paths) -> Paths + +Advance `p` to the next path. If [`at_end`](@ref)`(p)` is `true`, this +function does nothing, so repeated calls after the range is exhausted are +safe. + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +See also [`Base.get`](@ref Base.get(::Paths)), [`at_end`](@ref). +""" +function next!(p::Paths) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.throw_if_source_undefined(p.cxx) + @wrap_libsemigroups_call LibSemigroups.next!(p.cxx) + end + return p +end + +""" + at_end(p::Paths) -> Bool + +Return `true` if `p` has been exhausted, and `false` otherwise. + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +See also [`next!`](@ref). +""" +function at_end(p::Paths) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.throw_if_source_undefined(p.cxx) + result = @wrap_libsemigroups_call LibSemigroups.at_end(p.cxx) + end + return result +end + +""" + Base.count(p::Paths) -> Union{Int, PositiveInfinityType} + +Return the number of paths in the range. + +If the range is infinite (cyclic graph with no upper bound on length), returns +[`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY). Always throws if +`source(p) === UNDEFINED`; the source must be set before counting. + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). +""" +function Base.count(p::Paths) + GC.@preserve p begin + @wrap_libsemigroups_call LibSemigroups.throw_if_source_undefined(p.cxx) + c = @wrap_libsemigroups_call LibSemigroups.count(p.cxx) + end + return _length_from_cpp(c) +end + +# ============================================================================ +# Julia iteration protocol +# ============================================================================ +# Destructive iteration matches the WordRange precedent (`src/word-range.jl`). +# A `for w in p; ...; end` loop or `collect(p)` advances `p` itself; after +# completion, `at_end(p)` is true. + +Base.IteratorSize(::Type{Paths}) = Base.SizeUnknown() +Base.eltype(::Type{Paths}) = Vector{Int} + +function Base.iterate(p::Paths, state = nothing) + at_end(p) && return nothing + w = Base.get(p) + next!(p) + return (w, nothing) +end + +# ============================================================================ +# Display +# ============================================================================ + +function Base.show(io::IO, p::Paths) + print(io, LibSemigroups.to_human_readable_repr(p.cxx)) +end + +# ============================================================================ +# Keyword-arg factory +# ============================================================================ + +""" + paths(g::WordGraph; + source = UNDEFINED, + target = UNDEFINED, + min::Integer = 0, + max = POSITIVE_INFINITY, + order::Order = ORDER_SHORTLEX) -> Paths + +Construct a [`Paths`](@ref) over `g` configured by keyword arguments. + +This is a convenience factory equivalent to constructing a `Paths(g)` and +chaining the relevant `!`-suffixed setters. Argument names mirror the +underlying setting names ([`source!`](@ref), [`target!`](@ref), +[`min!`](@ref), [`max!`](@ref), [`order!`](@ref)). + +# Arguments +- `g::WordGraph`: the word graph. + +# Keywords +- `source`: the source node (1-based `Integer`) or + [`UNDEFINED`](@ref Semigroups.UNDEFINED) (default). +- `target`: the target node (1-based `Integer`) or + [`UNDEFINED`](@ref Semigroups.UNDEFINED) (default), meaning "any reachable + target". +- `min::Integer`: minimum path length (default `0`). +- `max`: maximum path length (`Integer` or + [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) — the default). +- `order::Order`: [`ORDER_SHORTLEX`](@ref) (default) or [`ORDER_LEX`](@ref). + +# Example +```jldoctest +julia> using Semigroups + +julia> g = WordGraph(3, 2); + +julia> target!(g, 1, 1, 2); target!(g, 2, 1, 3); + +julia> count(paths(g; source = 1, max = 5)) +3 +``` +""" +function paths( + g::WordGraph; + source = UNDEFINED, + target = UNDEFINED, + min::Integer = 0, + max = POSITIVE_INFINITY, + order::Order = ORDER_SHORTLEX, +) + p = Paths(g) + if !(source isa UndefinedType) + source!(p, source) + end + target!(p, target) + min!(p, min) + max!(p, max) + order!(p, order) + return p +end diff --git a/src/setup.jl b/src/setup.jl index c63f00a..9e44e13 100644 --- a/src/setup.jl +++ b/src/setup.jl @@ -54,7 +54,11 @@ function source_tree_hash() end function jll_tree_hashes() - path = joinpath(libsemigroups_julia_jll.find_artifact_dir(), "lib", "libsemigroups_julia.treehash") + path = joinpath( + libsemigroups_julia_jll.find_artifact_dir(), + "lib", + "libsemigroups_julia.treehash", + ) isfile(path) || return String[] return split(read(path, String)) end diff --git a/test/runtests.jl b/test/runtests.jl index 02dcb3c..cf3bc55 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -19,6 +19,7 @@ using Semigroups include("test_runner.jl") include("test_transf.jl") include("test_word_graph.jl") + include("test_paths.jl") include("test_presentation.jl") include("test_presentation_examples.jl") include("test_word_range.jl") diff --git a/test/test_paths.jl b/test/test_paths.jl new file mode 100644 index 0000000..5324ea9 --- /dev/null +++ b/test/test_paths.jl @@ -0,0 +1,359 @@ +# Copyright (c) 2026, James W. Swent +# +# Distributed under the terms of the GPL license version 3. + +using Test +using Semigroups + +# Pull in private aliases used by Layer 1 binding-surface tests. +const LibSemigroups = Semigroups.LibSemigroups + +# ============================================================================ +# Helpers for building the small word graphs used by the ported tests. +# These mirror the C++ test setup in libsemigroups/tests/test-paths.cpp. +# ============================================================================ + +# 100-node linear path (test 000): node i has edge labelled (i mod 2 + 1) +# pointing to node (i + 1), for i in 1..99 (in 1-based indexing). +function _linear_100() + g = WordGraph(100, 2) + for i = 1:99 + # C++ used i % 2 (0-based label), so 1-based label = (i-1) % 2 + 1 + label = (i - 1) % 2 + 1 + target!(g, i, label, i + 1) + end + return g +end + +# Cycle of n nodes, out_degree 1 (used by tests 002 and 009). +function _cycle(n::Integer) + g = WordGraph(n, 1) + for i = 1:(n-1) + target!(g, i, 1, i + 1) + end + target!(g, n, 1, 1) + return g +end + +# Linear chain of n nodes, out_degree 1: node i has its label-1 edge +# pointing to node (i + 1), for i in 1..n-1. Node n has no outgoing edge. +# Mirrors libsemigroups' `chain(n)` (used by test 009). +function _chain(n::Integer) + g = WordGraph(n, 1) + for i = 1:(n-1) + target!(g, i, 1, i + 1) + end + return g +end + +# Test 001 graph: 9 nodes, out_degree 3. +# C++ targets (0-based): {{1,2,UNDEF}, {}, {3,4,6}, {}, {UNDEF,5}, {}, +# {UNDEF,7}, {8}, {}} +function _g_paths_001() + g = WordGraph(9, 3) + # node 1 (C++ 0): labels 1,2 -> nodes 2,3 (label 3 undefined) + target!(g, 1, 1, 2) + target!(g, 1, 2, 3) + # node 3 (C++ 2): labels 1,2,3 -> 4,5,7 + target!(g, 3, 1, 4) + target!(g, 3, 2, 5) + target!(g, 3, 3, 7) + # node 5 (C++ 4): label 1 undefined; label 2 -> 6 + target!(g, 5, 2, 6) + # node 7 (C++ 6): label 1 undefined; label 2 -> 8 + target!(g, 7, 2, 8) + # node 8 (C++ 7): label 1 -> 9 + target!(g, 8, 1, 9) + return g +end + +# Test 003 graph: 15 nodes, out_degree 2, edges {{1,2},{3,4},...,{13,14}}. +function _g_paths_003() + g = WordGraph(15, 2) + target!(g, 1, 1, 2) + target!(g, 1, 2, 3) + target!(g, 2, 1, 4) + target!(g, 2, 2, 5) + target!(g, 3, 1, 6) + target!(g, 3, 2, 7) + target!(g, 4, 1, 8) + target!(g, 4, 2, 9) + target!(g, 5, 1, 10) + target!(g, 5, 2, 11) + target!(g, 6, 1, 12) + target!(g, 6, 2, 13) + target!(g, 7, 1, 14) + target!(g, 7, 2, 15) + return g +end + +# Test 007 graph: 6 nodes, out_degree 3. +# C++ targets (0-based): {{1,2,UNDEF}, {2,0,3}, {UNDEF,UNDEF,3}, {4}, +# {UNDEF,5}, {3}} +function _g_paths_007() + g = WordGraph(6, 3) + target!(g, 1, 1, 2) + target!(g, 1, 2, 3) + target!(g, 2, 1, 3) + target!(g, 2, 2, 1) + target!(g, 2, 3, 4) + target!(g, 3, 3, 4) + target!(g, 4, 1, 5) + target!(g, 5, 2, 6) + target!(g, 6, 1, 4) + return g +end + +# Test 009 small graph: 5 nodes, out_degree 2, +# C++ edges {{2,1}, {}, {3}, {4}, {2}}. +function _g_paths_009_small() + g = WordGraph(5, 2) + target!(g, 1, 1, 3) + target!(g, 1, 2, 2) + target!(g, 3, 1, 4) + target!(g, 4, 1, 5) + target!(g, 5, 1, 3) + return g +end + +@testset verbose = true "Paths" begin + + @testset "Paths correctness" begin + + @testset "Paths 000 / 100 node path" begin + g = _linear_100() + p = paths(g; source = 1, order = ORDER_LEX) + @test count(p) == 100 + + source!(p, 51) # C++ source(50) + @test count(p) == 50 + + source!(p, 1) + order!(p, ORDER_SHORTLEX) + @test count(p) == 100 + + source!(p, 51) + @test count(p) == 50 + + next!(p) + @test count(p) == 49 + next!(p) + @test count(p) == 48 + + source!(p, 100) # C++ source(99) + @test count(p) == 1 + + next!(p) + @test count(p) == 0 + next!(p) + @test count(p) == 0 + end + + @testset "Paths 001 / #1" begin + g = _g_paths_001() + + # source=2 (C++) -> 3 (Julia); min=3; max=3; count=1 + p = paths(g; source = 3, min = 3, max = 3, order = ORDER_LEX) + @test count(p) == 1 + # The unique path 210_w (C++ 0-based) -> [3, 2, 1] (1-based). + @test Base.get(p) == [3, 2, 1] + + # source=0, min=0, max=0 -> Julia source=1 + p = paths(g; source = 1, min = 0, max = 0, order = ORDER_LEX) + @test source(p) == 1 + @test target(p) === UNDEFINED + @test Semigroups.min(p) == 0 + @test Base.max(p) == 0 + @test !at_end(p) + @test count(p) == 1 + + # min=0, max=1 -> count == 3 (empty + two single letters) + min!(p, 0) + max!(p, 1) + @test count(p) == 3 + + min!(p, 0) + max!(p, 2) + @test count(p) == 6 + + min!(p, 0) + max!(p, 3) + @test count(p) == 8 + + min!(p, 0) + max!(p, 4) + @test count(p) == 9 + + min!(p, 0) + max!(p, 10) + @test count(p) == 9 + end + + @testset "Paths 002 / 100 node cycle" begin + g = _cycle(100) + p = paths(g; source = 1, max = 200, order = ORDER_LEX) + @test Base.get(p) == Int[] + @test count(p) == 201 + + order!(p, ORDER_SHORTLEX) + @test count(p) == 201 + end + + @testset "Paths 003 / #2" begin + g = _g_paths_003() + p = paths(g; source = 1, min = 0, max = 2, order = ORDER_LEX) + @test count(p) == 7 + + order!(p, ORDER_SHORTLEX) + source!(p, 1) + min!(p, 0) + max!(p, 2) + @test count(p) == 7 + end + + @testset "Paths 007 / #6 (POSITIVE_INFINITY round-trip)" begin + g = _g_paths_007() + p = paths(g; source = 1, min = 0, max = 9, order = ORDER_SHORTLEX) + @test count(p) == 75 + + max!(p, POSITIVE_INFINITY) + @test count(p) === POSITIVE_INFINITY + + max!(p, 9) + @test count(p) == 75 + end + + @testset "Paths 009 / pstilo corner case" begin + # Small 5-node graph with a single path. + g = _g_paths_009_small() + p = paths(g; source = 1, target = 2, order = ORDER_LEX) + @test Base.get(p) == [2] + next!(p) + @test at_end(p) + + # Chain of 5: source==target=1 yields only the empty path. + g = _chain(5) + p = paths(g; source = 1, target = 1, min = 0, max = 100, order = ORDER_LEX) + @test count(p) == 1 + + min!(p, 4) + @test count(p) == 0 + + # Cycle of 5: source==target, with various min/max bounds. + g = _cycle(5) + p = paths(g; source = 1, target = 1, min = 0, max = 6, order = ORDER_LEX) + @test count(p) == 2 + + max!(p, 100) + @test count(p) == 21 + + min!(p, 4) + @test count(p) == 20 + + min!(p, 0) + max!(p, 2) + @test count(p) == 1 + end + end + + @testset "Paths Julia API" begin + + @testset "iteration traits" begin + g = _g_paths_003() + p = paths(g; source = 1, max = 5) + @test Base.eltype(typeof(p)) === Vector{Int} + @test Base.IteratorSize(typeof(p)) === Base.SizeUnknown() + end + + @testset "collect returns 1-based letter vectors" begin + g = _g_paths_003() + p = paths(g; source = 1, target = 3, max = 5, order = ORDER_SHORTLEX) + words = collect(p) + @test words isa Vector{Vector{Int}} + # Every letter must be 1-based. + for w in words + for letter in w + @test letter >= 1 + end + end + # After collect, the underlying iterator is exhausted. + @test at_end(p) === true + end + + @testset "manual stepping with while !at_end" begin + g = _g_paths_003() + p = paths(g; source = 1, max = 2, order = ORDER_LEX) + seen = Vector{Vector{Int}}() + while !at_end(p) + push!(seen, copy(Base.get(p))) + next!(p) + end + @test length(seen) == 7 + end + + @testset "sentinel round-trips" begin + g = _g_paths_003() + @test source(Paths(g)) === UNDEFINED + @test target(Paths(g)) === UNDEFINED + + p = paths(g; source = 1) + @test Base.max(p) === POSITIVE_INFINITY + end + + @testset "init! rebinds and resets" begin + g1 = _g_paths_003() + p = paths(g1; source = 1, min = 1, max = 4, order = ORDER_LEX) + @test count(p) > 0 + + # Rebind to a different graph; settings reset to defaults. + g2 = _cycle(5) + init!(p, g2) + @test source(p) === UNDEFINED + @test target(p) === UNDEFINED + @test Semigroups.min(p) == 0 + @test Base.max(p) === POSITIVE_INFINITY + @test word_graph(p) === g2 + + # The new graph is now usable. + source!(p, 1) + max!(p, 4) + @test count(p) == 5 + + # Rebound graph survives GC after the original goes out of scope. + local p2 + let + gtmp = WordGraph(4, 2) + target!(gtmp, 1, 1, 2) + p2 = Paths(WordGraph(2, 2)) # initial graph thrown away + init!(p2, gtmp) + # `gtmp` falls out of scope here. + end + GC.gc() + @test number_of_nodes(word_graph(p2)) == 4 + end + + @testset "error paths" begin + g = _g_paths_003() + # Source undefined -> next! should throw. + @test_throws LibsemigroupsError next!(Paths(g)) + # Out-of-bounds source. + @test_throws LibsemigroupsError source!(Paths(g), 999) + # Order other than shortlex/lex is rejected. + @test_throws LibsemigroupsError order!(Paths(g), ORDER_RECURSIVE) + # 1-based guard: zero is not a valid node. + @test_throws InexactError source!(Paths(g), 0) + end + end + + @testset "Paths GC pin" begin + function make_paths() + g = WordGraph(5, 2) + target!(g, 1, 1, 2) + return Paths(g) # `g` goes out of scope here. + end + + p = make_paths() + GC.gc() + @test number_of_nodes(word_graph(p)) == 5 + end +end