diff --git a/Project.toml b/Project.toml
index b0f2640..e35c851 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,19 +1,20 @@
name = "Semigroups"
uuid = "f8a5e1c0-7b2d-4a3e-9c6f-1d2e3f4a5b6c"
-authors = ["James Swent"]
version = "0.1.0"
+authors = ["James Swent"]
[deps]
-CxxWrap = "1f15a43c-97ca-5a2a-ae31-89f07a497df4"
AbstractAlgebra = "c3fe647b-3220-5bb0-a1ea-a7954cac585d"
+CxxWrap = "1f15a43c-97ca-5a2a-ae31-89f07a497df4"
+Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
+
+[compat]
+AbstractAlgebra = "0.43, 0.48"
+CxxWrap = "0.17"
+julia = "1.9"
[extras]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
[targets]
test = ["Test"]
-
-[compat]
-julia = "1.9"
-CxxWrap = "0.17"
-AbstractAlgebra = "0.43, 0.48"
diff --git a/deps/src/CMakeLists.txt b/deps/src/CMakeLists.txt
index 888d929..7bd5eaf 100644
--- a/deps/src/CMakeLists.txt
+++ b/deps/src/CMakeLists.txt
@@ -33,7 +33,11 @@ message(STATUS "libsemigroups libraries: ${LIBSEMIGROUPS_LIBRARIES}")
add_library(libsemigroups_julia SHARED
libsemigroups_julia.cpp
constants.cpp
+ runner.cpp
+ word-graph.cpp
+ froidure-pin-base.cpp
transf.cpp
+ froidure-pin.cpp
)
# Include directories
diff --git a/deps/src/froidure-pin-base.cpp b/deps/src/froidure-pin-base.cpp
new file mode 100644
index 0000000..eb450bf
--- /dev/null
+++ b/deps/src/froidure-pin-base.cpp
@@ -0,0 +1,317 @@
+//
+// 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
+
+// Explicit SuperType specialization for CxxWrap upcasting.
+// Enables CxxBaseRef to upcast to Runner when calling
+// inherited Runner methods (finished, run!, etc.) on FroidurePinBase instances.
+namespace jlcxx {
+template <> struct SuperType {
+ typedef libsemigroups::Runner type;
+};
+} // namespace jlcxx
+
+namespace libsemigroups_julia {
+
+void define_froidure_pin_base(jl::Module & m)
+{
+ using libsemigroups::FroidurePinBase;
+ using libsemigroups::Runner;
+ using libsemigroups::word_type;
+ using libsemigroups::froidure_pin::product_by_reduction;
+
+ using FPB = FroidurePinBase;
+ using WG = FPB::cayley_graph_type;
+
+ // Register FroidurePinBase inheriting from Runner
+ auto type =
+ m.add_type("FroidurePinBase", jlcxx::julia_base_type());
+
+ //////////////////////////////////////////////////////////////////////////
+ // Settings
+ //////////////////////////////////////////////////////////////////////////
+
+ // batch_size - getter
+ type.method("batch_size", [](FPB const & self) -> size_t {
+ return self.batch_size();
+ });
+
+ // set_batch_size! - setter (different name to avoid CxxWrap overload issue)
+ type.method("set_batch_size!", [](FPB & self, size_t val) -> FPB & {
+ return self.batch_size(val);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Size and enumeration
+ //////////////////////////////////////////////////////////////////////////
+
+ // current_size - number of elements enumerated so far (no enumeration)
+ type.method("current_size", [](FPB const & self) -> size_t {
+ return self.current_size();
+ });
+
+ // size - full enumeration, returns total size
+ type.method("size", [](FPB & self) -> size_t {
+ return self.size();
+ });
+
+ // degree - degree of elements
+ type.method("degree", [](FPB const & self) -> size_t {
+ return self.degree();
+ });
+
+ // enumerate - enumerate up to limit elements
+ type.method("enumerate", [](FPB & self, size_t limit) {
+ self.enumerate(limit);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Rules
+ //////////////////////////////////////////////////////////////////////////
+
+ // number_of_rules - total (triggers full enumeration)
+ type.method("number_of_rules", [](FPB & self) -> size_t {
+ return self.number_of_rules();
+ });
+
+ // current_number_of_rules - so far enumerated (no enumeration)
+ type.method("current_number_of_rules", [](FPB const & self) -> size_t {
+ return self.current_number_of_rules();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Identity element
+ //////////////////////////////////////////////////////////////////////////
+
+ // contains_one - triggers full enumeration
+ type.method("contains_one", [](FPB & self) -> bool {
+ return self.contains_one();
+ });
+
+ // currently_contains_one - no enumeration
+ type.method("currently_contains_one", [](FPB const & self) -> bool {
+ return self.currently_contains_one();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Position queries
+ //////////////////////////////////////////////////////////////////////////
+
+ // position_of_generator - position of i-th generator
+ type.method("position_of_generator", [](FPB const & self, uint32_t i) -> uint32_t {
+ return self.position_of_generator(i);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Prefix / suffix / first / final letter
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("prefix", [](FPB const & self, uint32_t pos) -> uint32_t {
+ return self.prefix(pos);
+ });
+
+ type.method("suffix", [](FPB const & self, uint32_t pos) -> uint32_t {
+ return self.suffix(pos);
+ });
+
+ type.method("first_letter", [](FPB const & self, uint32_t pos) -> uint32_t {
+ return self.first_letter(pos);
+ });
+
+ type.method("final_letter", [](FPB const & self, uint32_t pos) -> uint32_t {
+ return self.final_letter(pos);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Word lengths
+ //////////////////////////////////////////////////////////////////////////
+
+ // current_length - no enumeration
+ type.method("current_length", [](FPB const & self, uint32_t pos) -> size_t {
+ return self.current_length(pos);
+ });
+
+ // length - triggers enumeration
+ type.method("length", [](FPB & self, uint32_t pos) -> size_t {
+ return self.length(pos);
+ });
+
+ // current_max_word_length - no enumeration
+ type.method("current_max_word_length", [](FPB const & self) -> size_t {
+ return self.current_max_word_length();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Number of elements by length
+ //////////////////////////////////////////////////////////////////////////
+
+ // number_of_elements_of_length - single length (no enumeration)
+ type.method("number_of_elements_of_length", [](FPB const & self, size_t len) -> size_t {
+ return self.number_of_elements_of_length(len);
+ });
+
+ // number_of_elements_of_length_range - range [min, max) (no enumeration)
+ type.method("number_of_elements_of_length_range",
+ [](FPB const & self, size_t min, size_t max) -> size_t {
+ return self.number_of_elements_of_length(min, max);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Cayley graphs (return by copy for safety)
+ //////////////////////////////////////////////////////////////////////////
+
+ // right_cayley_graph - triggers full enumeration
+ type.method("right_cayley_graph", [](FPB & self) -> WG {
+ return self.right_cayley_graph();
+ });
+
+ // left_cayley_graph - triggers full enumeration
+ type.method("left_cayley_graph", [](FPB & self) -> WG {
+ return self.left_cayley_graph();
+ });
+
+ // current_right_cayley_graph - no enumeration
+ type.method("current_right_cayley_graph", [](FPB const & self) -> WG {
+ return self.current_right_cayley_graph();
+ });
+
+ // current_left_cayley_graph - no enumeration
+ type.method("current_left_cayley_graph", [](FPB const & self) -> WG {
+ return self.current_left_cayley_graph();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: product_by_reduction
+ //////////////////////////////////////////////////////////////////////////
+
+ m.method("product_by_reduction",
+ [](FPB const & fpb, uint32_t i, uint32_t j) -> uint32_t {
+ return product_by_reduction(fpb, i, j);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: factorisation
+ //////////////////////////////////////////////////////////////////////////
+
+ // current_minimal_factorisation - no enumeration, returns word_type
+ m.method("current_minimal_factorisation",
+ [](FPB const & fpb, uint32_t pos) -> word_type {
+ return libsemigroups::froidure_pin::current_minimal_factorisation(fpb, pos);
+ });
+
+ // minimal_factorisation - triggers enumeration, returns word_type
+ m.method("minimal_factorisation", [](FPB & fpb, uint32_t pos) -> word_type {
+ return libsemigroups::froidure_pin::minimal_factorisation(fpb, pos);
+ });
+
+ // factorisation - triggers enumeration, returns word_type
+ m.method("factorisation", [](FPB & fpb, uint32_t pos) -> word_type {
+ return libsemigroups::froidure_pin::factorisation(fpb, pos);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: position from word
+ //////////////////////////////////////////////////////////////////////////
+
+ // current_position_word - no enumeration, returns UNDEFINED if not found
+ m.method("current_position_word",
+ [](FPB const & fpb, std::vector const & w) -> uint32_t {
+ return libsemigroups::froidure_pin::current_position(fpb, w);
+ });
+
+ // position_word - triggers full enumeration
+ m.method("position_word", [](FPB & fpb, std::vector const & w) -> uint32_t {
+ return libsemigroups::froidure_pin::position(fpb, w);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: rules (two parallel vectors)
+ //////////////////////////////////////////////////////////////////////////
+
+ // rules_lhs_vector - full enumeration, returns LHS of all rules
+ m.method("rules_lhs_vector", [](FPB & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & [lhs, rhs] : libsemigroups::froidure_pin::rules(fpb))
+ {
+ result.push_back(lhs);
+ }
+ return result;
+ });
+
+ // rules_rhs_vector - full enumeration, returns RHS of all rules
+ m.method("rules_rhs_vector", [](FPB & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & [lhs, rhs] : libsemigroups::froidure_pin::rules(fpb))
+ {
+ result.push_back(rhs);
+ }
+ return result;
+ });
+
+ // current_rules_lhs_vector - no enumeration
+ m.method("current_rules_lhs_vector", [](FPB const & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & [lhs, rhs] : libsemigroups::froidure_pin::current_rules(fpb))
+ {
+ result.push_back(lhs);
+ }
+ return result;
+ });
+
+ // current_rules_rhs_vector - no enumeration
+ m.method("current_rules_rhs_vector", [](FPB const & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & [lhs, rhs] : libsemigroups::froidure_pin::current_rules(fpb))
+ {
+ result.push_back(rhs);
+ }
+ return result;
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: normal forms
+ //////////////////////////////////////////////////////////////////////////
+
+ // normal_forms_vector - full enumeration
+ m.method("normal_forms_vector", [](FPB & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & w : libsemigroups::froidure_pin::normal_forms(fpb))
+ {
+ result.push_back(w);
+ }
+ return result;
+ });
+
+ // current_normal_forms_vector - no enumeration
+ m.method("current_normal_forms_vector", [](FPB const & fpb) -> std::vector {
+ std::vector result;
+ for (auto const & w : libsemigroups::froidure_pin::current_normal_forms(fpb))
+ {
+ result.push_back(w);
+ }
+ return result;
+ });
+}
+
+} // namespace libsemigroups_julia
diff --git a/deps/src/froidure-pin.cpp b/deps/src/froidure-pin.cpp
new file mode 100644
index 0000000..8184ad1
--- /dev/null
+++ b/deps/src/froidure-pin.cpp
@@ -0,0 +1,304 @@
+//
+// 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
+
+// CxxWrap SuperType: enables CxxBaseRef upcasting for all FroidurePin
+// types to FroidurePinBase when dispatching inherited methods.
+namespace jlcxx {
+template struct SuperType> {
+ typedef libsemigroups::FroidurePinBase type;
+};
+} // namespace jlcxx
+
+namespace libsemigroups_julia {
+
+namespace {
+
+template
+void bind_froidure_pin(jl::Module & m,
+ jlcxx::TypeWrapper> & type,
+ std::string const & type_name)
+{
+ using FP = libsemigroups::FroidurePin;
+ using word_type = libsemigroups::word_type;
+
+ //////////////////////////////////////////////////////////////////////////
+ // Constructors from individual generators
+ // StdVector cannot be constructed on the Julia side,
+ // so we provide individual-element constructors that build the vector
+ // internally on the C++ side.
+ //////////////////////////////////////////////////////////////////////////
+
+ m.method(type_name, [](Element const & g1) -> FP {
+ std::vector gens = {g1};
+ return FP(gens.begin(), gens.end());
+ });
+
+ m.method(type_name, [](Element const & g1, Element const & g2) -> FP {
+ std::vector gens = {g1, g2};
+ return FP(gens.begin(), gens.end());
+ });
+
+ m.method(type_name,
+ [](Element const & g1, Element const & g2, Element const & g3) -> FP {
+ std::vector gens = {g1, g2, g3};
+ return FP(gens.begin(), gens.end());
+ });
+
+ m.method(type_name,
+ [](Element const & g1, Element const & g2, Element const & g3,
+ Element const & g4) -> FP {
+ std::vector gens = {g1, g2, g3, g4};
+ return FP(gens.begin(), gens.end());
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Copy
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("copy", [](FP const & self) -> FP {
+ return FP(self);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Generator access
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("number_of_generators", [](FP const & self) -> size_t {
+ return self.number_of_generators();
+ });
+
+ type.method("generator", [](FP const & self, uint32_t i) -> Element {
+ return self.generator(i);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Element access (return by copy for GC safety)
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("at", [](FP & self, uint32_t i) -> Element {
+ return self.at(i);
+ });
+
+ type.method("sorted_at", [](FP & self, uint32_t i) -> Element {
+ return self.sorted_at(i);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Position / membership (element-based overloads)
+ // Named with _element suffix to avoid CxxWrap dispatch conflicts with
+ // FroidurePinBase's index/word-based methods.
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("current_position_element",
+ [](FP const & self, Element const & x) -> uint32_t {
+ return self.current_position(x);
+ });
+
+ type.method("position_element", [](FP & self, Element const & x) -> uint32_t {
+ return self.position(x);
+ });
+
+ type.method("sorted_position_element", [](FP & self, Element const & x) -> uint32_t {
+ return self.sorted_position(x);
+ });
+
+ type.method("contains_element", [](FP & self, Element const & x) -> bool {
+ return self.contains(x);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Products and index transforms
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("fast_product", [](FP const & self, uint32_t i, uint32_t j) -> uint32_t {
+ return self.fast_product(i, j);
+ });
+
+ type.method("to_sorted_position", [](FP & self, uint32_t i) -> uint32_t {
+ return self.to_sorted_position(i);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Idempotents
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("number_of_idempotents", [](FP & self) -> size_t {
+ return self.number_of_idempotents();
+ });
+
+ type.method("is_idempotent", [](FP & self, uint32_t i) -> bool {
+ return self.is_idempotent(i);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Generator management (mutating)
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("add_generator!", [](FP & self, Element const & x) {
+ self.add_generator(x);
+ });
+
+ type.method("add_generators!", [](FP & self, std::vector const & gens) {
+ self.add_generators(gens.begin(), gens.end());
+ });
+
+ type.method("closure!", [](FP & self, std::vector const & gens) {
+ self.closure(gens.begin(), gens.end());
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Copy operations (return new FP)
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("copy_add_generators",
+ [](FP const & self, std::vector const & gens) -> FP {
+ return self.copy_add_generators(gens.begin(), gens.end());
+ });
+
+ type.method("copy_closure", [](FP & self, std::vector const & gens) -> FP {
+ return self.copy_closure(gens.begin(), gens.end());
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Reserve
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("reserve!", [](FP & self, size_t val) {
+ self.reserve(val);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Collection methods (collect iterators to vectors)
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("elements_vector", [](FP & self) -> std::vector {
+ self.run();
+ return std::vector(self.cbegin(), self.cend());
+ });
+
+ type.method("sorted_elements_vector", [](FP & self) -> std::vector {
+ std::vector result;
+ for (auto const & e : libsemigroups::froidure_pin::sorted_elements(self))
+ {
+ result.push_back(e);
+ }
+ return result;
+ });
+
+ type.method("idempotents_vector", [](FP & self) -> std::vector {
+ std::vector result;
+ for (auto const & e : libsemigroups::froidure_pin::idempotents(self))
+ {
+ result.push_back(e);
+ }
+ return result;
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Free functions: element-dependent
+ //////////////////////////////////////////////////////////////////////////
+
+ // to_element: convert word -> Element (uses member function with iterators)
+ m.method("to_element", [](FP & fp, std::vector const & w) -> Element {
+ return fp.to_element(w.begin(), w.end());
+ });
+
+ // equal_to_words: check if two words represent the same element
+ m.method(
+ "equal_to_words",
+ [](FP & fp, std::vector const & x, std::vector const & y) -> bool {
+ return fp.equal_to(x.begin(), x.end(), y.begin(), y.end());
+ });
+
+ // factorisation by element (distinct from FPB's index-based factorisation)
+ m.method("factorisation_element", [](FP & fp, Element const & x) -> word_type {
+ return libsemigroups::froidure_pin::factorisation(fp, x);
+ });
+
+ m.method("minimal_factorisation_element", [](FP & fp, Element const & x) -> word_type {
+ return libsemigroups::froidure_pin::minimal_factorisation(fp, x);
+ });
+}
+
+} // anonymous namespace
+
+// Main function to define all FroidurePin template instantiations
+void define_froidure_pin(jl::Module & m)
+{
+ using namespace libsemigroups;
+
+ ////////////////////////////////////////////////////////////////////////
+ // FroidurePin>
+ ////////////////////////////////////////////////////////////////////////
+
+ auto fp_transf1 = m.add_type>>(
+ "FroidurePinTransf1", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_transf1, "FroidurePinTransf1");
+
+ auto fp_transf2 = m.add_type>>(
+ "FroidurePinTransf2", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_transf2, "FroidurePinTransf2");
+
+ auto fp_transf4 = m.add_type>>(
+ "FroidurePinTransf4", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_transf4, "FroidurePinTransf4");
+
+ ////////////////////////////////////////////////////////////////////////
+ // FroidurePin>
+ ////////////////////////////////////////////////////////////////////////
+
+ auto fp_pperm1 = m.add_type>>(
+ "FroidurePinPPerm1", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_pperm1, "FroidurePinPPerm1");
+
+ auto fp_pperm2 = m.add_type>>(
+ "FroidurePinPPerm2", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_pperm2, "FroidurePinPPerm2");
+
+ auto fp_pperm4 = m.add_type>>(
+ "FroidurePinPPerm4", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_pperm4, "FroidurePinPPerm4");
+
+ ////////////////////////////////////////////////////////////////////////
+ // FroidurePin>
+ ////////////////////////////////////////////////////////////////////////
+
+ auto fp_perm1 = m.add_type>>(
+ "FroidurePinPerm1", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_perm1, "FroidurePinPerm1");
+
+ auto fp_perm2 = m.add_type>>(
+ "FroidurePinPerm2", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_perm2, "FroidurePinPerm2");
+
+ auto fp_perm4 = m.add_type>>(
+ "FroidurePinPerm4", jlcxx::julia_base_type());
+ bind_froidure_pin>(m, fp_perm4, "FroidurePinPerm4");
+}
+
+} // namespace libsemigroups_julia
diff --git a/deps/src/libsemigroups_julia.cpp b/deps/src/libsemigroups_julia.cpp
index baec02d..3a0e329 100644
--- a/deps/src/libsemigroups_julia.cpp
+++ b/deps/src/libsemigroups_julia.cpp
@@ -28,10 +28,21 @@ JLCXX_MODULE define_julia_module(jl::Module & mod)
// Define constants first (UNDEFINED, POSITIVE_INFINITY, etc.)
define_constants(mod);
+ // Define base types (must be registered before derived types)
+ define_runner(mod);
+
+ // Define WordGraph (must be before FroidurePinBase)
+ define_word_graph(mod);
+
+ // Define FroidurePinBase (inherits Runner, uses WordGraph)
+ define_froidure_pin_base(mod);
+
// Define element types
define_transf(mod);
- // Add more definitions here (FroidurePin, etc.)
+ // Define FroidurePin template instantiations
+ // Must be AFTER transf (element types) AND froidure_pin_base
+ define_froidure_pin(mod);
}
} // namespace libsemigroups_julia
diff --git a/deps/src/libsemigroups_julia.hpp b/deps/src/libsemigroups_julia.hpp
index 7677609..639b310 100644
--- a/deps/src/libsemigroups_julia.hpp
+++ b/deps/src/libsemigroups_julia.hpp
@@ -22,19 +22,32 @@
#ifndef LIBSEMIGROUPS_JULIA_HPP_
#define LIBSEMIGROUPS_JULIA_HPP_
-// JlCxx headers
+// JlCxx headers FIRST — these pull in standard library headers (,
+// , etc.) that on libstdc++ use __cpp_lib_is_constant_evaluated to
+// decide constexpr-ness. They must be included while the macro is intact.
#include "jlcxx/jlcxx.hpp"
#include "jlcxx/stl.hpp"
-// libsemigroups headers
+// FIX for fmt consteval issue:
+// JlCxx requires C++20, but libsemigroups bundles fmt which enables
+// consteval format string validation in C++20 mode, causing compile errors
+// when inline functions pass runtime std::string_view to fmt::format.
+//
+// fmt/base.h unconditionally defines FMT_USE_CONSTEVAL via an #if/#elif
+// chain (no #ifndef guard), so pre-defining it has no effect. Instead we
+// #undef __cpp_lib_is_constant_evaluated AFTER all standard library headers
+// are included (so their constexpr declarations are correct) but BEFORE any
+// libsemigroups/fmt headers. When fmt/base.h later includes ,
+// include guards prevent re-parsing, so the macro stays undefined and fmt
+// takes the !defined(__cpp_lib_is_constant_evaluated) branch, setting
+// FMT_USE_CONSTEVAL=0.
+#undef __cpp_lib_is_constant_evaluated
+
+// libsemigroups headers (these transitively include fmt)
#include
+#include
#include
-
-// Standard library
-#include
-#include
-#include
-#include
+#include
namespace libsemigroups_julia {
@@ -44,7 +57,11 @@ namespace libsemigroups = ::libsemigroups;
// Forward declarations of binding functions
void define_constants(jl::Module & mod);
+void define_runner(jl::Module & mod);
+void define_word_graph(jl::Module & mod);
+void define_froidure_pin_base(jl::Module & mod);
void define_transf(jl::Module & mod);
+void define_froidure_pin(jl::Module & mod);
} // namespace libsemigroups_julia
diff --git a/deps/src/runner.cpp b/deps/src/runner.cpp
new file mode 100644
index 0000000..122597e
--- /dev/null
+++ b/deps/src/runner.cpp
@@ -0,0 +1,156 @@
+// runner.cpp - Runner base class bindings for libsemigroups_julia
+//
+// 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 .
+//
+// This file exposes the libsemigroups Runner class to Julia via CxxWrap.
+// Runner is an abstract base class providing algorithm execution control
+// (run, run_for, timeout, stop, etc.) used by FroidurePinBase and other
+// algorithm classes.
+
+#include "libsemigroups_julia.hpp"
+
+#include
+
+#include
+
+#include
+#include
+#include
+
+namespace libsemigroups_julia {
+
+void define_runner(jl::Module & m)
+{
+ using libsemigroups::Runner;
+
+ // Register Runner as a base type.
+ // Runner is abstract (pure virtual run_impl/finished_impl) so we do NOT
+ // add constructors. It will only be usable through derived types
+ // (e.g. FroidurePinBase).
+ auto type = m.add_type("Runner");
+
+ //////////////////////////////////////////////////////////////////////////
+ // State enum
+ //////////////////////////////////////////////////////////////////////////
+
+ m.add_bits("state", jl::julia_type("CppEnum"));
+ m.set_const("state_never_run", Runner::state::never_run);
+ m.set_const("state_running_to_finish", Runner::state::running_to_finish);
+ m.set_const("state_running_for", Runner::state::running_for);
+ m.set_const("state_running_until", Runner::state::running_until);
+ m.set_const("state_timed_out", Runner::state::timed_out);
+ m.set_const("state_stopped_by_predicate", Runner::state::stopped_by_predicate);
+ m.set_const("state_not_running", Runner::state::not_running);
+ m.set_const("state_dead", Runner::state::dead);
+
+ //////////////////////////////////////////////////////////////////////////
+ // Core algorithm control
+ //////////////////////////////////////////////////////////////////////////
+
+ // run / run! - Run the algorithm to completion
+ type.method("run!", [](Runner & self) {
+ self.run();
+ });
+
+ // run_for / run_for! - Run for a specified duration.
+ // We accept Int64 nanoseconds from Julia (the Julia layer converts
+ // Dates.TimePeriod to nanoseconds before calling this binding).
+ type.method("run_for!", [](Runner & self, int64_t ns) {
+ self.run_for(std::chrono::nanoseconds(ns));
+ });
+
+ // run_until / run_until! - Run until a nullary predicate returns true.
+ // CxxWrap does not support std::function as a parameter type, so we
+ // accept a SafeCFunction and convert it to a function pointer via
+ // make_function_pointer. The Julia side uses @safe_cfunction to create the
+ // SafeCFunction from a closure. A uint8_t is used instead of bool to
+ // avoid C++ bool ABI issues across platforms.
+ type.method("run_until!", [](Runner & self, jlcxx::SafeCFunction func) {
+ auto fp = jlcxx::make_function_pointer(func);
+ self.run_until([fp]() -> bool {
+ return fp() != 0;
+ });
+ });
+
+ // init - Re-initialize the runner to its default-constructed state
+ type.method("init!", [](Runner & self) -> Runner & {
+ return self.init();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // State queries
+ //////////////////////////////////////////////////////////////////////////
+
+ // finished - Has the algorithm run to completion?
+ type.method("finished", &Runner::finished);
+
+ // success - Has the algorithm completed successfully?
+ type.method("success", &Runner::success);
+
+ // started - Has run() been called at least once?
+ type.method("started", &Runner::started);
+
+ // running - Is the algorithm currently executing?
+ type.method("running", &Runner::running);
+
+ // timed_out - Did run_for! exhaust its time limit?
+ type.method("timed_out", &Runner::timed_out);
+
+ // stopped - Is the algorithm stopped for any reason?
+ // (finished, timed_out, dead, or stopped_by_predicate)
+ type.method("stopped", &Runner::stopped);
+
+ // dead - Was the runner killed from another thread?
+ type.method("dead", &Runner::dead);
+
+ // stopped_by_predicate - Was run_until's predicate satisfied?
+ type.method("stopped_by_predicate", &Runner::stopped_by_predicate);
+
+ // running_for - Is it currently in a run_for! call?
+ type.method("running_for", &Runner::running_for);
+
+ // running_for_how_long - Return last run_for duration in nanoseconds
+ type.method("running_for_how_long", [](Runner const & self) -> int64_t {
+ return self.running_for_how_long().count();
+ });
+
+ // running_until - Is it currently in a run_until call?
+ type.method("running_until", &Runner::running_until);
+
+ // current_state - Return the current state enum value
+ type.method("current_state", &Runner::current_state);
+
+ //////////////////////////////////////////////////////////////////////////
+ // Control
+ //////////////////////////////////////////////////////////////////////////
+
+ // kill / kill! - Stop the runner from another thread (thread-safe)
+ type.method("kill!", [](Runner & self) {
+ self.kill();
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Reporting
+ //////////////////////////////////////////////////////////////////////////
+
+ // report_why_we_stopped - Print reason for stopping to stderr
+ type.method("report_why_we_stopped", &Runner::report_why_we_stopped);
+
+ // string_why_we_stopped - Return reason for stopping as a string
+ type.method("string_why_we_stopped", &Runner::string_why_we_stopped);
+}
+
+} // namespace libsemigroups_julia
diff --git a/deps/src/transf.cpp b/deps/src/transf.cpp
index 920dc9c..fa07900 100644
--- a/deps/src/transf.cpp
+++ b/deps/src/transf.cpp
@@ -122,8 +122,8 @@ void bind_ptransf_common(jl::Module & m,
type.method("is_greater_equal",
[](PTransfType const & a, PTransfType const & b) -> bool {
- return a >= b;
- });
+ return a >= b;
+ });
type.method("multiply", [](PTransfType const & a, PTransfType const & b) {
return a * b;
@@ -154,8 +154,8 @@ void bind_pperm_type(jl::Module & m, std::string const & name)
m.method(name,
[](std::vector const & dom, std::vector const & img,
size_t deg) -> PPermType {
- return libsemigroups::make(dom, img, deg);
- });
+ return libsemigroups::make(dom, img, deg);
+ });
}
template void bind_perm_type(jl::Module & m, std::string const & name)
diff --git a/deps/src/word-graph.cpp b/deps/src/word-graph.cpp
new file mode 100644
index 0000000..dbfdbb3
--- /dev/null
+++ b/deps/src/word-graph.cpp
@@ -0,0 +1,128 @@
+//
+// 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
+
+namespace libsemigroups_julia {
+
+void define_word_graph(jl::Module & m)
+{
+ using WG = libsemigroups::WordGraph;
+
+ auto type = m.add_type("WordGraph");
+
+ //////////////////////////////////////////////////////////////////////////
+ // Constructor
+ //////////////////////////////////////////////////////////////////////////
+
+ m.method("WordGraph", [](size_t num_nodes, size_t out_deg) -> WG {
+ return WG(num_nodes, out_deg);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Size / structure queries
+ //////////////////////////////////////////////////////////////////////////
+
+ // number_of_nodes - total number of nodes in the graph
+ type.method("number_of_nodes", &WG::number_of_nodes);
+
+ // out_degree - number of edge labels (same for all nodes)
+ type.method("out_degree", &WG::out_degree);
+
+ // number_of_edges - total number of defined edges (all nodes)
+ type.method("number_of_edges", [](WG const & self) -> size_t {
+ return self.number_of_edges();
+ });
+
+ // number_of_edges_node - number of defined edges from a specific node
+ type.method("number_of_edges_node", [](WG const & self, uint32_t s) -> size_t {
+ return self.number_of_edges(s);
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Edge lookup
+ //////////////////////////////////////////////////////////////////////////
+
+ // target - get the target of edge (source, label).
+ // Returns UNDEFINED (as uint32_t max) if no such edge is defined.
+ type.method("target", [](WG const & self, uint32_t source, uint32_t label) -> uint32_t {
+ return self.target(source, label);
+ });
+
+ // next_label_and_target - find next defined edge from node s with label >= a.
+ // Split into two methods to avoid std::pair which CxxWrap can't return.
+ // next_label: returns the label of the next defined edge (UNDEFINED if none)
+ // next_target: returns the target of the next defined edge (UNDEFINED if none)
+ type.method("next_label", [](WG const & self, uint32_t s, uint32_t a) -> uint32_t {
+ return self.next_label_and_target(s, a).first;
+ });
+ type.method("next_target", [](WG const & self, uint32_t s, uint32_t a) -> uint32_t {
+ return self.next_label_and_target(s, a).second;
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Iteration helpers (collect to vector for CxxWrap)
+ //////////////////////////////////////////////////////////////////////////
+
+ // targets_vector - all targets from a given source node as a vector.
+ // Includes UNDEFINED entries for labels with no defined edge.
+ type.method(
+ "targets_vector", [](WG const & self, uint32_t source) -> std::vector {
+ std::vector result;
+ result.reserve(self.out_degree());
+ for (auto it = self.cbegin_targets(source); it != self.cend_targets(source); ++it)
+ {
+ result.push_back(*it);
+ }
+ return result;
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Comparison
+ //////////////////////////////////////////////////////////////////////////
+
+ type.method("is_equal", [](WG const & a, WG const & b) -> bool {
+ return a == b;
+ });
+ type.method("is_not_equal", [](WG const & a, WG const & b) -> bool {
+ return a != b;
+ });
+ type.method("is_less", [](WG const & a, WG const & b) -> bool {
+ return a < b;
+ });
+
+ //////////////////////////////////////////////////////////////////////////
+ // Copy and hash
+ //////////////////////////////////////////////////////////////////////////
+
+ // copy - returns a deep copy
+ type.method("copy", [](WG const & self) -> WG {
+ return WG(self);
+ });
+
+ // hash - for use in Julia Base.hash
+ type.method("hash", [](WG const & self) -> size_t {
+ return self.hash_value();
+ });
+}
+
+} // namespace libsemigroups_julia
diff --git a/src/Semigroups.jl b/src/Semigroups.jl
index 267372e..ce27c36 100644
--- a/src/Semigroups.jl
+++ b/src/Semigroups.jl
@@ -8,6 +8,7 @@ module Semigroups
using CxxWrap
using AbstractAlgebra
+using Dates: TimePeriod, Nanosecond
# ============================================================================
# Debug mode
@@ -60,11 +61,19 @@ using .Errors: LibsemigroupsError, @wrap_libsemigroups_call
# Julia-side wrapper files
include("libsemigroups/constants.jl")
+include("libsemigroups/runner.jl")
+include("libsemigroups/word-graph.jl")
include("libsemigroups/transf.jl")
+# Type alias for FroidurePinBase (abstract base; full API comes with FroidurePin{E})
+const FroidurePinBase = LibSemigroups.FroidurePinBase
+
# High-level element types
include("elements/transf.jl")
+# High-level FroidurePin API
+include("froidure-pin.jl")
+
# Module initialization
function __init__()
# Initialize the CxxWrap module
@@ -77,10 +86,36 @@ end
export enable_debug, is_debug, LibsemigroupsError
export UNDEFINED, POSITIVE_INFINITY, NEGATIVE_INFINITY, LIMIT_MAX
+export Runner, RunnerState
+export STATE_NEVER_RUN, STATE_RUNNING_TO_FINISH, STATE_RUNNING_FOR
+export STATE_RUNNING_UNTIL, STATE_TIMED_OUT, STATE_STOPPED_BY_PREDICATE
+export STATE_NOT_RUNNING, STATE_DEAD
+export run!, run_for!, run_until!, init!, kill!
+export finished, started, running, timed_out, stopped, dead
+export stopped_by_predicate, running_for, running_until
+export current_state, running_for_how_long
+export report_why_we_stopped, string_why_we_stopped
export tril, tril_FALSE, tril_TRUE, tril_unknown, tril_to_bool
export is_undefined, is_positive_infinity, is_negative_infinity, is_limit_max
+# WordGraph
+export WordGraph
+export number_of_nodes, out_degree, number_of_edges
+
+# FroidurePinBase
+export FroidurePinBase
+
# Transformation types and functions
+export FroidurePin
+export number_of_generators, generator, generators, current_size
+export sorted_position, sorted_at, to_sorted_position
+export fast_product, number_of_idempotents, is_idempotent, idempotents
+export factorisation, minimal_factorisation, to_element, equal_to
+export prefix, suffix, first_letter, final_letter, word_length, current_max_word_length
+export elements, sorted_elements
+export number_of_rules, contains_one
+export right_cayley_graph, left_cayley_graph
+export batch_size, set_batch_size!, reserve!, add_generator!
export Transf, PPerm, Perm
export degree, rank, images, image_set, domain_set
export increase_degree_by!, swap!
diff --git a/src/froidure-pin.jl b/src/froidure-pin.jl
new file mode 100644
index 0000000..7d6c308
--- /dev/null
+++ b/src/froidure-pin.jl
@@ -0,0 +1,636 @@
+# 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.
+
+"""
+froidure-pin.jl - High-level Julia API for FroidurePin
+
+This file provides the user-facing `FroidurePin{E}` parametric type wrapping
+the C++ FroidurePin template instantiations. All indices are 1-based
+following Julia conventions.
+"""
+
+using CxxWrap.StdLib: StdVector
+using CxxWrap.CxxWrapCore: CxxULong
+
+# ============================================================================
+# Helper functions (private)
+# ============================================================================
+
+# Map from CxxWrap element type → FroidurePin constructor function
+const _FP_CONSTRUCTOR_MAP = Dict{DataType,Any}(
+ Transf1 => LibSemigroups.FroidurePinTransf1,
+ Transf2 => LibSemigroups.FroidurePinTransf2,
+ Transf4 => LibSemigroups.FroidurePinTransf4,
+ PPerm1 => LibSemigroups.FroidurePinPPerm1,
+ PPerm2 => LibSemigroups.FroidurePinPPerm2,
+ PPerm4 => LibSemigroups.FroidurePinPPerm4,
+ Perm1 => LibSemigroups.FroidurePinPerm1,
+ Perm2 => LibSemigroups.FroidurePinPerm2,
+ Perm4 => LibSemigroups.FroidurePinPerm4,
+)
+
+# Map Julia element type → CxxWrap FroidurePin constructor
+function _cxx_fp_constructor(::Type{Transf{T}}) where {T}
+ return _FP_CONSTRUCTOR_MAP[_transf_type_from_scalar_type(T)]
+end
+
+function _cxx_fp_constructor(::Type{PPerm{T}}) where {T}
+ return _FP_CONSTRUCTOR_MAP[_pperm_type_from_scalar_type(T)]
+end
+
+function _cxx_fp_constructor(::Type{Perm{T}}) where {T}
+ return _FP_CONSTRUCTOR_MAP[_perm_type_from_scalar_type(T)]
+end
+
+# Dispatch to 1/2/3/4-arg C++ constructors, or fallback for >4
+function _construct_cxx_fp(Constructor, cxx_gens::Vector)
+ n = length(cxx_gens)
+ if n == 1
+ return Constructor(cxx_gens[1])
+ elseif n == 2
+ return Constructor(cxx_gens[1], cxx_gens[2])
+ elseif n == 3
+ return Constructor(cxx_gens[1], cxx_gens[2], cxx_gens[3])
+ elseif n == 4
+ return Constructor(cxx_gens[1], cxx_gens[2], cxx_gens[3], cxx_gens[4])
+ else
+ # >4: construct with first, then add_generator! for rest
+ fp = Constructor(cxx_gens[1])
+ for i in 2:n
+ LibSemigroups.add_generator!(fp, cxx_gens[i])
+ end
+ return fp
+ end
+end
+
+# Wrap C++ element back to high-level Julia type.
+# Uses Transf()/PPerm()/Perm() constructors which accept any CxxWrap variant
+# (Allocated or Dereferenced). For Dereferenced elements (from C++ vectors),
+# these constructors create owned copies via CxxWrap's implicit conversion.
+_wrap_element(::Type{Transf{T}}, cxx_elem) where {T} = Transf(LibSemigroups.copy(cxx_elem))
+_wrap_element(::Type{PPerm{T}}, cxx_elem) where {T} = PPerm(LibSemigroups.copy(cxx_elem))
+_wrap_element(::Type{Perm{T}}, cxx_elem) where {T} = Perm(LibSemigroups.copy(cxx_elem))
+
+# Word conversion (0-based C++ <-> 1-based Julia)
+_to_word_1based(cxx_word) = [Int(w) + 1 for w in cxx_word]
+_to_word_0based(word) = StdVector{CxxULong}(CxxULong[w - 1 for w in word])
+
+# Check if C++ returned UNDEFINED and convert to nothing (with +1 shift)
+_maybe_undefined(val::UInt32) = is_undefined(val, UInt32) ? nothing : Int(val) + 1
+
+# ============================================================================
+# FroidurePin{E} type
+# ============================================================================
+
+"""
+ FroidurePin{E}
+
+A semigroup defined by generators of element type `E`, enumerated using the
+Froidure-Pin algorithm. `E` is one of `Transf{T}`, `PPerm{T}`, or `Perm{T}`.
+
+All indices are 1-based (Julia convention). The semigroup is lazily enumerated:
+elements are computed on demand when queried.
+
+# Construction
+```julia
+S = FroidurePin(Transf([2, 1, 3]), Transf([2, 3, 1])) # S3
+S = FroidurePin([Transf([2, 1, 3]), Transf([2, 3, 1])]) # same, from vector
+```
+"""
+mutable struct FroidurePin{E}
+ cxx_obj::Any # CxxWrap FroidurePinTransf1Allocated, etc.
+end
+
+# ============================================================================
+# Constructors
+# ============================================================================
+
+"""
+ FroidurePin(gens::AbstractVector{E}) where {E}
+
+Construct a FroidurePin semigroup from a vector of generators.
+Requires at least one generator.
+
+# Example
+```julia
+S = FroidurePin([Transf([2, 1, 3]), Transf([2, 3, 1])])
+```
+"""
+function FroidurePin(gens::AbstractVector{E}) where {E<:Union{Transf,PPerm,Perm}}
+ isempty(gens) && throw(ArgumentError("at least one generator required"))
+ Constructor = _cxx_fp_constructor(E)
+ cxx_gens = [g.cxx_obj for g in gens]
+ cxx_obj = _construct_cxx_fp(Constructor, cxx_gens)
+ return FroidurePin{E}(cxx_obj)
+end
+
+"""
+ FroidurePin(g1::E, gs::E...) where {E}
+
+Construct a FroidurePin semigroup from one or more generators (varargs).
+
+# Example
+```julia
+S = FroidurePin(Transf([2, 1, 3]), Transf([2, 3, 1]))
+```
+"""
+FroidurePin(g1::E, gs::E...) where {E<:Union{Transf,PPerm,Perm}} =
+ FroidurePin(collect(E, (g1, gs...)))
+
+# ============================================================================
+# Runner delegation
+# ============================================================================
+
+"""
+ run!(S::FroidurePin) -> FroidurePin
+
+Run the Froidure-Pin algorithm to completion. Returns `S` for method chaining.
+"""
+run!(S::FroidurePin) = (LibSemigroups.run!(S.cxx_obj); S)
+
+"""
+ run_for!(S::FroidurePin, t::TimePeriod) -> FroidurePin
+
+Run the algorithm for at most duration `t`. Returns `S` for method chaining.
+"""
+run_for!(S::FroidurePin, t::TimePeriod) = (run_for!(S.cxx_obj, t); S)
+
+"""
+ finished(S::FroidurePin) -> Bool
+
+Return `true` if the semigroup has been fully enumerated.
+"""
+finished(S::FroidurePin) = LibSemigroups.finished(S.cxx_obj)
+
+"""
+ started(S::FroidurePin) -> Bool
+
+Return `true` if enumeration has been started.
+"""
+started(S::FroidurePin) = LibSemigroups.started(S.cxx_obj)
+
+"""
+ timed_out(S::FroidurePin) -> Bool
+
+Return `true` if the last `run_for!` call timed out.
+"""
+timed_out(S::FroidurePin) = LibSemigroups.timed_out(S.cxx_obj)
+
+# ============================================================================
+# Size and degree
+# ============================================================================
+
+"""
+ length(S::FroidurePin) -> Int
+
+Return the number of elements in the semigroup. Triggers full enumeration.
+"""
+Base.length(S::FroidurePin) = Int(LibSemigroups.size(S.cxx_obj))
+
+"""
+ current_size(S::FroidurePin) -> Int
+
+Return the number of elements enumerated so far (no further enumeration).
+"""
+current_size(S::FroidurePin) = Int(LibSemigroups.current_size(S.cxx_obj))
+
+"""
+ degree(S::FroidurePin) -> Int
+
+Return the degree of the elements in the semigroup.
+"""
+degree(S::FroidurePin) = Int(LibSemigroups.degree(S.cxx_obj))
+
+# ============================================================================
+# Generators
+# ============================================================================
+
+"""
+ number_of_generators(S::FroidurePin) -> Int
+
+Return the number of generators of the semigroup.
+"""
+number_of_generators(S::FroidurePin) = Int(LibSemigroups.number_of_generators(S.cxx_obj))
+
+"""
+ generator(S::FroidurePin{E}, i::Integer) where E -> E
+
+Return the `i`-th generator (1-based indexing).
+"""
+function generator(S::FroidurePin{E}, i::Integer) where {E}
+ (i < 1 || i > number_of_generators(S)) && throw(BoundsError(S, i))
+ return _wrap_element(E, LibSemigroups.generator(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ generators(S::FroidurePin) -> Vector
+
+Return a vector of all generators.
+"""
+generators(S::FroidurePin) = [generator(S, i) for i in 1:number_of_generators(S)]
+
+# ============================================================================
+# Collection protocol
+# ============================================================================
+
+"""
+ getindex(S::FroidurePin{E}, i::Integer) where E -> E
+
+Return the `i`-th element (1-based). Triggers full enumeration.
+"""
+function Base.getindex(S::FroidurePin{E}, i::Integer) where {E}
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _wrap_element(E, LibSemigroups.at(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ iterate(S::FroidurePin[, state]) -> Union{Tuple, Nothing}
+
+Iterate over elements of the semigroup. Triggers full enumeration.
+"""
+function Base.iterate(S::FroidurePin, state = 1)
+ state > length(S) && return nothing
+ return (S[state], state + 1)
+end
+
+"""
+ in(x::E, S::FroidurePin{E}) -> Bool
+
+Return `true` if element `x` is in the semigroup `S`.
+"""
+Base.in(x::E, S::FroidurePin{E}) where {E<:Union{Transf,PPerm,Perm}} =
+ LibSemigroups.contains_element(S.cxx_obj, x.cxx_obj)
+
+"""
+ eltype(::Type{FroidurePin{E}}) -> Type
+
+Return the element type of the semigroup.
+"""
+Base.eltype(::Type{FroidurePin{E}}) where {E} = E
+
+"""
+ copy(S::FroidurePin{E}) -> FroidurePin{E}
+
+Return an independent copy of the semigroup.
+"""
+Base.copy(S::FroidurePin{E}) where {E} = FroidurePin{E}(LibSemigroups.copy(S.cxx_obj))
+
+# ============================================================================
+# Display
+# ============================================================================
+
+function Base.show(io::IO, S::FroidurePin{E}) where {E}
+ n = number_of_generators(S)
+ if finished(S)
+ print(io, "")
+ elseif started(S)
+ print(io, "")
+ else
+ print(io, "")
+ end
+end
+
+# ============================================================================
+# Position / membership
+# ============================================================================
+
+"""
+ position(S::FroidurePin{E}, x::E) -> Union{Int, Nothing}
+
+Return the 1-based position of `x` in `S`, or `nothing` if not found.
+Triggers full enumeration.
+"""
+function position(S::FroidurePin{E}, x::E) where {E}
+ pos = LibSemigroups.position_element(S.cxx_obj, x.cxx_obj)
+ return _maybe_undefined(pos)
+end
+
+"""
+ current_position(S::FroidurePin{E}, x::E) -> Union{Int, Nothing}
+
+Return the 1-based position of `x` among elements enumerated so far,
+or `nothing` if not yet found. Does not trigger further enumeration.
+"""
+function current_position(S::FroidurePin{E}, x::E) where {E}
+ pos = LibSemigroups.current_position_element(S.cxx_obj, x.cxx_obj)
+ return _maybe_undefined(pos)
+end
+
+"""
+ sorted_position(S::FroidurePin{E}, x::E) -> Union{Int, Nothing}
+
+Return the 1-based position of `x` in the sorted enumeration order,
+or `nothing` if not found. Triggers full enumeration.
+"""
+function sorted_position(S::FroidurePin{E}, x::E) where {E}
+ pos = LibSemigroups.sorted_position_element(S.cxx_obj, x.cxx_obj)
+ return _maybe_undefined(pos)
+end
+
+"""
+ sorted_at(S::FroidurePin{E}, i::Integer) -> E
+
+Return the `i`-th element in sorted order (1-based). Triggers full enumeration.
+"""
+function sorted_at(S::FroidurePin{E}, i::Integer) where {E}
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _wrap_element(E, LibSemigroups.sorted_at(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ to_sorted_position(S::FroidurePin, i::Integer) -> Int
+
+Convert a 1-based enumeration-order position to a 1-based sorted-order position.
+"""
+function to_sorted_position(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return Int(LibSemigroups.to_sorted_position(S.cxx_obj, UInt32(i - 1))) + 1
+end
+
+# ============================================================================
+# Products
+# ============================================================================
+
+"""
+ fast_product(S::FroidurePin, i::Integer, j::Integer) -> Int
+
+Return the 1-based position of S[i] * S[j]. Both `i` and `j` are 1-based.
+The semigroup must be fully enumerated.
+"""
+function fast_product(S::FroidurePin, i::Integer, j::Integer)
+ return Int(LibSemigroups.fast_product(S.cxx_obj, UInt32(i - 1), UInt32(j - 1))) + 1
+end
+
+# ============================================================================
+# Idempotents
+# ============================================================================
+
+"""
+ number_of_idempotents(S::FroidurePin) -> Int
+
+Return the number of idempotent elements. Triggers full enumeration.
+"""
+number_of_idempotents(S::FroidurePin) = Int(LibSemigroups.number_of_idempotents(S.cxx_obj))
+
+"""
+ is_idempotent(S::FroidurePin, i::Integer) -> Bool
+
+Return `true` if the element at 1-based position `i` is idempotent.
+"""
+function is_idempotent(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return LibSemigroups.is_idempotent(S.cxx_obj, UInt32(i - 1))
+end
+
+"""
+ idempotents(S::FroidurePin{E}) -> Vector{E}
+
+Return a vector of all idempotent elements.
+"""
+function idempotents(S::FroidurePin{E}) where {E}
+ cxx_vec = LibSemigroups.idempotents_vector(S.cxx_obj)
+ return [_wrap_element(E, e) for e in cxx_vec]
+end
+
+# ============================================================================
+# Factorisation
+# ============================================================================
+
+"""
+ factorisation(S::FroidurePin, i::Integer) -> Vector{Int}
+
+Return the factorisation of the element at 1-based position `i` as a
+1-based word over generator indices. Triggers full enumeration.
+"""
+function factorisation(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _to_word_1based(LibSemigroups.factorisation(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ factorisation(S::FroidurePin{E}, x::E) -> Vector{Int}
+
+Return the factorisation of element `x` as a 1-based word over generator indices.
+"""
+function factorisation(S::FroidurePin{E}, x::E) where {E}
+ return _to_word_1based(LibSemigroups.factorisation_element(S.cxx_obj, x.cxx_obj))
+end
+
+"""
+ minimal_factorisation(S::FroidurePin, i::Integer) -> Vector{Int}
+
+Return the minimal (short-lex least) factorisation of the element at 1-based
+position `i` as a 1-based word over generator indices.
+"""
+function minimal_factorisation(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _to_word_1based(LibSemigroups.minimal_factorisation(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ minimal_factorisation(S::FroidurePin{E}, x::E) -> Vector{Int}
+
+Return the minimal factorisation of element `x` as a 1-based word.
+"""
+function minimal_factorisation(S::FroidurePin{E}, x::E) where {E}
+ return _to_word_1based(LibSemigroups.minimal_factorisation_element(S.cxx_obj, x.cxx_obj))
+end
+
+# ============================================================================
+# Word operations
+# ============================================================================
+
+"""
+ to_element(S::FroidurePin{E}, word::AbstractVector{<:Integer}) -> E
+
+Convert a 1-based word (vector of generator indices) to the corresponding element.
+"""
+function to_element(S::FroidurePin{E}, word::AbstractVector{<:Integer}) where {E}
+ cxx_word = _to_word_0based(word)
+ return _wrap_element(E, LibSemigroups.to_element(S.cxx_obj, cxx_word))
+end
+
+"""
+ equal_to(S::FroidurePin, w1, w2) -> Bool
+
+Return `true` if 1-based words `w1` and `w2` represent the same element.
+"""
+function equal_to(S::FroidurePin, w1::AbstractVector{<:Integer}, w2::AbstractVector{<:Integer})
+ return LibSemigroups.equal_to_words(S.cxx_obj, _to_word_0based(w1), _to_word_0based(w2))
+end
+
+# ============================================================================
+# Structure (prefix, suffix, first/final letter, word length)
+# ============================================================================
+
+"""
+ prefix(S::FroidurePin, i::Integer) -> Union{Int, Nothing}
+
+Return the 1-based position of the prefix of the element at position `i`,
+or `nothing` for generators (which have no proper prefix).
+"""
+function prefix(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _maybe_undefined(LibSemigroups.prefix(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ suffix(S::FroidurePin, i::Integer) -> Union{Int, Nothing}
+
+Return the 1-based position of the suffix of the element at position `i`,
+or `nothing` for generators.
+"""
+function suffix(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return _maybe_undefined(LibSemigroups.suffix(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ first_letter(S::FroidurePin, i::Integer) -> Int
+
+Return the 1-based index of the first generator in the factorisation
+of the element at position `i`.
+"""
+function first_letter(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return Int(LibSemigroups.first_letter(S.cxx_obj, UInt32(i - 1))) + 1
+end
+
+"""
+ final_letter(S::FroidurePin, i::Integer) -> Int
+
+Return the 1-based index of the last generator in the factorisation
+of the element at position `i`.
+"""
+function final_letter(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return Int(LibSemigroups.final_letter(S.cxx_obj, UInt32(i - 1))) + 1
+end
+
+"""
+ word_length(S::FroidurePin, i::Integer) -> Int
+
+Return the length of the factorisation of the element at 1-based position `i`.
+"""
+function word_length(S::FroidurePin, i::Integer)
+ (i < 1 || i > length(S)) && throw(BoundsError(S, i))
+ return Int(LibSemigroups.length(S.cxx_obj, UInt32(i - 1)))
+end
+
+"""
+ current_max_word_length(S::FroidurePin) -> Int
+
+Return the maximum word length among elements enumerated so far.
+"""
+current_max_word_length(S::FroidurePin) = Int(LibSemigroups.current_max_word_length(S.cxx_obj))
+
+# ============================================================================
+# Bulk element access
+# ============================================================================
+
+"""
+ elements(S::FroidurePin{E}) -> Vector{E}
+
+Return a vector of all elements. Triggers full enumeration.
+"""
+function elements(S::FroidurePin{E}) where {E}
+ return [_wrap_element(E, e) for e in LibSemigroups.elements_vector(S.cxx_obj)]
+end
+
+"""
+ sorted_elements(S::FroidurePin{E}) -> Vector{E}
+
+Return a vector of all elements in sorted order.
+"""
+function sorted_elements(S::FroidurePin{E}) where {E}
+ return [_wrap_element(E, e) for e in LibSemigroups.sorted_elements_vector(S.cxx_obj)]
+end
+
+# ============================================================================
+# Rules
+# ============================================================================
+
+"""
+ number_of_rules(S::FroidurePin) -> Int
+
+Return the total number of rules (relations) in the semigroup.
+Triggers full enumeration.
+"""
+number_of_rules(S::FroidurePin) = Int(LibSemigroups.number_of_rules(S.cxx_obj))
+
+"""
+ contains_one(S::FroidurePin) -> Bool
+
+Return `true` if the semigroup contains the identity element.
+Triggers full enumeration.
+"""
+contains_one(S::FroidurePin) = LibSemigroups.contains_one(S.cxx_obj)
+
+# ============================================================================
+# Cayley graphs
+# ============================================================================
+
+"""
+ right_cayley_graph(S::FroidurePin) -> WordGraph
+
+Return the right Cayley graph of the semigroup. Triggers full enumeration.
+"""
+right_cayley_graph(S::FroidurePin) = LibSemigroups.right_cayley_graph(S.cxx_obj)
+
+"""
+ left_cayley_graph(S::FroidurePin) -> WordGraph
+
+Return the left Cayley graph of the semigroup. Triggers full enumeration.
+"""
+left_cayley_graph(S::FroidurePin) = LibSemigroups.left_cayley_graph(S.cxx_obj)
+
+# ============================================================================
+# Settings
+# ============================================================================
+
+"""
+ batch_size(S::FroidurePin) -> Int
+
+Return the current batch size used during enumeration.
+"""
+batch_size(S::FroidurePin) = Int(LibSemigroups.batch_size(S.cxx_obj))
+
+"""
+ set_batch_size!(S::FroidurePin, n::Integer) -> FroidurePin
+
+Set the batch size for enumeration. Returns `S` for method chaining.
+"""
+function set_batch_size!(S::FroidurePin, n::Integer)
+ n < 1 && throw(ArgumentError("batch_size must be positive, got $n"))
+ LibSemigroups.set_batch_size!(S.cxx_obj, n)
+ return S
+end
+
+"""
+ reserve!(S::FroidurePin, n::Integer) -> FroidurePin
+
+Reserve capacity for `n` elements. Returns `S` for method chaining.
+"""
+function reserve!(S::FroidurePin, n::Integer)
+ n < 0 && throw(ArgumentError("reserve size must be non-negative, got $n"))
+ LibSemigroups.reserve!(S.cxx_obj, n)
+ return S
+end
+
+# ============================================================================
+# Mutating operations
+# ============================================================================
+
+"""
+ add_generator!(S::FroidurePin{E}, x::E) -> FroidurePin{E}
+
+Add a generator to the semigroup. Returns `S` for method chaining.
+The semigroup must not have been fully enumerated yet.
+"""
+function add_generator!(S::FroidurePin{E}, x::E) where {E}
+ LibSemigroups.add_generator!(S.cxx_obj, x.cxx_obj)
+ return S
+end
diff --git a/src/libsemigroups/runner.jl b/src/libsemigroups/runner.jl
new file mode 100644
index 0000000..731d565
--- /dev/null
+++ b/src/libsemigroups/runner.jl
@@ -0,0 +1,240 @@
+# 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.
+
+"""
+runner.jl - Julia wrappers for libsemigroups Runner base class
+
+This file provides low-level Julia wrappers for the C++ Runner class exposed
+via CxxWrap. Runner is an abstract base class providing algorithm execution
+control (run, run_for, timeout, stop, etc.) used by FroidurePinBase and other
+algorithm classes.
+"""
+
+# ============================================================================
+# Type aliases
+# ============================================================================
+
+"""
+ Runner
+
+Abstract base type for algorithm runners in libsemigroups. This type is not
+directly constructible; it is used as the base type for concrete algorithm
+classes such as `FroidurePinBase`.
+"""
+const Runner = LibSemigroups.Runner
+
+"""
+ RunnerState
+
+Enum type for the state of a [`Runner`](@ref). Possible values:
+
+- `STATE_NEVER_RUN` - the runner has never been run
+- `STATE_RUNNING_TO_FINISH` - running to completion
+- `STATE_RUNNING_FOR` - running for a bounded duration
+- `STATE_RUNNING_UNTIL` - running until a predicate is satisfied
+- `STATE_TIMED_OUT` - the last `run_for!` call timed out
+- `STATE_STOPPED_BY_PREDICATE` - the last `run_until` call was stopped
+- `STATE_NOT_RUNNING` - the runner is not currently running
+- `STATE_DEAD` - the runner was killed
+"""
+const RunnerState = LibSemigroups.state
+
+const STATE_NEVER_RUN = LibSemigroups.state_never_run
+const STATE_RUNNING_TO_FINISH = LibSemigroups.state_running_to_finish
+const STATE_RUNNING_FOR = LibSemigroups.state_running_for
+const STATE_RUNNING_UNTIL = LibSemigroups.state_running_until
+const STATE_TIMED_OUT = LibSemigroups.state_timed_out
+const STATE_STOPPED_BY_PREDICATE = LibSemigroups.state_stopped_by_predicate
+const STATE_NOT_RUNNING = LibSemigroups.state_not_running
+const STATE_DEAD = LibSemigroups.state_dead
+
+# ============================================================================
+# Core algorithm control
+# ============================================================================
+
+"""
+ run!(r::Runner)
+
+Run the algorithm to completion. This is a blocking call that will not return
+until the algorithm has finished, timed out, or been killed.
+"""
+run!(r::Runner) = LibSemigroups.run!(r)
+
+"""
+ run_for!(r::Runner, t::TimePeriod)
+
+Run the algorithm for at most the duration `t`. The algorithm may finish
+before the time limit, in which case [`finished`](@ref) will return `true`.
+If the time limit is reached, [`timed_out`](@ref) will return `true`.
+
+# Examples
+```julia
+run_for!(r, Second(1))
+run_for!(r, Millisecond(500))
+```
+"""
+function run_for!(r::Runner, t::TimePeriod)
+ ns = convert(Nanosecond, t)
+ Dates.value(ns) >= 0 ||
+ throw(ArgumentError("run_for! requires a non-negative duration, got $t"))
+ LibSemigroups.run_for!(r, Int64(Dates.value(ns)))
+end
+
+
+"""
+ run_until!(f::Function, r::Runner)
+ run_until!(r::Runner, f::Function)
+
+Run the algorithm until the nullary predicate `f` returns `true` or the
+algorithm [`finished`](@ref). Supports do-block syntax:
+
+```julia
+run_until!(r) do
+ some_condition(r)
+end
+```
+"""
+function run_until!(f::Function, r::Runner)
+ sf = @safe_cfunction($f, Cuchar, ())
+ GC.@preserve sf LibSemigroups.run_until!(r, sf)
+end
+run_until!(r::Runner, f::Function) = run_until!(f, r)
+
+"""
+ init!(r::Runner) -> Runner
+
+Re-initialize the runner to its default-constructed state, discarding all
+previously computed results.
+"""
+init!(r::Runner) = LibSemigroups.init!(r)
+
+# ============================================================================
+# State queries
+# ============================================================================
+
+"""
+ finished(r::Runner) -> Bool
+
+Return `true` if the algorithm has run to completion.
+"""
+finished(r::Runner) = LibSemigroups.finished(r)
+
+"""
+ Base.success(r::Runner) -> Bool
+
+Return `true` if the algorithm has completed successfully. This extends
+`Base.success` (which checks process exit status) to work with libsemigroups
+[`Runner`](@ref) types. By default, this returns the same value as
+[`finished`](@ref), but derived classes may override this to distinguish
+between completion and successful completion.
+"""
+Base.success(r::Runner) = LibSemigroups.success(r)
+
+"""
+ started(r::Runner) -> Bool
+
+Return `true` if [`run!`](@ref) has been called at least once.
+"""
+started(r::Runner) = LibSemigroups.started(r)
+
+"""
+ running(r::Runner) -> Bool
+
+Return `true` if the algorithm is currently executing.
+"""
+running(r::Runner) = LibSemigroups.running(r)
+
+"""
+ timed_out(r::Runner) -> Bool
+
+Return `true` if the last call to [`run_for!`](@ref) exhausted its time limit
+without the algorithm finishing.
+"""
+timed_out(r::Runner) = LibSemigroups.timed_out(r)
+
+"""
+ stopped(r::Runner) -> Bool
+
+Return `true` if the algorithm is stopped for any reason (finished, timed out,
+dead, or stopped by predicate).
+"""
+stopped(r::Runner) = LibSemigroups.stopped(r)
+
+"""
+ dead(r::Runner) -> Bool
+
+Return `true` if the runner was killed (e.g. from another thread via
+[`kill!`](@ref)).
+"""
+dead(r::Runner) = LibSemigroups.dead(r)
+
+"""
+ stopped_by_predicate(r::Runner) -> Bool
+
+Return `true` if the last `run_until` call was stopped because the predicate
+was satisfied.
+"""
+stopped_by_predicate(r::Runner) = LibSemigroups.stopped_by_predicate(r)
+
+"""
+ running_for(r::Runner) -> Bool
+
+Return `true` if the runner is currently executing a [`run_for!`](@ref) call.
+"""
+running_for(r::Runner) = LibSemigroups.running_for(r)
+
+"""
+ running_for_how_long(r::Runner) -> Nanosecond
+
+Return the duration of the most recent [`run_for!`](@ref) call as a
+`Dates.Nanosecond` period.
+"""
+running_for_how_long(r::Runner) = Nanosecond(LibSemigroups.running_for_how_long(r))
+
+"""
+ running_until(r::Runner) -> Bool
+
+Return `true` if the runner is currently executing a `run_until` call.
+"""
+running_until(r::Runner) = LibSemigroups.running_until(r)
+
+"""
+ current_state(r::Runner) -> RunnerState
+
+Return the current [`RunnerState`](@ref) of the runner.
+"""
+current_state(r::Runner) = LibSemigroups.current_state(r)
+
+# ============================================================================
+# Control
+# ============================================================================
+
+"""
+ kill!(r::Runner)
+
+Kill the runner. This is thread-safe and can be called from another thread
+to stop a running algorithm. After calling `kill!`, [`dead`](@ref) will return
+`true`.
+"""
+kill!(r::Runner) = LibSemigroups.kill!(r)
+
+# ============================================================================
+# Reporting
+# ============================================================================
+
+"""
+ report_why_we_stopped(r::Runner)
+
+Print the reason the algorithm stopped to `stderr`.
+"""
+report_why_we_stopped(r::Runner) = LibSemigroups.report_why_we_stopped(r)
+
+"""
+ string_why_we_stopped(r::Runner) -> String
+
+Return a human-readable string describing why the algorithm stopped.
+"""
+string_why_we_stopped(r::Runner) = LibSemigroups.string_why_we_stopped(r)
diff --git a/src/libsemigroups/word-graph.jl b/src/libsemigroups/word-graph.jl
new file mode 100644
index 0000000..f242567
--- /dev/null
+++ b/src/libsemigroups/word-graph.jl
@@ -0,0 +1,125 @@
+# 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.
+
+"""
+word-graph.jl - Julia wrappers for libsemigroups WordGraph
+
+This file provides low-level Julia wrappers for the C++ WordGraph
+class exposed via CxxWrap.
+
+Indices at this layer are **0-based** (matching C++). The high-level
+FroidurePin API will add 1-based conversion when exposing Cayley
+graphs to users.
+"""
+
+# ============================================================================
+# Type alias
+# ============================================================================
+
+"""
+ WordGraph
+
+A word graph (deterministic automaton without initial or accept states).
+Nodes are numbered `0` to `number_of_nodes(g) - 1` and every node has the
+same out-degree.
+
+Construct with `WordGraph(m, n)` for `m` nodes and out-degree `n`.
+"""
+const WordGraph = LibSemigroups.WordGraph
+
+# ============================================================================
+# Size / structure queries
+# ============================================================================
+
+"""
+ number_of_nodes(g::WordGraph) -> Int
+
+Return the number of nodes in the word graph.
+"""
+number_of_nodes(g::WordGraph) = Int(LibSemigroups.number_of_nodes(g))
+
+"""
+ out_degree(g::WordGraph) -> Int
+
+Return the out-degree (number of edge labels) of every node.
+"""
+out_degree(g::WordGraph) = Int(LibSemigroups.out_degree(g))
+
+"""
+ number_of_edges(g::WordGraph) -> Int
+
+Return the total number of defined edges in the word graph.
+"""
+number_of_edges(g::WordGraph) = Int(LibSemigroups.number_of_edges(g))
+
+"""
+ number_of_edges(g::WordGraph, s::Integer) -> Int
+
+Return the number of defined edges with source node `s` (0-based).
+"""
+number_of_edges(g::WordGraph, s::Integer) =
+ Int(LibSemigroups.number_of_edges_node(g, UInt32(s)))
+
+# ============================================================================
+# Edge lookup
+# ============================================================================
+
+"""
+ target(g::WordGraph, source::Integer, label::Integer) -> UInt32
+
+Return the target of the edge from `source` with `label` (both 0-based).
+Returns the C++ `UNDEFINED` value (as `UInt32`) if no such edge exists.
+"""
+target(g::WordGraph, source::Integer, label::Integer) =
+ LibSemigroups.target(g, UInt32(source), UInt32(label))
+
+"""
+ next_label_and_target(g::WordGraph, s::Integer, a::Integer)
+
+Return the next defined edge from node `s` with label >= `a` (both 0-based).
+Returns a `(label, target)` tuple; both are `UNDEFINED` if none found.
+"""
+function next_label_and_target(g::WordGraph, s::Integer, a::Integer)
+ s32, a32 = UInt32(s), UInt32(a)
+ return (LibSemigroups.next_label(g, s32, a32), LibSemigroups.next_target(g, s32, a32))
+end
+
+# ============================================================================
+# Iteration helpers
+# ============================================================================
+
+"""
+ targets(g::WordGraph, source::Integer) -> Vector{UInt32}
+
+Return all edge targets from `source` (0-based) as a vector. Includes
+`UNDEFINED` entries for labels with no defined edge.
+"""
+targets(g::WordGraph, source::Integer) = LibSemigroups.targets_vector(g, UInt32(source))
+
+# ============================================================================
+# Comparison operators
+# ============================================================================
+
+Base.:(==)(a::WordGraph, b::WordGraph) = LibSemigroups.is_equal(a, b)
+Base.:(<)(a::WordGraph, b::WordGraph) = LibSemigroups.is_less(a, b)
+
+# ============================================================================
+# Copy and hash
+# ============================================================================
+
+Base.copy(g::WordGraph) = LibSemigroups.copy(g)
+Base.hash(g::WordGraph, h::UInt) = hash(LibSemigroups.hash(g), h)
+
+# ============================================================================
+# Display
+# ============================================================================
+
+function Base.show(io::IO, g::WordGraph)
+ n = number_of_nodes(g)
+ e = number_of_edges(g)
+ d = out_degree(g)
+ print(io, "WordGraph($n, $d) with $e edges")
+end
diff --git a/test/runtests.jl b/test/runtests.jl
index 7bf6e01..21d3b4c 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -14,5 +14,8 @@ using Semigroups
@testset "Semigroups.jl" begin
include("test_constants.jl")
include("test_errors.jl")
+ include("test_runner.jl")
+ include("test_word_graph.jl")
+ include("test_froidure_pin.jl")
include("test_transf.jl")
end
diff --git a/test/test_froidure_pin.jl b/test/test_froidure_pin.jl
new file mode 100644
index 0000000..74e9efa
--- /dev/null
+++ b/test/test_froidure_pin.jl
@@ -0,0 +1,187 @@
+# 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.
+
+"""
+test_froidure_pin.jl - Tests for FroidurePin C++ bindings
+"""
+
+using CxxWrap.StdLib: StdVector
+
+@testset "FroidurePin type registration" begin
+ LS = Semigroups.LibSemigroups
+
+ # All 9 types exist
+ @test isdefined(LS, :FroidurePinTransf1)
+ @test isdefined(LS, :FroidurePinTransf2)
+ @test isdefined(LS, :FroidurePinTransf4)
+ @test isdefined(LS, :FroidurePinPPerm1)
+ @test isdefined(LS, :FroidurePinPPerm2)
+ @test isdefined(LS, :FroidurePinPPerm4)
+ @test isdefined(LS, :FroidurePinPerm1)
+ @test isdefined(LS, :FroidurePinPerm2)
+ @test isdefined(LS, :FroidurePinPerm4)
+
+ # All inherit from FroidurePinBase
+ @test LS.FroidurePinTransf1 <: FroidurePinBase
+ @test LS.FroidurePinTransf2 <: FroidurePinBase
+ @test LS.FroidurePinTransf4 <: FroidurePinBase
+ @test LS.FroidurePinPPerm1 <: FroidurePinBase
+ @test LS.FroidurePinPPerm2 <: FroidurePinBase
+ @test LS.FroidurePinPPerm4 <: FroidurePinBase
+ @test LS.FroidurePinPerm1 <: FroidurePinBase
+ @test LS.FroidurePinPerm2 <: FroidurePinBase
+ @test LS.FroidurePinPerm4 <: FroidurePinBase
+
+ # Transitive inheritance from Runner
+ @test LS.FroidurePinTransf1 <: Runner
+ @test LS.FroidurePinPPerm1 <: Runner
+ @test LS.FroidurePinPerm1 <: Runner
+end
+
+@testset "FroidurePin construction and enumeration (Transf)" begin
+ LS = Semigroups.LibSemigroups
+
+ # S3 = symmetric group on 3 letters (0-based images)
+ # swap(0,1): [1, 0, 2]
+ # cycle(0->1->2): [1, 2, 0]
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+
+ fp = LS.FroidurePinTransf1(t1, t2)
+
+ @test !LS.finished(fp)
+ LS.run!(fp)
+ @test LS.finished(fp)
+ @test LS.size(fp) == 6
+ @test LS.number_of_generators(fp) == 2
+ @test LS.degree(fp) == 3
+end
+
+@testset "FroidurePin element access" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+ LS.run!(fp)
+
+ # generator access (0-based)
+ g0 = LS.generator(fp, UInt32(0))
+ @test LS.is_equal(g0, t1)
+ g1 = LS.generator(fp, UInt32(1))
+ @test LS.is_equal(g1, t2)
+
+ # at access (0-based)
+ e0 = LS.at(fp, UInt32(0))
+ @test LS.is_equal(e0, t1)
+
+ # position_element round-trip
+ pos = LS.position_element(fp, t1)
+ @test pos == UInt32(0)
+
+ pos2 = LS.position_element(fp, t2)
+ @test pos2 == UInt32(1)
+
+ # contains_element
+ @test LS.contains_element(fp, t1)
+ @test LS.contains_element(fp, t2)
+end
+
+@testset "FroidurePin idempotents" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+
+ # S3 has exactly 1 idempotent (the identity)
+ @test LS.number_of_idempotents(fp) == 1
+end
+
+@testset "FroidurePin fast_product" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+ LS.run!(fp)
+
+ # fast_product(i, j) returns position of fp[i] * fp[j]
+ prod_pos = LS.fast_product(fp, UInt32(0), UInt32(1))
+ @test prod_pos isa UInt32
+ @test prod_pos < UInt32(LS.size(fp))
+end
+
+@testset "FroidurePin inherited FroidurePinBase methods" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+ LS.run!(fp)
+
+ # number_of_rules works via inheritance
+ @test LS.number_of_rules(fp) > 0
+
+ # Cayley graphs work via inheritance
+ rg = LS.right_cayley_graph(fp)
+ @test Semigroups.number_of_nodes(rg) == LS.size(fp)
+
+ lg = LS.left_cayley_graph(fp)
+ @test Semigroups.number_of_nodes(lg) == LS.size(fp)
+
+ # contains_one works via inheritance
+ @test LS.contains_one(fp)
+end
+
+@testset "FroidurePin with Perm" begin
+ LS = Semigroups.LibSemigroups
+
+ # S3 with Perm1
+ p1 = LS.Perm1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ p2 = LS.Perm1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinPerm1(p1, p2)
+
+ @test LS.size(fp) == 6
+ @test LS.number_of_generators(fp) == 2
+ @test LS.contains_one(fp)
+end
+
+@testset "FroidurePin with PPerm" begin
+ LS = Semigroups.LibSemigroups
+
+ # S3 as partial perms (total, so same as perms)
+ pp1 = LS.PPerm1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ pp2 = LS.PPerm1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinPPerm1(pp1, pp2)
+
+ @test LS.size(fp) == 6
+ @test LS.number_of_generators(fp) == 2
+end
+
+@testset "FroidurePin elements_vector" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+
+ elems = LS.elements_vector(fp)
+ @test length(elems) == 6
+end
+
+@testset "FroidurePin copy" begin
+ LS = Semigroups.LibSemigroups
+
+ t1 = LS.Transf1(StdVector{UInt8}(UInt8[1, 0, 2]))
+ t2 = LS.Transf1(StdVector{UInt8}(UInt8[1, 2, 0]))
+ fp = LS.FroidurePinTransf1(t1, t2)
+ LS.run!(fp)
+
+ fp2 = LS.copy(fp)
+ @test LS.size(fp2) == 6
+ @test LS.finished(fp2)
+end
diff --git a/test/test_runner.jl b/test/test_runner.jl
new file mode 100644
index 0000000..4eaedb2
--- /dev/null
+++ b/test/test_runner.jl
@@ -0,0 +1,73 @@
+# 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.
+
+"""
+test_runner.jl - Tests for Runner base class bindings
+
+Runner is abstract, so we cannot instantiate it directly. These tests verify
+that types, constants, and method definitions are correctly bound. Behavioral
+tests are exercised via concrete derived classes (e.g. FroidurePin) once they
+are available.
+"""
+
+using Dates: TimePeriod
+
+@testset "Runner type aliases" begin
+ @test Runner === Semigroups.LibSemigroups.Runner
+ @test RunnerState === Semigroups.LibSemigroups.state
+end
+
+@testset "RunnerState constants" begin
+ # All state constants are accessible and have the right type
+ states = [
+ STATE_NEVER_RUN,
+ STATE_RUNNING_TO_FINISH,
+ STATE_RUNNING_FOR,
+ STATE_RUNNING_UNTIL,
+ STATE_TIMED_OUT,
+ STATE_STOPPED_BY_PREDICATE,
+ STATE_NOT_RUNNING,
+ STATE_DEAD,
+ ]
+
+ for s in states
+ @test s isa RunnerState
+ end
+
+ # All state constants are distinct
+ for i in eachindex(states), j in eachindex(states)
+ if i != j
+ @test states[i] != states[j]
+ end
+ end
+end
+
+@testset "Runner method definitions" begin
+ # Verify that each wrapper function is defined with the correct
+ # signature dispatching on Runner
+ @test hasmethod(run!, Tuple{Runner})
+ @test hasmethod(run_for!, Tuple{Runner,TimePeriod})
+ @test hasmethod(run_until!, Tuple{Function,Runner})
+ @test hasmethod(run_until!, Tuple{Runner,Function})
+ @test hasmethod(init!, Tuple{Runner})
+ @test hasmethod(kill!, Tuple{Runner})
+
+ @test hasmethod(finished, Tuple{Runner})
+ @test hasmethod(success, Tuple{Runner})
+ @test hasmethod(started, Tuple{Runner})
+ @test hasmethod(running, Tuple{Runner})
+ @test hasmethod(timed_out, Tuple{Runner})
+ @test hasmethod(stopped, Tuple{Runner})
+ @test hasmethod(dead, Tuple{Runner})
+ @test hasmethod(stopped_by_predicate, Tuple{Runner})
+ @test hasmethod(running_for, Tuple{Runner})
+ @test hasmethod(running_for_how_long, Tuple{Runner})
+ @test hasmethod(running_until, Tuple{Runner})
+ @test hasmethod(current_state, Tuple{Runner})
+
+ @test hasmethod(report_why_we_stopped, Tuple{Runner})
+ @test hasmethod(string_why_we_stopped, Tuple{Runner})
+end
diff --git a/test/test_word_graph.jl b/test/test_word_graph.jl
new file mode 100644
index 0000000..88cf7a1
--- /dev/null
+++ b/test/test_word_graph.jl
@@ -0,0 +1,90 @@
+# 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.
+
+"""
+test_word_graph.jl - Tests for WordGraph bindings
+"""
+
+@testset "WordGraph type alias" begin
+ @test WordGraph === Semigroups.LibSemigroups.WordGraph
+end
+
+@testset "WordGraph construction" begin
+ g = WordGraph(5, 3)
+ @test number_of_nodes(g) == 5
+ @test out_degree(g) == 3
+ @test number_of_edges(g) == 0 # no edges defined yet
+
+ # Default: zero nodes, zero out-degree
+ g0 = WordGraph(0, 0)
+ @test number_of_nodes(g0) == 0
+ @test out_degree(g0) == 0
+ @test number_of_edges(g0) == 0
+end
+
+@testset "WordGraph number_of_edges per node" begin
+ g = WordGraph(3, 2)
+ # No edges defined, so each node has 0 defined edges
+ for i = 0:2
+ @test number_of_edges(g, i) == 0
+ end
+end
+
+@testset "WordGraph target on empty graph" begin
+ g = WordGraph(3, 2)
+ # All targets should be UNDEFINED
+ for node = 0:2, label = 0:1
+ t = Semigroups.target(g, node, label)
+ @test is_undefined(t, UInt32)
+ end
+end
+
+@testset "WordGraph targets vector" begin
+ g = WordGraph(3, 2)
+ # All targets from node 0 should be UNDEFINED
+ ts = Semigroups.targets(g, 0)
+ @test length(ts) == 2 # out_degree is 2
+ for t in ts
+ @test is_undefined(t, UInt32)
+ end
+end
+
+@testset "WordGraph comparison" begin
+ g1 = WordGraph(3, 2)
+ g2 = WordGraph(3, 2)
+ @test g1 == g2
+
+ g3 = WordGraph(4, 2)
+ @test g1 != g3
+end
+
+@testset "WordGraph copy" begin
+ g1 = WordGraph(3, 2)
+ g2 = copy(g1)
+ @test g1 == g2
+end
+
+@testset "WordGraph hash" begin
+ g1 = WordGraph(3, 2)
+ g2 = WordGraph(3, 2)
+ @test hash(g1) == hash(g2)
+
+ # Can be used in Sets
+ s = Set([g1])
+ @test g2 in s
+end
+
+@testset "WordGraph method signatures" begin
+ @test hasmethod(number_of_nodes, Tuple{WordGraph})
+ @test hasmethod(out_degree, Tuple{WordGraph})
+ @test hasmethod(number_of_edges, Tuple{WordGraph})
+ @test hasmethod(number_of_edges, Tuple{WordGraph,Integer})
+ @test hasmethod(Semigroups.target, Tuple{WordGraph,Integer,Integer})
+ @test hasmethod(Semigroups.next_label_and_target, Tuple{WordGraph,Integer,Integer})
+ @test hasmethod(Semigroups.targets, Tuple{WordGraph,Integer})
+ @test hasmethod(copy, Tuple{WordGraph})
+ @test hasmethod(hash, Tuple{WordGraph,UInt})
+end