From 8a098bd7aff7e2e582727eaced9489477ffa86aa Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 12:09:32 +0100 Subject: [PATCH 1/9] feat: add Paths C++ bindings via CxxWrap --- deps/src/CMakeLists.txt | 1 + deps/src/libsemigroups_julia.cpp | 1 + deps/src/libsemigroups_julia.hpp | 1 + deps/src/paths.cpp | 135 +++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 deps/src/paths.cpp 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..c51cb7f 100644 --- a/deps/src/libsemigroups_julia.cpp +++ b/deps/src/libsemigroups_julia.cpp @@ -49,6 +49,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..3793c8c --- /dev/null +++ b/deps/src/paths.cpp @@ -0,0 +1,135 @@ +// +// 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(); + + // --- 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 From 4b81ce99fa415a85e89b87df543e96569c17e449 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 12:37:15 +0100 Subject: [PATCH 2/9] test: basic Paths tests --- test/runtests.jl | 1 + test/test_paths.jl | 373 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 test/test_paths.jl 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..29bcd98 --- /dev/null +++ b/test/test_paths.jl @@ -0,0 +1,373 @@ +# 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 in 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 in 1:(n-1) + target!(g, i, 1, i + 1) + end + target!(g, n, 1, 1) + 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 + + # ======================================================================= + # Layer 1: binding-surface tests. + # These hit LibSemigroups.PathsCxx directly to confirm the C++ glue is + # wired up. They should pass immediately after the Task 1 commit, before + # any high-level Julia wrapper exists. + # ======================================================================= + + @testset "Paths bindings" begin + @test isdefined(LibSemigroups, :PathsCxx) + # Constructor takes a WordGraph (CxxWrap registers the constructor on + # the type itself; we test instantiability through the underlying C++ + # type used in the binding). + @test hasmethod(LibSemigroups.PathsCxx, + Tuple{LibSemigroups.WordGraph}) + + # Validation + @test hasmethod(LibSemigroups.throw_if_source_undefined, + Tuple{LibSemigroups.PathsCxx}) + + # Range / iteration interface + @test hasmethod(LibSemigroups.get, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"next!", + Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.at_end, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.count, Tuple{LibSemigroups.PathsCxx}) + + # Settings: getter / setter pairs + @test hasmethod(LibSemigroups.source, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"source!", + Tuple{LibSemigroups.PathsCxx,UInt32}) + @test hasmethod(LibSemigroups.target, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"target!", + Tuple{LibSemigroups.PathsCxx,UInt32}) + @test hasmethod(LibSemigroups.min, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"min!", + Tuple{LibSemigroups.PathsCxx,UInt}) + @test hasmethod(LibSemigroups.max, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"max!", + Tuple{LibSemigroups.PathsCxx,UInt}) + @test hasmethod(LibSemigroups.order, Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.var"order!", + Tuple{LibSemigroups.PathsCxx,LibSemigroups.Order}) + + # Read-only queries + @test hasmethod(LibSemigroups.current_target, + Tuple{LibSemigroups.PathsCxx}) + @test hasmethod(LibSemigroups.word_graph, + Tuple{LibSemigroups.PathsCxx}) + + # Free function + @test hasmethod(LibSemigroups.to_human_readable_repr, + Tuple{LibSemigroups.PathsCxx}) + end + + # ======================================================================= + # Layer 2: correctness tests ported from libsemigroups/tests/test-paths.cpp. + # These exercise the high-level Julia API (paths(g; ...), count(p), + # source!, etc.) which does NOT exist yet — these failures are the RED + # signal that drives Task 3. + # + # All letter values, source nodes, and target nodes are translated to + # 1-based; min / max (path lengths) are unchanged. + # ======================================================================= + + @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) + + # Cycle of 5: source==target, with various min/max bounds. + g = _cycle(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 + + # Same cycle but smaller bounds again. + min!(p, 0); max!(p, 6) + @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 + + # ======================================================================= + # Layer 3: high-level Julia API integration tests. + # ======================================================================= + + @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 "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 + + # ======================================================================= + # GC stress test: confirms the wrapper struct's `g::WordGraph` field + # keeps the WordGraph alive after it leaves the surrounding scope. + # Without that pin, this case would crash. + # ======================================================================= + + @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 From 08cd658f59ca71e8c215a91a82b95260e02980c1 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 12:50:24 +0100 Subject: [PATCH 3/9] fix: use chain(5) for chain-specific assertions in test 009 --- test/test_paths.jl | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/test_paths.jl b/test/test_paths.jl index 29bcd98..60a3f93 100644 --- a/test/test_paths.jl +++ b/test/test_paths.jl @@ -35,6 +35,17 @@ function _cycle(n::Integer) 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 in 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}, {}} @@ -267,8 +278,8 @@ end next!(p) @test at_end(p) - # Cycle of 5: source==target, with various min/max bounds. - g = _cycle(5) + # 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 @@ -276,8 +287,10 @@ end min!(p, 4) @test count(p) == 0 - # Same cycle but smaller bounds again. - min!(p, 0); max!(p, 6) + # 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) From bef5fbf82215102c263d1fdce778779592beec1c Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 13:02:07 +0100 Subject: [PATCH 4/9] test: tighten Paths binding-surface tests --- test/test_paths.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/test_paths.jl b/test/test_paths.jl index 60a3f93..e2f3e69 100644 --- a/test/test_paths.jl +++ b/test/test_paths.jl @@ -116,11 +116,10 @@ end @testset "Paths bindings" begin @test isdefined(LibSemigroups, :PathsCxx) - # Constructor takes a WordGraph (CxxWrap registers the constructor on - # the type itself; we test instantiability through the underlying C++ - # type used in the binding). - @test hasmethod(LibSemigroups.PathsCxx, - Tuple{LibSemigroups.WordGraph}) + # Constructor takes a WordGraph. CxxWrap-allocated types don't expose + # constructors as `hasmethod`-discoverable on the type itself, so + # smoke-test by actually calling the constructor. + @test LibSemigroups.PathsCxx(WordGraph(3, 2)) isa LibSemigroups.PathsCxx # Validation @test hasmethod(LibSemigroups.throw_if_source_undefined, @@ -216,7 +215,7 @@ end @test source(p) == 1 @test target(p) === UNDEFINED @test Semigroups.min(p) == 0 - @test Base.max(p) === 0 + @test Base.max(p) == 0 @test !at_end(p) @test count(p) == 1 From 5dd96d650f799ed82c582c400764be3c0fde1859 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 13:29:02 +0100 Subject: [PATCH 5/9] feat: add Paths Julia wrapper --- src/Semigroups.jl | 5 + src/paths.jl | 507 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 src/paths.jl diff --git a/src/Semigroups.jl b/src/Semigroups.jl index 5dc8fa8..972d273 100644 --- a/src/Semigroups.jl +++ b/src/Semigroups.jl @@ -80,6 +80,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") @@ -263,6 +264,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..45d523f --- /dev/null +++ b/src/paths.jl @@ -0,0 +1,507 @@ +# 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 (private, file-local) +# ---------------------------------------------------------------------------- +# The C++ binding is pure pass-through: all conversion happens here, on the +# Julia side. `_to_cpp` calls live OUTSIDE @wrap_libsemigroups_call so that a +# native InexactError (from a zero or negative input being cast to UInt32 / +# UInt) propagates as InexactError rather than being re-wrapped. +# ============================================================================ + +# 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), [`next!`](@ref), [`at_end`](@ref), [`Base.count`](@ref)). + +# 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)) + +# ============================================================================ +# 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) = Int(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). + +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). + +See also [`Base.min`](@ref). +""" +function min!(p::Paths, n::Integer) + v = _length_to_cpp(n) + 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). + +See also [`Base.max`](@ref). +""" +function max!(p::Paths, n::Integer) + v = _length_to_cpp(n) + 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), [`next!`](@ref), [`at_end`](@ref), and +[`Base.count`](@ref) 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 any). + +# Throws +- `LibsemigroupsError`: if [`source`](@ref)`(p)` is + [`UNDEFINED`](@ref Semigroups.UNDEFINED). + +See also [`Base.get`](@ref), [`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). + +# 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, _ = 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 From e5c932b710f3a9cbebf070a648779b2c38a4bf80 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 13:44:05 +0100 Subject: [PATCH 6/9] docs: add Paths documentation page --- docs/make.jl | 1 + docs/src/data-structures/paths.md | 66 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/src/data-structures/paths.md 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..c3f3fba --- /dev/null +++ b/docs/src/data-structures/paths.md @@ -0,0 +1,66 @@ +# 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 +``` + +## 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). | +| [`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). | +| `Base.iterate(p::Paths)` | Julia iteration protocol (destructive — consumes the range). | +| `Base.show(io::IO, p::Paths)` | Human-readable representation. | + +## Full API + +```@docs +Semigroups.Paths(::WordGraph) +Semigroups.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) +``` From f0b98c5c4a0da7ceab5c1ac7bf8670a82e28fd78 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 14:28:52 +0100 Subject: [PATCH 7/9] docs: add top-of-page Paths usage example --- docs/src/data-structures/paths.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/src/data-structures/paths.md b/docs/src/data-structures/paths.md index c3f3fba..52ace76 100644 --- a/docs/src/data-structures/paths.md +++ b/docs/src/data-structures/paths.md @@ -15,6 +15,25 @@ protocol or the manual `get` / [`next!`](@ref Semigroups.next!) / 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 | From ca7d1283cf86e13d74d5e7ea34e818ad18815570 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 14:50:53 +0100 Subject: [PATCH 8/9] chore: cosmetic cleanups --- src/paths.jl | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/paths.jl b/src/paths.jl index 45d523f..4d825a2 100644 --- a/src/paths.jl +++ b/src/paths.jl @@ -139,7 +139,7 @@ This getter is not exported; call as `Semigroups.min(p)` or `Base.min(p)`. See also [`min!`](@ref). """ -Base.min(p::Paths) = Int(LibSemigroups.min(p.cxx)) +Base.min(p::Paths) = _length_from_cpp(LibSemigroups.min(p.cxx)) """ Base.max(p::Paths) -> Union{Int, PositiveInfinityType} @@ -251,10 +251,18 @@ 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 @@ -272,10 +280,15 @@ passing [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY). - `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 @@ -429,7 +442,7 @@ end Base.IteratorSize(::Type{Paths}) = Base.SizeUnknown() Base.eltype(::Type{Paths}) = Vector{Int} -function Base.iterate(p::Paths, _ = nothing) +function Base.iterate(p::Paths, state = nothing) at_end(p) && return nothing w = Base.get(p) next!(p) From d7ce60b150f8f76257756b97b94292354ab49d70 Mon Sep 17 00:00:00 2001 From: James Swent Date: Sun, 26 Apr 2026 16:12:07 +0100 Subject: [PATCH 9/9] chore: run formatter --- deps/src/libsemigroups_julia.cpp | 5 +- deps/src/paths.cpp | 59 +++++----- docs/src/data-structures/paths.md | 9 +- src/Semigroups.jl | 59 +++++++--- src/paths.jl | 71 ++++++++---- src/setup.jl | 6 +- test/test_paths.jl | 180 +++++++++++++----------------- 7 files changed, 215 insertions(+), 174 deletions(-) diff --git a/deps/src/libsemigroups_julia.cpp b/deps/src/libsemigroups_julia.cpp index c51cb7f..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); diff --git a/deps/src/paths.cpp b/deps/src/paths.cpp index 3793c8c..0266931 100644 --- a/deps/src/paths.cpp +++ b/deps/src/paths.cpp @@ -48,11 +48,25 @@ namespace libsemigroups_julia { // (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(); - }); + type.method("throw_if_source_undefined", + [](Paths_ const& self) { self.throw_if_source_undefined(); }); // --- Range / iteration interface --- @@ -75,9 +89,8 @@ namespace libsemigroups_julia { // setters to use the `!` suffix per Julia convention. // source - type.method("source", [](Paths_ const& self) -> uint32_t { - return self.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 @@ -85,24 +98,19 @@ namespace libsemigroups_julia { // (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_ 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); }); + 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); }); + 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", @@ -119,17 +127,16 @@ namespace libsemigroups_julia { // 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(); }); + 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); - }); + 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/src/data-structures/paths.md b/docs/src/data-structures/paths.md index 52ace76..41ba7eb 100644 --- a/docs/src/data-structures/paths.md +++ b/docs/src/data-structures/paths.md @@ -40,6 +40,7 @@ julia> collect(p) | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | [`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)). | @@ -57,14 +58,18 @@ julia> collect(p) | [`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). | -| `Base.iterate(p::Paths)` | Julia iteration protocol (destructive — consumes the range). | -| `Base.show(io::IO, p::Paths)` | Human-readable representation. | + +[`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) diff --git a/src/Semigroups.jl b/src/Semigroups.jl index 972d273..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 @@ -93,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 @@ -171,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 @@ -194,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), ) @@ -210,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__() diff --git a/src/paths.jl b/src/paths.jl index 4d825a2..009c682 100644 --- a/src/paths.jl +++ b/src/paths.jl @@ -14,25 +14,19 @@ 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`) +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 (private, file-local) -# ---------------------------------------------------------------------------- -# The C++ binding is pure pass-through: all conversion happens here, on the -# Julia side. `_to_cpp` calls live OUTSIDE @wrap_libsemigroups_call so that a -# native InexactError (from a zero or negative input being cast to UInt32 / -# UInt) propagates as InexactError rather than being re-wrapped. +# 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 +@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) @@ -61,7 +55,8 @@ 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), [`next!`](@ref), [`at_end`](@ref), [`Base.count`](@ref)). +([`Base.get`](@ref Base.get(::Paths)), [`next!`](@ref), [`at_end`](@ref), +[`Base.count`](@ref Base.count(::Paths))). # Example ```jldoctest @@ -103,6 +98,30 @@ 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 # ============================================================================ @@ -168,7 +187,8 @@ 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). +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). @@ -336,8 +356,8 @@ 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), [`next!`](@ref), [`at_end`](@ref), and -[`Base.count`](@ref) since the underlying C++ implementation does *not* +[`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 @@ -377,13 +397,15 @@ end """ next!(p::Paths) -> Paths -Advance `p` to the next path (if any). +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), [`at_end`](@ref). +See also [`Base.get`](@ref Base.get(::Paths)), [`at_end`](@ref). """ function next!(p::Paths) GC.@preserve p begin @@ -418,7 +440,8 @@ end 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). +[`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 @@ -502,12 +525,14 @@ 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) +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) 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/test_paths.jl b/test/test_paths.jl index e2f3e69..5324ea9 100644 --- a/test/test_paths.jl +++ b/test/test_paths.jl @@ -8,16 +8,16 @@ 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 in 1:99 + 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) @@ -28,7 +28,7 @@ end # Cycle of n nodes, out_degree 1 (used by tests 002 and 009). function _cycle(n::Integer) g = WordGraph(n, 1) - for i in 1:(n-1) + for i = 1:(n-1) target!(g, i, 1, i + 1) end target!(g, n, 1, 1) @@ -40,7 +40,7 @@ end # Mirrors libsemigroups' `chain(n)` (used by test 009). function _chain(n::Integer) g = WordGraph(n, 1) - for i in 1:(n-1) + for i = 1:(n-1) target!(g, i, 1, i + 1) end return g @@ -70,13 +70,20 @@ 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) + 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 @@ -85,8 +92,11 @@ end # {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, 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) @@ -98,7 +108,8 @@ end # 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, 1, 1, 3) + target!(g, 1, 2, 2) target!(g, 3, 1, 4) target!(g, 4, 1, 5) target!(g, 5, 1, 3) @@ -107,69 +118,6 @@ end @testset verbose = true "Paths" begin - # ======================================================================= - # Layer 1: binding-surface tests. - # These hit LibSemigroups.PathsCxx directly to confirm the C++ glue is - # wired up. They should pass immediately after the Task 1 commit, before - # any high-level Julia wrapper exists. - # ======================================================================= - - @testset "Paths bindings" begin - @test isdefined(LibSemigroups, :PathsCxx) - # Constructor takes a WordGraph. CxxWrap-allocated types don't expose - # constructors as `hasmethod`-discoverable on the type itself, so - # smoke-test by actually calling the constructor. - @test LibSemigroups.PathsCxx(WordGraph(3, 2)) isa LibSemigroups.PathsCxx - - # Validation - @test hasmethod(LibSemigroups.throw_if_source_undefined, - Tuple{LibSemigroups.PathsCxx}) - - # Range / iteration interface - @test hasmethod(LibSemigroups.get, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"next!", - Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.at_end, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.count, Tuple{LibSemigroups.PathsCxx}) - - # Settings: getter / setter pairs - @test hasmethod(LibSemigroups.source, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"source!", - Tuple{LibSemigroups.PathsCxx,UInt32}) - @test hasmethod(LibSemigroups.target, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"target!", - Tuple{LibSemigroups.PathsCxx,UInt32}) - @test hasmethod(LibSemigroups.min, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"min!", - Tuple{LibSemigroups.PathsCxx,UInt}) - @test hasmethod(LibSemigroups.max, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"max!", - Tuple{LibSemigroups.PathsCxx,UInt}) - @test hasmethod(LibSemigroups.order, Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.var"order!", - Tuple{LibSemigroups.PathsCxx,LibSemigroups.Order}) - - # Read-only queries - @test hasmethod(LibSemigroups.current_target, - Tuple{LibSemigroups.PathsCxx}) - @test hasmethod(LibSemigroups.word_graph, - Tuple{LibSemigroups.PathsCxx}) - - # Free function - @test hasmethod(LibSemigroups.to_human_readable_repr, - Tuple{LibSemigroups.PathsCxx}) - end - - # ======================================================================= - # Layer 2: correctness tests ported from libsemigroups/tests/test-paths.cpp. - # These exercise the high-level Julia API (paths(g; ...), count(p), - # source!, etc.) which does NOT exist yet — these failures are the RED - # signal that drives Task 3. - # - # All letter values, source nodes, and target nodes are translated to - # 1-based; min / max (path lengths) are unchanged. - # ======================================================================= - @testset "Paths correctness" begin @testset "Paths 000 / 100 node path" begin @@ -220,19 +168,24 @@ end @test count(p) == 1 # min=0, max=1 -> count == 3 (empty + two single letters) - min!(p, 0); max!(p, 1) + min!(p, 0) + max!(p, 1) @test count(p) == 3 - min!(p, 0); max!(p, 2) + min!(p, 0) + max!(p, 2) @test count(p) == 6 - min!(p, 0); max!(p, 3) + min!(p, 0) + max!(p, 3) @test count(p) == 8 - min!(p, 0); max!(p, 4) + min!(p, 0) + max!(p, 4) @test count(p) == 9 - min!(p, 0); max!(p, 10) + min!(p, 0) + max!(p, 10) @test count(p) == 9 end @@ -252,14 +205,15 @@ end @test count(p) == 7 order!(p, ORDER_SHORTLEX) - source!(p, 1); min!(p, 0); max!(p, 2) + 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) + p = paths(g; source = 1, min = 0, max = 9, order = ORDER_SHORTLEX) @test count(p) == 75 max!(p, POSITIVE_INFINITY) @@ -279,8 +233,7 @@ end # 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) + p = paths(g; source = 1, target = 1, min = 0, max = 100, order = ORDER_LEX) @test count(p) == 1 min!(p, 4) @@ -288,8 +241,7 @@ end # 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) + p = paths(g; source = 1, target = 1, min = 0, max = 6, order = ORDER_LEX) @test count(p) == 2 max!(p, 100) @@ -298,15 +250,12 @@ end min!(p, 4) @test count(p) == 20 - min!(p, 0); max!(p, 2) + min!(p, 0) + max!(p, 2) @test count(p) == 1 end end - # ======================================================================= - # Layer 3: high-level Julia API integration tests. - # ======================================================================= - @testset "Paths Julia API" begin @testset "iteration traits" begin @@ -318,8 +267,7 @@ 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) + 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. @@ -352,6 +300,38 @@ end @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. @@ -365,12 +345,6 @@ end end end - # ======================================================================= - # GC stress test: confirms the wrapper struct's `g::WordGraph` field - # keeps the WordGraph alive after it leaves the surrounding scope. - # Without that pin, this case would crash. - # ======================================================================= - @testset "Paths GC pin" begin function make_paths() g = WordGraph(5, 2)