diff --git a/deps/src/CMakeLists.txt b/deps/src/CMakeLists.txt
index 4f33dda..791fcb6 100644
--- a/deps/src/CMakeLists.txt
+++ b/deps/src/CMakeLists.txt
@@ -60,6 +60,7 @@ add_library(libsemigroups_julia SHARED
presentation-examples.cpp
knuth-bendix.cpp
todd-coxeter.cpp
+ kambites.cpp
)
# Include directories
diff --git a/deps/src/kambites.cpp b/deps/src/kambites.cpp
new file mode 100644
index 0000000..289e78b
--- /dev/null
+++ b/deps/src/kambites.cpp
@@ -0,0 +1,183 @@
+//
+// 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 .
+//
+
+// CRITICAL: libsemigroups_julia.hpp MUST be included first (fmt consteval fix)
+#include "libsemigroups_julia.hpp"
+
+#include
+
+// kambites-class.hpp and kambites-helpers.hpp MUST come BEFORE cong-common.hpp
+// so the template bodies in cong-common.hpp see Kambites-specific overloads of
+// congruence_common helpers. ADL resolves these at template-instantiation time;
+// see the include-order requirement documented at cong-common.hpp:27-38.
+#include
+#include
+
+// Required for `congruence_common::normal_forms(Kambites&)` (used by the
+// `kambites_normal_forms_take` binding below). The returned
+// `KambitesNormalFormRange::init` calls `libsemigroups::to(k)`,
+// whose template definition lives in to-froidure-pin.tpp (transitively
+// included by to-froidure-pin.hpp). Without this header, the binding compiles
+// but fails to link with an undefined `libsemigroups::to`
+// symbol.
+#include
+
+#include "cong-common.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace jlcxx {
+ template <>
+ struct IsMirroredType>
+ : std::false_type {};
+
+ template <>
+ struct SuperType> {
+ using type = libsemigroups::detail::CongruenceCommon;
+ };
+} // namespace jlcxx
+
+namespace libsemigroups_julia {
+
+ void define_kambites(jl::Module& m) {
+ using libsemigroups::congruence_kind;
+ using libsemigroups::Presentation;
+ using libsemigroups::word_type;
+
+ using CongruenceCommon = libsemigroups::detail::CongruenceCommon;
+ using K = libsemigroups::Kambites;
+
+ // Type registration
+ auto type = m.add_type("KambitesWord",
+ jlcxx::julia_base_type());
+
+ // Constructors. Direct registration (no defensive lambda); CxxWrap
+ // converts C++ exceptions through the std::function path for direct
+ // constructor bindings.
+ type.constructor<>();
+ type.constructor const&>();
+ type.constructor(); // copy ctor
+
+ // init! overloads (mirror constructors)
+ type.method("init!", [](K& self) -> K& { return self.init(); });
+ type.method("init!",
+ [](K& self,
+ congruence_kind knd,
+ Presentation const& p) -> K& {
+ return self.init(knd, p);
+ });
+
+ // presentation - return by copy (storage may relocate)
+ type.method("presentation", [](K const& self) -> Presentation {
+ return self.presentation();
+ });
+
+ // generating_pairs - return by copy
+ type.method("generating_pairs",
+ [](K const& self) -> std::vector {
+ auto const& pairs = self.generating_pairs();
+ return std::vector(pairs.begin(), pairs.end());
+ });
+
+ // kind / number_of_generating_pairs (inherited; exposed for parity with TC)
+ type.method("kind",
+ [](K const& self) -> congruence_kind { return self.kind(); });
+
+ type.method("number_of_generating_pairs", [](K const& self) -> size_t {
+ return self.number_of_generating_pairs();
+ });
+
+ // number_of_classes
+ type.method("number_of_classes",
+ [](K& self) -> uint64_t { return self.number_of_classes(); });
+
+ // Const-overload split (kambites-class.hpp:731-744): the two
+ // small_overlap_class() overloads differ only on receiver const-ness, which
+ // CxxWrap cannot dispatch. Split into two distinctly-named Julia methods.
+ //
+ // small_overlap_class -- mutable variant (calls run, returns the class).
+ type.method("small_overlap_class",
+ [](K& self) -> size_t { return self.small_overlap_class(); });
+
+ // current_small_overlap_class -- const variant (returns UNDEFINED if
+ // unknown). Receiver-by-const-ref selects the const overload.
+ type.method("current_small_overlap_class", [](K const& self) -> size_t {
+ return self.small_overlap_class();
+ });
+
+ // throw_if_not_C4 -- bind only the mutable overload, const deferred
+ type.method("throw_if_not_C4", [](K& self) { self.throw_if_not_C4(); });
+
+ // TODO: ukkonen() is intentionally NOT bound: its return type is the
+ // Ukkonen suffix-tree class, which is currently not bound
+
+ // throw_if_letter_not_in_alphabet -- accept ArrayRef, build a
+ // word_type inside the lambda (mirrors todd-coxeter.cpp:256-260).
+ type.method("throw_if_letter_not_in_alphabet",
+ [](K const& self, jlcxx::ArrayRef w) {
+ word_type ww(w.begin(), w.end());
+ self.throw_if_letter_not_in_alphabet(ww.begin(), ww.end());
+ });
+
+ // Display
+ type.method("to_human_readable_repr", [](K const& self) -> std::string {
+ return libsemigroups::to_human_readable_repr(self);
+ });
+
+ // Cong common helper subset - DO NOT call aggregator
+ define_cong_common_word_helpers(m);
+
+ // Defense: register a throwing `cong_common_normal_forms` for
+ // Kambites so the abstract-supertype dispatch path in
+ // `src/cong-common.jl::normal_forms(::CongruenceCommon)` raises a clear
+ // LibsemigroupsError if it is ever reached on a Kambites value (rather
+ // than CxxWrap's opaque method-not-found error). The user-facing
+ // `normal_forms(::Kambites)` override in `src/kambites.jl` already
+ // throws ArgumentError for direct calls; this guards the indirect path.
+ m.method("cong_common_normal_forms", [](K&) -> std::vector {
+ throw libsemigroups::LibsemigroupsException(
+ __FILE__,
+ __LINE__,
+ __func__,
+ "Kambites has infinitely many normal forms; use "
+ "kambites_normal_forms_take (or normal_forms(k, n) on "
+ "the Julia side) to materialize a finite prefix.");
+ });
+
+ // Bounded normal_forms binding (Kambites-specific). Mirrors the cong-common
+ // normal_forms template but caps iteration at n elements so callers can
+ // safely take a finite prefix of the infinite normal-form range.
+ m.method("kambites_normal_forms_take",
+ [](K& self, size_t n) -> std::vector {
+ std::vector result;
+ result.reserve(n);
+ auto range
+ = libsemigroups::congruence_common::normal_forms(self);
+ for (size_t i = 0; i < n && !range.at_end(); ++i) {
+ result.push_back(range.get());
+ range.next();
+ }
+ return result;
+ });
+ }
+
+} // namespace libsemigroups_julia
diff --git a/deps/src/libsemigroups_julia.cpp b/deps/src/libsemigroups_julia.cpp
index 94b2d52..e17d477 100644
--- a/deps/src/libsemigroups_julia.cpp
+++ b/deps/src/libsemigroups_julia.cpp
@@ -55,6 +55,7 @@ namespace libsemigroups_julia {
define_presentation_examples(mod);
define_knuth_bendix(mod);
define_todd_coxeter(mod);
+ define_kambites(mod);
}
} // namespace libsemigroups_julia
diff --git a/deps/src/libsemigroups_julia.hpp b/deps/src/libsemigroups_julia.hpp
index 85b92e1..522f7ec 100644
--- a/deps/src/libsemigroups_julia.hpp
+++ b/deps/src/libsemigroups_julia.hpp
@@ -70,6 +70,7 @@ namespace libsemigroups_julia {
void define_presentation_examples(jl::Module& mod);
void define_knuth_bendix(jl::Module& mod);
void define_todd_coxeter(jl::Module& mod);
+ void define_kambites(jl::Module& mod);
} // namespace libsemigroups_julia
diff --git a/docs/make.jl b/docs/make.jl
index 04fb924..6226c81 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -78,6 +78,10 @@ makedocs(;
"The KnuthBendix type" => "main-algorithms/knuth-bendix/knuth-bendix.md",
"Helper functions" => "main-algorithms/knuth-bendix/helpers.md",
],
+ "Kambites" => [
+ "Overview" => "main-algorithms/kambites/index.md",
+ "The Kambites type" => "main-algorithms/kambites/kambites.md",
+ ],
],
],
warnonly = [:missing_docs, :linkcheck, :cross_references],
diff --git a/docs/src/data-structures/constants/index.md b/docs/src/data-structures/constants/index.md
index 31cdce2..9188591 100644
--- a/docs/src/data-structures/constants/index.md
+++ b/docs/src/data-structures/constants/index.md
@@ -29,3 +29,11 @@ Semigroups.tril_FALSE
Semigroups.tril_unknown
Semigroups.tril_to_bool
```
+
+## Congruence Kind
+
+```@docs
+Semigroups.congruence_kind
+Semigroups.onesided
+Semigroups.twosided
+```
diff --git a/docs/src/main-algorithms/kambites/index.md b/docs/src/main-algorithms/kambites/index.md
new file mode 100644
index 0000000..3d4fefd
--- /dev/null
+++ b/docs/src/main-algorithms/kambites/index.md
@@ -0,0 +1,20 @@
+# Kambites
+
+This section links to the documentation for the algorithms in
+Semigroups.jl for small overlap monoids by Mark Kambites and the authors
+of libsemigroups.
+
+| Page | Description |
+| ---- | ----------- |
+| [The Kambites type](kambites.md) | The [`Kambites`](@ref Semigroups.Kambites) type: construction, queries, the small-overlap-class accessors, validators, and bounded normal forms. |
+
+Helper functions for [`Kambites`](@ref Semigroups.Kambites) are
+documented on the [Common congruence helpers](../cong-common-helpers.md)
+page. There are currently no helper functions specific to `Kambites`
+beyond those that apply to every
+[`CongruenceCommon`](@ref Semigroups.CongruenceCommon) subtype.
+
+!!! warning "v1 limitation"
+ Semigroups.jl v1 binds `Kambites{word_type}` only. String-alphabet
+ presentations are deferred to a later release. Letter indices are
+ 1-based `Int` values throughout the Julia API.
diff --git a/docs/src/main-algorithms/kambites/kambites.md b/docs/src/main-algorithms/kambites/kambites.md
new file mode 100644
index 0000000..77de927
--- /dev/null
+++ b/docs/src/main-algorithms/kambites/kambites.md
@@ -0,0 +1,154 @@
+# The Kambites type
+
+This page documents the [`Kambites`](@ref Semigroups.Kambites) type,
+which implements Kambites's algorithm for the word problem in small
+overlap monoids -- finitely presented monoids whose presentation
+satisfies the small overlap condition `C(n)` for some `n >= 4`.
+
+`Kambites` is a subtype of
+[`CongruenceCommon`](@ref Semigroups.CongruenceCommon) (and hence of
+[`Runner`](@ref Semigroups.Runner)), so all runner methods
+([`run!`](@ref), [`run_for!`](@ref), [`finished`](@ref), etc.) and the
+shared word-operation helpers ([`reduce`](@ref Semigroups.reduce(::CongruenceCommon, ::AbstractVector{<:Integer})),
+[`contains`](@ref Semigroups.contains(::CongruenceCommon, ::AbstractVector{<:Integer}, ::AbstractVector{<:Integer})),
+[`currently_contains`](@ref Semigroups.currently_contains(::CongruenceCommon, ::AbstractVector{<:Integer}, ::AbstractVector{<:Integer})),
+[`add_generating_pair!`](@ref Semigroups.add_generating_pair!(::CongruenceCommon, ::AbstractVector{<:Integer}, ::AbstractVector{<:Integer})),
+[`partition`](@ref Semigroups.partition(::CongruenceCommon, ::AbstractVector{<:AbstractVector{<:Integer}})))
+are available.
+
+## Table of contents
+
+| Section | Description |
+| ------- | ----------- |
+| [Construction and re-initialization](#Construction-and-re-initialization) | Constructors and `init!`. |
+| [Queries](#Queries) | Class count and number of generating pairs. |
+| [Presentation and generating pairs](#Presentation-and-generating-pairs) | Access the underlying presentation and extra generating pairs. |
+| [Small overlap class](#Small-overlap-class) | Compute or read the cached small overlap class `C(n)` of the underlying presentation. |
+| [Validators](#Validators) | Throw on invalid letters or insufficient small overlap class. |
+| [Normal forms](#Normal-forms) | Bounded enumeration of normal forms (the unbounded form throws). |
+| [Non-trivial classes (always throws)](#Non-trivial-classes-always-throws) | Why `non_trivial_classes(::Kambites, ::Kambites)` is intentionally not provided. |
+| [Display and copy](#Display-and-copy) | `show`, `copy`. |
+
+```@docs
+Semigroups.Kambites
+```
+
+## Construction and re-initialization
+
+| Function | Description |
+| -------- | ----------- |
+| `Kambites()` | Construct a default `Kambites`; throws on subsequent use until reinitialized via [`init!`](@ref). |
+| `Kambites(kind, p)` | Construct from a [`congruence_kind`](@ref Semigroups.congruence_kind) (must be [`twosided`](@ref Semigroups.twosided)) and a [`Presentation`](@ref Semigroups.Presentation). |
+| `Kambites(other)` | Copy an existing `Kambites`. |
+| [`init!(k)`](@ref Semigroups.init!(::Kambites)) | Reset to default-constructed state, or reinitialize from a new kind and presentation. |
+
+```@docs
+Semigroups.init!(::Kambites)
+```
+
+## Queries
+
+| Function | Description |
+| -------- | ----------- |
+| [`number_of_classes(k)`](@ref Semigroups.number_of_classes(::Kambites)) | Number of congruence classes; returns [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) when `small_overlap_class(k) >= 4`. |
+| [`number_of_generating_pairs(k)`](@ref Semigroups.number_of_generating_pairs(::Kambites)) | Number of extra generating pairs. |
+
+```@docs
+Semigroups.number_of_classes(::Kambites)
+Semigroups.number_of_generating_pairs(::Kambites)
+```
+
+## Presentation and generating pairs
+
+| Function | Description |
+| -------- | ----------- |
+| [`kind(k)`](@ref Semigroups.kind(::Kambites)) | Congruence kind (always `twosided`). |
+| [`presentation(k)`](@ref Semigroups.presentation(::Kambites)) | Copy of the underlying presentation. |
+| [`generating_pairs(k)`](@ref Semigroups.generating_pairs(::Kambites)) | Extra generating pairs as 1-based word-tuple pairs. |
+
+```@docs
+Semigroups.kind(::Kambites)
+Semigroups.presentation(::Kambites)
+Semigroups.generating_pairs(::Kambites)
+```
+
+## Small overlap class
+
+The small overlap class of the underlying presentation is the largest
+`n` such that the presentation satisfies the condition `C(n)`. Kambites's
+algorithm decides the word problem when this class is at least `4`.
+
+| Function | Description |
+| -------- | ----------- |
+| [`small_overlap_class(k)`](@ref Semigroups.small_overlap_class(::Kambites)) | Compute the small overlap class (may trigger work). |
+| [`current_small_overlap_class(k)`](@ref Semigroups.current_small_overlap_class(::Kambites)) | Return the cached value, or [`UNDEFINED`](@ref Semigroups.UNDEFINED) if not yet computed. |
+
+```@docs
+Semigroups.small_overlap_class(::Kambites)
+Semigroups.current_small_overlap_class(::Kambites)
+```
+
+## Validators
+
+| Function | Description |
+| -------- | ----------- |
+| [`throw_if_not_C4(k)`](@ref Semigroups.throw_if_not_C4(::Kambites)) | Throw if the small overlap class is less than `4`. |
+| [`throw_if_letter_not_in_alphabet(k, w)`](@ref Semigroups.throw_if_letter_not_in_alphabet(::Kambites, ::AbstractVector{<:Integer})) | Throw if `w` contains any letter that is not in the alphabet of `k`'s presentation. |
+
+```@docs
+Semigroups.throw_if_not_C4(::Kambites)
+Semigroups.throw_if_letter_not_in_alphabet(::Kambites, ::AbstractVector{<:Integer})
+```
+
+## Normal forms
+
+For `Kambites`, the set of normal forms is infinite, so only the
+bounded form `normal_forms(k, n)` is provided. The no-argument form
+[`normal_forms(k)`](@ref Semigroups.normal_forms(::Kambites)) throws an
+`ArgumentError` to prevent accidental infinite enumeration.
+
+| Function | Description |
+| -------- | ----------- |
+| [`normal_forms(k, n)`](@ref Semigroups.normal_forms(::Kambites, ::Integer)) | Return the first `n` normal forms as 1-based `Vector{Int}` words. |
+| [`normal_forms(k)`](@ref Semigroups.normal_forms(::Kambites)) | Always throws `ArgumentError`. |
+
+```@docs
+Semigroups.normal_forms(::Kambites, ::Integer)
+Semigroups.normal_forms(::Kambites)
+```
+
+## Non-trivial classes (always throws)
+
+```@docs
+Semigroups.non_trivial_classes(::Kambites, ::Kambites)
+```
+
+## Word operations
+
+These functions are defined on
+[`CongruenceCommon`](@ref Semigroups.CongruenceCommon) and work on all
+congruence types, including `Kambites`. Words are given and returned
+as 1-based `Vector{Int}` letter indices. See the
+[Common congruence helpers](../cong-common-helpers.md) page for the
+full API.
+
+!!! note
+ `Semigroups.reduce` and `Semigroups.contains` are not exported to
+ avoid shadowing `Base.reduce` and `Base.contains`. Use the
+ module-qualified form: `Semigroups.reduce(k, w)`,
+ `Semigroups.contains(k, u, v)`.
+
+| Function | Description |
+| -------- | ----------- |
+| `Semigroups.reduce(k, w)` | Reduce a word to normal form (triggers a full run). |
+| `Semigroups.contains(k, u, v)` | Test if two words are congruent (triggers a full run). |
+| `currently_contains(k, u, v)` | Test containment using current state; returns [`tril`](@ref Semigroups.tril). |
+| `add_generating_pair!(k, u, v)` | Add an extra generating pair. |
+| `partition(k, ws)` | Partition a list of words into congruence classes. |
+
+## Display and copy
+
+```@docs
+Base.show(::IO, ::Kambites)
+Base.copy(::Kambites)
+```
diff --git a/src/Semigroups.jl b/src/Semigroups.jl
index 9d143e7..1d79ada 100644
--- a/src/Semigroups.jl
+++ b/src/Semigroups.jl
@@ -83,6 +83,7 @@ include("presentation-examples.jl")
include("cong-common.jl")
include("knuth-bendix.jl")
include("todd-coxeter.jl")
+include("kambites.jl")
# High-level element types
include("bmat8.jl")
@@ -375,6 +376,10 @@ export standardize!, is_standardized, current_word_graph, word_graph
export current_index_of, word_of, current_word_of
export is_non_trivial, tc_redundant_rule
+# Kambites
+export Kambites
+export small_overlap_class, current_small_overlap_class, throw_if_not_C4
+
# Transformation types and functions
export Transf, PPerm, Perm
export degree, rank, image, domain, inverse
diff --git a/src/kambites.jl b/src/kambites.jl
new file mode 100644
index 0000000..1d5e794
--- /dev/null
+++ b/src/kambites.jl
@@ -0,0 +1,449 @@
+# Copyright (c) 2026, James W. Swent
+#
+# Distributed under the terms of the GPL license version 3.
+
+"""
+kambites.jl - Kambites wrapper (Layer 2 + 3)
+"""
+
+# ============================================================================
+# Type alias
+# ============================================================================
+
+"""
+ Kambites
+
+Type implementing small overlap class, equality, and normal forms for
+small overlap monoids -- finitely presented monoids whose presentation
+satisfies the small overlap condition `C(n)` for `n >= 4` (Kambites,
+2009).
+
+A [`Kambites`](@ref Semigroups.Kambites) instance represents a
+congruence on the free monoid or semigroup containing the rules of the
+[`Presentation`](@ref Semigroups.Presentation) used to construct the
+instance, together with the
+[`generating_pairs`](@ref Semigroups.generating_pairs) added via
+[`add_generating_pair!`](@ref Semigroups.add_generating_pair!). As such,
+generating pairs and presentation rules are interchangeable in the
+context of `Kambites` objects.
+
+A `Kambites` object is constructed from a
+[`congruence_kind`](@ref Semigroups.congruence_kind) (which must be
+[`twosided`](@ref Semigroups.twosided)) and a
+[`Presentation`](@ref Semigroups.Presentation), or copied from another
+`Kambites`.
+
+`Kambites` is a subtype of
+[`CongruenceCommon`](@ref Semigroups.CongruenceCommon) (and hence of
+[`Runner`](@ref Semigroups.Runner)), so all runner methods (`run!`,
+`run_for!`, `finished`, `timed_out`, etc.) and most common congruence
+helpers ([`reduce`](@ref Semigroups.reduce),
+[`contains`](@ref Semigroups.contains),
+[`currently_contains`](@ref Semigroups.currently_contains),
+[`add_generating_pair!`](@ref Semigroups.add_generating_pair!),
+[`partition`](@ref Semigroups.partition)) work on `Kambites` objects.
+
+# Structural deviations from `KnuthBendix` / `ToddCoxeter` precedent
+
+- `Base.length` is intentionally **not** defined for `Kambites`. The
+ number of classes is always
+ [`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) (or throws),
+ so an alias would silently misbehave with `for i in 1:length(k)`.
+ Use [`number_of_classes`](@ref Semigroups.number_of_classes)
+ explicitly.
+- [`non_trivial_classes`](@ref Semigroups.non_trivial_classes)`(k1::Kambites, k2::Kambites)`
+ throws `ArgumentError` (upstream rationale: both `Kambites` instances
+ always represent infinite-class congruences, so the construction does
+ not generalize; cf. `kambites-helpers.hpp:128-133`).
+- [`normal_forms`](@ref Semigroups.normal_forms)`(k::Kambites)` (no-arg)
+ throws `ArgumentError` because the underlying normal-form range is
+ infinite. Use the bounded form `normal_forms(k, n)` to materialize
+ the first `n` normal forms.
+- The `ukkonen()` accessor (and the `Ukkonen` type) is deferred to a
+ later release
+
+# Constructors
+
+ Kambites() -> Kambites
+ Kambites(kind::congruence_kind, p::Presentation) -> Kambites
+ Kambites(k::Kambites) -> Kambites
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if `kind`
+ is not [`twosided`](@ref Semigroups.twosided).
+
+!!! warning "v1 limitation"
+ v1 of Semigroups.jl binds `Kambites{word_type}` only. String-alphabet
+ presentations are deferred to later versions.
+"""
+const Kambites = LibSemigroups.KambitesWord
+
+# ============================================================================
+# Initialization
+# ============================================================================
+
+"""
+ Kambites(kind::congruence_kind, p::Presentation) -> Kambites
+
+Construct a [`Kambites`](@ref Semigroups.Kambites) instance representing
+a congruence of kind `kind` over the semigroup or monoid defined by the
+presentation `p`.
+
+`Kambites` instances can only be used to compute two-sided congruences,
+so `kind` must always be [`twosided`](@ref Semigroups.twosided). The
+parameter is included for uniformity of interface with
+[`KnuthBendix`](@ref Semigroups.KnuthBendix), `ToddCoxeter`, and
+`Congruence`.
+
+This Julia wrapper builds a default `Kambites` and then calls
+[`init!`](@ref Semigroups.init!) so that exceptions raised by
+libsemigroups surface as
+[`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) (the direct
+CxxWrap-bound constructor would surface them as `Base.ErrorException`).
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if `p` is
+ not valid.
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if `kind`
+ is not [`twosided`](@ref Semigroups.twosided).
+"""
+function Kambites(kind::congruence_kind, p::Presentation)
+ k = LibSemigroups.KambitesWord()
+ init!(k, kind, p)
+ return k
+end
+
+"""
+ init!(k::Kambites) -> Kambites
+ init!(k::Kambites, kind::congruence_kind, p::Presentation) -> Kambites
+
+Re-initialize `k` so that it is in the state it would have been in
+immediately after the corresponding constructor.
+
+The one-argument form puts `k` back into the same state as a newly
+default-constructed [`Kambites`](@ref Semigroups.Kambites).
+
+The three-argument form puts `k` back into the state it would have been
+in if it had just been newly constructed from `kind` and `p`.
+`Kambites` instances can only be used to compute two-sided congruences,
+so `kind` must always be [`twosided`](@ref Semigroups.twosided); the
+parameter is included for uniformity of interface with
+[`KnuthBendix`](@ref Semigroups.KnuthBendix), `ToddCoxeter`, and
+`Congruence`.
+
+Returns `k` for chaining.
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if `p` is
+ not valid (three-argument form only).
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if `kind`
+ is not [`twosided`](@ref Semigroups.twosided) (three-argument form
+ only).
+"""
+function init!(k::Kambites)
+ @wrap_libsemigroups_call LibSemigroups.init!(k)
+ return k
+end
+
+function init!(k::Kambites, kind::congruence_kind, p::Presentation)
+ @wrap_libsemigroups_call LibSemigroups.init!(k, kind, p)
+ return k
+end
+
+# ============================================================================
+# Accessors
+# ============================================================================
+
+"""
+ presentation(k::Kambites) -> Presentation
+
+Return a copy of the [`Presentation`](@ref Semigroups.Presentation) used
+to construct or initialize `k` (if any).
+
+If `k` was constructed or initialized using a presentation, that
+presentation is returned. The Julia binding returns by value (an
+independent copy) rather than by reference; mutating the returned
+object does not affect `k`.
+"""
+presentation(k::Kambites) = LibSemigroups.presentation(k)
+
+"""
+ generating_pairs(k::Kambites) -> Vector{Tuple{Vector{Int}, Vector{Int}}}
+
+Return the generating pairs of `k` as a vector of 1-based word pairs.
+
+These are the pairs added via
+[`add_generating_pair!`](@ref Semigroups.add_generating_pair!). Each pair
+`(u, v)` is returned as a 2-tuple of 1-based `Vector{Int}` letter
+indices. The length of the returned vector equals
+[`number_of_generating_pairs`](@ref Semigroups.number_of_generating_pairs).
+"""
+function generating_pairs(k::Kambites)
+ flat = LibSemigroups.generating_pairs(k)
+ result = Tuple{Vector{Int},Vector{Int}}[]
+ for i = 1:2:length(flat)
+ push!(result, (_word_from_cpp(flat[i]), _word_from_cpp(flat[i+1])))
+ end
+ return result
+end
+
+"""
+ kind(k::Kambites) -> congruence_kind
+
+Return the kind of congruence (one- or two-sided) represented by `k`;
+see [`congruence_kind`](@ref Semigroups.congruence_kind) for details.
+For [`Kambites`](@ref Semigroups.Kambites) this is always
+[`twosided`](@ref Semigroups.twosided), since the constructor enforces
+that constraint.
+
+Complexity: constant.
+"""
+kind(k::Kambites) = LibSemigroups.kind(k)
+
+"""
+ number_of_generating_pairs(k::Kambites) -> Int
+
+Return the number of generating pairs added to `k`. Equals the length
+of [`generating_pairs`](@ref Semigroups.generating_pairs)`(k)`.
+
+Complexity: constant.
+"""
+number_of_generating_pairs(k::Kambites) = Int(LibSemigroups.number_of_generating_pairs(k))
+
+"""
+ number_of_classes(k::Kambites) -> UInt64
+
+Compute the number of congruence classes of `k`.
+
+`Kambites` instances can only compute the number of classes when
+[`small_overlap_class`](@ref Semigroups.small_overlap_class) is at
+least 4, and in that case the number is always
+[`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY); otherwise an
+exception is thrown. Use
+[`is_positive_infinity`](@ref Semigroups.is_positive_infinity) (or
+direct comparison `result == POSITIVE_INFINITY`) to detect the infinite
+case.
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if it is
+ not possible to compute the number of classes because the small
+ overlap class is too small (`small_overlap_class(k) < 4`).
+"""
+number_of_classes(k::Kambites) = @wrap_libsemigroups_call LibSemigroups.number_of_classes(k)
+
+# ============================================================================
+# small_overlap_class (const-overload split)
+# ============================================================================
+
+"""
+ small_overlap_class(k::Kambites) -> UInt64
+
+Get the small overlap class of the [`Presentation`](@ref
+Semigroups.Presentation) underlying `k`.
+
+If ``S`` is a finitely presented semigroup with generating set ``A``,
+then a word ``w`` over ``A`` is a *piece* if ``w`` occurs as a factor
+in at least two of the relations defining ``S``, or if it occurs as a
+factor of one relation in two different positions (possibly
+overlapping). A finitely presented semigroup ``S`` satisfies the
+condition ``C(n)`` for a positive integer ``n`` if the minimum number
+of pieces in any factorisation of a word occurring as the left or
+right hand side of a relation is at least ``n``.
+
+This function returns the greatest positive integer `n` such that the
+finitely presented semigroup represented by `k` satisfies the
+condition ``C(n)``, or
+[`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) if no word
+occurring in a relation can be written as a product of pieces. It may
+trigger computation.
+
+The return type is `UInt64` (rather than `Int`) because
+[`POSITIVE_INFINITY`](@ref Semigroups.POSITIVE_INFINITY) is encoded as
+`typemax(UInt64) - 1` on the wire and would not round-trip through
+`Int`. Use [`is_positive_infinity`](@ref Semigroups.is_positive_infinity)
+(or direct comparison `result == POSITIVE_INFINITY`) to detect the
+infinite case.
+
+Complexity: ``O(m^3)``, where ``m`` is the sum of the lengths of the
+words occurring in the relations of the semigroup.
+
+!!! warning
+ [`Semigroups.contains`](@ref Semigroups.contains) and
+ [`Semigroups.reduce`](@ref Semigroups.reduce) only work if the
+ return value of this function is at least 4.
+
+# See also
+
+[`current_small_overlap_class`](@ref Semigroups.current_small_overlap_class)
+"""
+small_overlap_class(k::Kambites) = LibSemigroups.small_overlap_class(k)
+
+"""
+ current_small_overlap_class(k::Kambites) -> Union{UInt64, UndefinedType}
+
+Get the current value of the small overlap class of `k`, if known.
+
+Returns the small overlap class if it has already been computed, or
+[`UNDEFINED`](@ref Semigroups.UNDEFINED) otherwise. This function does
+not trigger any computation. The known value is returned as `UInt64`
+(see [`small_overlap_class`](@ref Semigroups.small_overlap_class) for
+the rationale).
+
+See [`small_overlap_class`](@ref Semigroups.small_overlap_class) for
+more details on what the small overlap class is.
+
+# See also
+
+[`small_overlap_class`](@ref Semigroups.small_overlap_class)
+"""
+function current_small_overlap_class(k::Kambites)
+ val = LibSemigroups.current_small_overlap_class(k)
+ return val == convert(UInt, UNDEFINED) ? UNDEFINED : val
+end
+
+# ============================================================================
+# Validators
+# ============================================================================
+
+"""
+ throw_if_letter_not_in_alphabet(k::Kambites, w::AbstractVector{<:Integer})
+
+Throw if any letter in `w` is out of bounds.
+
+This function throws a
+[`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if any
+letter of `w` is out of bounds -- i.e. does not belong to the alphabet
+of the [`Presentation`](@ref Semigroups.Presentation) used to
+construct `k`. Letters in `w` are 1-based indices.
+
+# Arguments
+
+- `k::Kambites`: the [`Kambites`](@ref Semigroups.Kambites) instance.
+- `w::AbstractVector{<:Integer}`: the word to check.
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if any
+ letter in `w` is not in the alphabet.
+"""
+function throw_if_letter_not_in_alphabet(k::Kambites, w::AbstractVector{<:Integer})
+ cpp_w = _word_to_cpp(w)
+ @wrap_libsemigroups_call LibSemigroups.throw_if_letter_not_in_alphabet(k, cpp_w)
+ return nothing
+end
+
+"""
+ throw_if_not_C4(k::Kambites)
+
+Throw if the [`small_overlap_class`](@ref Semigroups.small_overlap_class)
+of `k` is not at least 4.
+
+This function throws an exception if the small overlap class of `k` is
+not at least 4 (and computes it if necessary).
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if
+ `small_overlap_class(k) < 4`.
+"""
+function throw_if_not_C4(k::Kambites)
+ @wrap_libsemigroups_call LibSemigroups.throw_if_not_C4(k)
+ return nothing
+end
+
+# ============================================================================
+# Base.* overloads
+# ============================================================================
+
+"""
+ Base.show(io::IO, k::Kambites)
+
+Print a human-readable representation of `k`. Delegates to
+libsemigroups' `to_human_readable_repr`.
+"""
+function Base.show(io::IO, k::Kambites)
+ print(io, LibSemigroups.to_human_readable_repr(k))
+end
+
+"""
+ Base.copy(k::Kambites) -> Kambites
+
+Create an independent copy of `k` via the C++ copy constructor.
+"""
+Base.copy(k::Kambites) = LibSemigroups.KambitesWord(k)
+
+Base.deepcopy_internal(k::Kambites, ::IdDict) = LibSemigroups.KambitesWord(k)
+
+# ============================================================================
+# non_trivial_classes override (throws)
+# ============================================================================
+
+"""
+ non_trivial_classes(k1::Kambites, k2::Kambites)
+
+Always throws `ArgumentError` for [`Kambites`](@ref Semigroups.Kambites)
+arguments.
+
+`non_trivial_classes(Kambites, Kambites)` is intentionally not provided
+upstream (see `kambites-helpers.hpp:128-133`) because both `Kambites`
+instances always represent infinite-class congruences, so the
+construction does not generalize.
+"""
+function non_trivial_classes(::Kambites, ::Kambites)
+ throw(
+ ArgumentError(
+ "non_trivial_classes(::Kambites, ::Kambites) is intentionally not " *
+ "supported: both Kambites instances always represent infinite-class " *
+ "congruences, so the construction does not generalize " *
+ "(see kambites-helpers.hpp:128-133).",
+ ),
+ )
+end
+
+# ============================================================================
+# normal_forms (bounded; the no-arg form throws)
+# ============================================================================
+
+"""
+ normal_forms(k::Kambites, n::Integer) -> Vector{Vector{Int}}
+
+Return the first `n` short-lex normal forms of the classes of the
+congruence represented by `k`, as 1-based `Vector{Int}` words.
+
+The underlying range of normal forms is always infinite (one per
+congruence class, and a `C(>=4)` presentation has infinitely many
+classes), so the caller must specify the bound `n`. The bounded form
+materializes only the first `n` words and is safe to call.
+
+# Throws
+
+- [`LibsemigroupsError`](@ref Semigroups.LibsemigroupsError) if
+ `small_overlap_class(k) < 4`.
+- `InexactError` if `n` is negative.
+"""
+function normal_forms(k::Kambites, n::Integer)
+ cpp_n = UInt(n)
+ nf = @wrap_libsemigroups_call LibSemigroups.kambites_normal_forms_take(k, cpp_n)
+ return [_word_from_cpp(w) for w in nf]
+end
+
+"""
+ normal_forms(k::Kambites)
+
+Always throws `ArgumentError`. The set of normal forms of a `Kambites`
+is infinite, so the no-argument form is unsafe; use the bounded form
+[`normal_forms(k, n)`](@ref Semigroups.normal_forms(::Kambites, ::Integer))
+instead.
+"""
+function normal_forms(k::Kambites)
+ throw(
+ ArgumentError(
+ "Kambites has infinitely many normal forms; use normal_forms(k, n) " *
+ "to take the first n.",
+ ),
+ )
+end
diff --git a/test/runtests.jl b/test/runtests.jl
index 7d4a1ee..4aadc90 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -27,4 +27,5 @@ using Semigroups
include("test_knuth_bendix_1.jl")
include("test_knuth_bendix_6.jl")
include("test_todd_coxeter.jl")
+ include("test_kambites.jl")
end
diff --git a/test/test_kambites.jl b/test/test_kambites.jl
new file mode 100644
index 0000000..ddea463
--- /dev/null
+++ b/test/test_kambites.jl
@@ -0,0 +1,191 @@
+# Copyright (c) 2026, James W. Swent
+#
+# Distributed under the terms of the GPL license version 3.
+
+"""
+test_kambites.jl - Tests for the Kambites Julia API.
+"""
+
+# TODO: Lots of improvements should be made to the
+# comprehensiveness of these tests. This was made to
+# validate the development process only.
+
+using Test
+using Semigroups
+
+@testset "Kambites - construction and small-overlap-class on MT4" begin
+ # Alphabet "abcdefg" = 1..7. Rules: abcd = aaaeaa; ef = dg.
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+
+ k = Kambites(twosided, p)
+
+ @test kind(k) == twosided
+ @test small_overlap_class(k) >= 4
+ @test number_of_classes(k) == POSITIVE_INFINITY
+
+ @test Semigroups.contains(k, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ @test Semigroups.contains(k, [5, 6], [4, 7])
+ @test Semigroups.contains(k, [1, 1, 1, 1, 1, 5, 6], [1, 1, 1, 1, 1, 4, 7])
+ @test Semigroups.contains(k, [5, 6, 1, 2, 1, 2, 1], [4, 7, 1, 2, 1, 2, 1])
+end
+
+@testset "Kambites - small_overlap_class parametric loop" begin
+ # For i = 4..19, build over alphabet "ab" = [1, 2]:
+ # lhs(i) = concat over b = 1..i of (1, b copies of 2)
+ # rhs(i) = concat over b = i+1..2i of (1, b copies of 2)
+ # The single rule lhs(i) = rhs(i) yields small_overlap_class == i.
+ for i = 4:19
+ lhs = Int[]
+ for b = 1:i
+ push!(lhs, 1)
+ append!(lhs, fill(2, b))
+ end
+ rhs = Int[]
+ for b = (i+1):(2*i)
+ push!(rhs, 1)
+ append!(rhs, fill(2, b))
+ end
+
+ p = Presentation()
+ set_alphabet!(p, 2)
+ add_rule_no_checks!(p, lhs, rhs)
+
+ k = Kambites(twosided, p)
+ @test small_overlap_class(k) == i
+ end
+end
+
+@testset "Kambites - aabc = acba over a 3-letter alphabet" begin
+ # Alphabet [c=1, a=2, b=3]; rule aabc = acba -> [2,2,3,1] = [2,1,3,2].
+ p = Presentation()
+ set_alphabet!(p, 3)
+ add_rule_no_checks!(p, [2, 2, 3, 1], [2, 1, 3, 2])
+
+ k = Kambites(twosided, p)
+
+ @test !Semigroups.contains(k, [2], [3])
+ @test Semigroups.contains(k, [2, 2, 3, 1, 2, 3, 1], [2, 2, 3, 1, 1, 3, 2])
+ @test number_of_classes(k) == POSITIVE_INFINITY
+end
+
+@testset "Kambites - free semigroup" begin
+ # Empty rule set -> small_overlap_class is POSITIVE_INFINITY.
+ p = Presentation()
+ set_alphabet!(p, 3)
+ k = Kambites(twosided, p)
+ @test small_overlap_class(k) == POSITIVE_INFINITY
+
+ p2 = Presentation()
+ set_alphabet!(p2, 1)
+ k2 = Kambites(twosided, p2)
+ @test small_overlap_class(k2) == POSITIVE_INFINITY
+end
+
+@testset "Kambites - negated containment" begin
+ # Alphabet [a=1, b=2, c=3, d=4, e=5]; rule cadeca = baedba.
+ p = Presentation()
+ set_alphabet!(p, 5)
+ add_rule_no_checks!(p, [3, 1, 4, 5, 3, 1], [2, 1, 5, 4, 2, 1])
+
+ k = Kambites(twosided, p)
+
+ # cadece (= [3,1,4,5,3,5]) is not congruent to baedce (= [2,1,5,4,3,5]).
+ @test !Semigroups.contains(k, [3, 1, 4, 5, 3, 5], [2, 1, 5, 4, 3, 5])
+end
+
+@testset "Kambites - end-to-end smoke (run!, finished, success, contains, reduce)" begin
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+
+ k = Kambites(twosided, p)
+ run!(k)
+ @test finished(k)
+ @test success(k)
+
+ @test Semigroups.contains(k, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+
+ r = Semigroups.reduce(k, [1, 2, 3, 4])
+ @test r isa Vector{Int}
+ @test Semigroups.contains(k, r, [1, 2, 3, 4])
+end
+
+@testset "Kambites - bounded normal_forms" begin
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+
+ k = Kambites(twosided, p)
+
+ nfs = normal_forms(k, 20)
+ @test length(nfs) == 20
+ @test all(w -> w isa Vector{Int}, nfs)
+
+ # The no-arg form must throw rather than hang on the infinite range.
+ @test_throws ArgumentError normal_forms(k)
+end
+
+@testset "Kambites - copy round-trip" begin
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+
+ k = Kambites(twosided, p)
+ k2 = copy(k)
+
+ @test kind(k2) == twosided
+ @test small_overlap_class(k2) == small_overlap_class(k)
+
+ run!(k)
+ @test kind(k2) == twosided
+end
+
+@testset "Kambites - show" begin
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+ k = Kambites(twosided, p)
+
+ s = sprint(show, k)
+ @test s isa String
+ @test !isempty(s)
+end
+
+@testset "Kambites - error paths" begin
+ p = Presentation()
+ set_alphabet!(p, 7)
+ add_rule_no_checks!(p, [1, 2, 3, 4], [1, 1, 1, 5, 1, 1])
+ add_rule_no_checks!(p, [5, 6], [4, 7])
+
+ # Kambites accepts only twosided congruences.
+ @test_throws LibsemigroupsError Kambites(Semigroups.onesided, p)
+
+ # `non_trivial_classes(::Kambites, ::Kambites)` is intentionally
+ # unsupported (both arguments always represent infinite-class
+ # congruences); the override throws ArgumentError.
+ k1 = Kambites(twosided, p)
+ k2 = Kambites(twosided, p)
+ @test_throws ArgumentError non_trivial_classes(k1, k2)
+
+ # throw_if_not_C4 throws when small_overlap_class < 4. The presentation
+ # `{aa = b}` over a 2-letter alphabet has small_overlap_class < 4.
+ p_low = Presentation()
+ set_alphabet!(p_low, 2)
+ add_rule_no_checks!(p_low, [1, 1], [2])
+ k_low = Kambites(twosided, p_low)
+ @test small_overlap_class(k_low) < 4
+ @test_throws LibsemigroupsError throw_if_not_C4(k_low)
+end
+
+@testset "Kambites - design constraint: no Base.length" begin
+ # `Base.length` is intentionally NOT defined for Kambites because
+ # `number_of_classes` is always-infinite-or-throws; defining `length`
+ # as an alias would silently misbehave with `for i in 1:length(k)`.
+ @test !hasmethod(Base.length, Tuple{Kambites})
+end