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