diff --git a/Makefile.am b/Makefile.am
index 4400ff6a9..234d044a0 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -175,6 +175,8 @@ pkginclude_HEADERS += include/libsemigroups/word-graph-helpers-deprecated.hpp
pkginclude_HEADERS += include/libsemigroups/word-graph-helpers-deprecated.tpp
pkginclude_HEADERS += include/libsemigroups/word-graph-helpers.hpp
pkginclude_HEADERS += include/libsemigroups/word-graph-helpers.tpp
+pkginclude_HEADERS += include/libsemigroups/word-graph-io.hpp
+pkginclude_HEADERS += include/libsemigroups/word-graph-io.tpp
pkginclude_HEADERS += include/libsemigroups/word-graph-view-class.hpp
pkginclude_HEADERS += include/libsemigroups/word-graph-view-class.tpp
pkginclude_HEADERS += include/libsemigroups/word-graph-view-helpers-deprecated.hpp
@@ -211,6 +213,7 @@ detailinclude_HEADERS += include/libsemigroups/detail/iterator.hpp
detailinclude_HEADERS += include/libsemigroups/detail/kambites-nf.hpp
detailinclude_HEADERS += include/libsemigroups/detail/kbe.hpp
detailinclude_HEADERS += include/libsemigroups/detail/kbe.tpp
+detailinclude_HEADERS += include/libsemigroups/detail/kbmag.hpp
detailinclude_HEADERS += include/libsemigroups/detail/ke.hpp
detailinclude_HEADERS += include/libsemigroups/detail/knuth-bendix-impl.hpp
detailinclude_HEADERS += include/libsemigroups/detail/knuth-bendix-impl.tpp
@@ -1130,6 +1133,7 @@ libsemigroups_la_SOURCES += src/words-helpers.cpp
libsemigroups_la_SOURCES += src/detail/aho-corasick-impl.cpp
libsemigroups_la_SOURCES += src/detail/cong-common-class.cpp
libsemigroups_la_SOURCES += src/detail/felsch-tree.cpp
+libsemigroups_la_SOURCES += src/detail/kbmag.cpp
libsemigroups_la_SOURCES += src/detail/ke.cpp
libsemigroups_la_SOURCES += src/detail/knuth-bendix-impl.cpp
libsemigroups_la_SOURCES += src/detail/print.cpp
@@ -1268,6 +1272,7 @@ EXTRA_PROGRAMS += test_types
EXTRA_PROGRAMS += test_uf
EXTRA_PROGRAMS += test_ukkonen
EXTRA_PROGRAMS += test_word_graph
+EXTRA_PROGRAMS += test_word_graph_io
EXTRA_PROGRAMS += test_word_graph_view
EXTRA_PROGRAMS += test_word_range
@@ -1383,6 +1388,7 @@ test_all_SOURCES += tests/test-types.cpp
test_all_SOURCES += tests/test-uf.cpp
test_all_SOURCES += tests/test-ukkonen.cpp
test_all_SOURCES += tests/test-word-graph.cpp
+test_all_SOURCES += tests/test-word-graph-io.cpp
test_all_SOURCES += tests/test-word-graph-view.cpp
test_all_SOURCES += tests/test-word-range.cpp
test_all_LDADD = $(LDADD) $(CATCH2_LIBS) $(CATCH2MAIN_LIBS)
@@ -1777,6 +1783,11 @@ test_word_graph_SOURCES += tests/test-main.cpp
test_word_graph_SOURCES += $(CATCH2_SOURCES)
test_word_graph_LDADD = $(LDADD) $(CATCH2_LIBS) $(CATCH2MAIN_LIBS)
+test_word_graph_io_SOURCES = tests/test-word-graph-io.cpp
+test_word_graph_io_SOURCES += tests/test-main.cpp
+test_word_graph_io_SOURCES += $(CATCH2_SOURCES)
+test_word_graph_io_LDADD = $(LDADD) $(CATCH2_LIBS) $(CATCH2MAIN_LIBS)
+
test_word_graph_view_SOURCES = tests/test-word-graph-view.cpp
test_word_graph_view_SOURCES += tests/test-main.cpp
test_word_graph_view_SOURCES += $(CATCH2_SOURCES)
diff --git a/include/libsemigroups/detail/kbmag.hpp b/include/libsemigroups/detail/kbmag.hpp
new file mode 100644
index 000000000..6baf4a439
--- /dev/null
+++ b/include/libsemigroups/detail/kbmag.hpp
@@ -0,0 +1,102 @@
+//
+// libsemigroups - C++ library for semigroups and monoids
+// Copyright (C) 2026 James D. Mitchell
+//
+// 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 .
+//
+
+// Private streaming parser and buffered writer for KBMAG records.
+
+#ifndef LIBSEMIGROUPS_DETAIL_KBMAG_HPP_
+#define LIBSEMIGROUPS_DETAIL_KBMAG_HPP_
+
+#include // for size_t
+#include // for uint64_t
+#include // for istream, ostream
+#include // for string
+#include // for string_view
+#include // for unordered_set
+#include // for vector
+
+namespace libsemigroups {
+ namespace detail {
+ class KbmagReader {
+ public:
+ explicit KbmagReader(std::istream& input);
+
+ uint64_t number_of_nodes() const {
+ return _number_of_nodes;
+ }
+ uint64_t out_degree() const {
+ return _out_degree;
+ }
+ std::vector const& read_row();
+ void finish();
+ [[noreturn]] void fail(std::string const& reason) const;
+
+ private:
+ enum class kind { identifier, number, string, punctuation, end };
+
+ int get();
+ int peek();
+ void next();
+ bool is(std::string_view value) const;
+ bool accept(std::string_view value);
+ void expect(std::string_view value);
+ uint64_t number(std::string_view description,
+ bool allow_negative = false);
+ std::string string();
+ std::string field(std::unordered_set& seen);
+ void skip_value();
+ uint64_t read_set(std::string const& name);
+ void read_flags();
+ void read_initial();
+ void read_accepting();
+ void read_table_field(std::string const& name);
+ void require_header() const;
+
+ std::istream& _input;
+ size_t _line = 1;
+ size_t _column = 1;
+ size_t _token_line = 1;
+ size_t _token_column = 1;
+ kind _kind = kind::end;
+ std::string _token;
+ std::unordered_set _fields;
+ std::unordered_set _table_fields;
+ uint64_t _number_of_nodes = 0;
+ uint64_t _out_degree = 0;
+ uint64_t _source = 0;
+ uint64_t _default_target = 0;
+ uint64_t _expected_transitions = 0;
+ uint64_t _transitions = 0;
+ bool _sparse = false;
+ std::vector _row;
+ std::vector _seen;
+ };
+
+ class KbmagWriter {
+ public:
+ explicit KbmagWriter(std::ostream& output);
+ void write(std::string_view text);
+ void write(uint64_t value);
+ void flush();
+
+ private:
+ std::ostream& _output;
+ std::string _buffer;
+ };
+ } // namespace detail
+} // namespace libsemigroups
+#endif // LIBSEMIGROUPS_DETAIL_KBMAG_HPP_
diff --git a/include/libsemigroups/libsemigroups.hpp b/include/libsemigroups/libsemigroups.hpp
index 7df3a791b..64e524ca2 100644
--- a/include/libsemigroups/libsemigroups.hpp
+++ b/include/libsemigroups/libsemigroups.hpp
@@ -102,6 +102,7 @@
#include "word-graph-class.hpp"
#include "word-graph-helpers-deprecated.hpp"
#include "word-graph-helpers.hpp"
+#include "word-graph-io.hpp"
#include "word-graph-view-class.hpp"
#include "word-graph-view-helpers-deprecated.hpp"
#include "word-graph-view-helpers.hpp"
@@ -126,6 +127,7 @@
#include "detail/iterator.hpp"
#include "detail/kambites-nf.hpp"
#include "detail/kbe.hpp"
+#include "detail/kbmag.hpp"
#include "detail/ke.hpp"
#include "detail/knuth-bendix-impl.hpp"
#include "detail/knuth-bendix-nf.hpp"
diff --git a/include/libsemigroups/word-graph-io.hpp b/include/libsemigroups/word-graph-io.hpp
new file mode 100644
index 000000000..dfcaaac6a
--- /dev/null
+++ b/include/libsemigroups/word-graph-io.hpp
@@ -0,0 +1,190 @@
+//
+// libsemigroups - C++ library for semigroups and monoids
+// Copyright (C) 2026 James D. Mitchell
+//
+// 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 .
+//
+
+// KBMAG serialization of word graphs.
+
+#ifndef LIBSEMIGROUPS_WORD_GRAPH_IO_HPP_
+#define LIBSEMIGROUPS_WORD_GRAPH_IO_HPP_
+
+#include // for istream, ostream
+#include // for string
+#include // for enable_if_t
+
+#include "is_specialization_of.hpp" // for is_specialization_of_v
+#include "word-graph-class.hpp" // for WordGraph
+
+namespace libsemigroups {
+ namespace word_graph {
+ //! \ingroup word_graph_group
+ //! \brief The representation of a KBMAG transition table.
+ enum class kbmagformat {
+ dense, //!< One target per label, with zero for undefined transitions.
+ sparse //!< A list of (label, target) pairs for each source node.
+ };
+
+ //! \ingroup word_graph_group
+ //! \brief Writes a word graph as a KBMAG finite-state automaton record.
+ //!
+ //! Writes a GAP assignment `word_graph := rec(...);`. States and alphabet
+ //! letters have type `"simple"`. Node and label numbers are increased by
+ //! one; dense tables use zero for undefined transitions, and sparse tables
+ //! omit them. The alphabet size records the out-degree even when some
+ //! labels have no transitions. Different labels with the same target are
+ //! preserved. The initial list is `[1]`, or `[]` for a graph with no nodes,
+ //! and the accepting list is always `[]`.
+ //!
+ //! The algorithm counts the defined transitions, then visits nodes and
+ //! labels in their original order and writes the targets directly, using
+ //! a bounded output buffer. Including `numTransitions` allows KBMAG tools
+ //! to read sparse output using sparse storage. The algorithm does
+ //! not sort edges or copy the graph. Output is independent of the stream's
+ //! numeric formatting flags and locale.
+ //!
+ //! \tparam Node the node type of the word graph.
+ //! \param output the destination stream.
+ //! \param wg the word graph to encode.
+ //! \param format the dense or sparse table representation (defaults to
+ //! \ref kbmagformat::dense).
+ //!
+ //! \throws LibsemigroupsException if a target is out of bounds, the
+ //! dimensions exceed the node type's range, the format is invalid, or
+ //! writing fails without the stream throwing its own exception.
+ //! \throws std::ios_base::failure if enabled by the stream.
+ //!
+ //! \complexity
+ //! \f$O(n(d+1)+L)\f$ time and constant additional space, where \f$n\f$
+ //! is the number of nodes, \f$d\f$ is the out-degree, and \f$L\f$ is the
+ //! output length.
+ //!
+ //! \note On failure the stream may contain a partial record.
+ //!
+ //! \sa make(std::istream&), to_kbmag_string(), and the
+ //!
+ //! KBMAG/GASP finite-state automaton specification.
+ template
+ void write_kbmag(std::ostream& output,
+ WordGraph const& wg,
+ kbmagformat format = kbmagformat::dense);
+
+ //! \ingroup word_graph_group
+ //! \brief Returns a KBMAG representation of a word graph.
+ //!
+ //! Uses the same encoding and conventions as write_kbmag(). For large
+ //! graphs, write_kbmag() avoids constructing the complete output string.
+ //!
+ //! \par Example
+ //! \code
+ //! WordGraph graph(2, 2);
+ //! graph.target(0, 0, 1);
+ //! graph.target(0, 1, 1);
+ //! auto text = word_graph::to_kbmag_string(graph);
+ //! auto copy = make>(text);
+ //! copy == graph; // returns true
+ //! \endcode
+ //!
+ //! \tparam Node the node type of the word graph.
+ //! \param wg the word graph to encode.
+ //! \param format the dense or sparse table representation (defaults to
+ //! \ref kbmagformat::dense).
+ //! \returns The KBMAG record as a string.
+ //!
+ //! \throws LibsemigroupsException if write_kbmag() throws.
+ //!
+ //! \complexity
+ //! \f$O(n(d+1)+L)\f$ time and \f$O(L)\f$ additional space, with the same
+ //! notation as write_kbmag().
+ template
+ [[nodiscard]] std::string to_kbmag_string(WordGraph const& wg,
+ kbmagformat format
+ = kbmagformat::dense);
+ } // namespace word_graph
+
+ //! \ingroup make_word_graph_group
+ //! \brief Constructs a word graph from a KBMAG input stream.
+ //!
+ //! Reads a KBMAG/GASP record, optionally preceded by a GAP variable
+ //! assignment and followed by a semicolon. Both `"dense deterministic"`
+ //! and `"sparse"` tables are supported. States and alphabet must have type
+ //! `"simple"`, and flags must include `"DFA"`. The initial list must be
+ //! `[1]` (or `[]` for no nodes), and the accepting list must be empty.
+ //! Nonempty accepting lists and other initial states are rejected rather
+ //! than silently discarded.
+ //!
+ //! The parser accepts whitespace, comments beginning with `#`, backslash
+ //! line continuations, and additional record fields containing balanced
+ //! GAP expressions. Additional fields are skipped without evaluating GAP
+ //! code. Required metadata must precede `table`, and `format` and any
+ //! `defaultTarget` must precede `transitions`. External transition files
+ //! and nondeterministic tables are not supported.
+ //!
+ //! Dense rows have exactly the alphabet size entries: a blank or nonpositive
+ //! integer denotes an undefined transition. Sparse rows contain distinct
+ //! positive labels and positive targets. A sparse `defaultTarget` supplies
+ //! omitted transitions. Repeated targets under different labels are valid.
+ //! The declared dimensions, including isolated nodes and unused labels,
+ //! are preserved. The parser checks one row at a time, converts one-based
+ //! indices to zero-based indices, and places targets at their original
+ //! labels. It never constructs a second complete transition table.
+ //!
+ //! \tparam Return the return type, a specialization of WordGraph.
+ //! \param input the source stream, consumed through the end of input.
+ //! \returns The decoded word graph.
+ //!
+ //! \throws LibsemigroupsException for invalid or unsupported input, including
+ //! dimensions too large for the requested node type or transition storage.
+ //! Messages include the line and column, and the invalid value or field.
+ //! \throws std::ios_base::failure if enabled by the stream.
+ //!
+ //! \complexity
+ //! \f$O(L+n(d+1))\f$ time and \f$O(d+t+h)\f$ additional space, excluding
+ //! the result, where \f$L\f$ is the input length, \f$n\f$ the node count,
+ //! \f$d\f$ the out-degree, \f$t\f$ the longest token, and \f$h\f$ the
+ //! size of additional metadata and its nesting depth.
+ //!
+ //! \note An empty graph is encoded with an empty initial list because
+ //! there is no node zero. On failure the input may have been partly consumed.
+ //!
+ //! \sa word_graph::write_kbmag(), word_graph::to_kbmag_string().
+ template
+ [[nodiscard]] std::enable_if_t,
+ Return>
+ make(std::istream& input);
+
+ //! \ingroup make_word_graph_group
+ //! \brief Constructs a word graph from a KBMAG string.
+ //!
+ //! Uses the same parser and conventions as make(std::istream&).
+ //!
+ //! \tparam Return the return type, a specialization of WordGraph.
+ //! \param input the KBMAG record to decode.
+ //! \returns The decoded word graph.
+ //!
+ //! \throws LibsemigroupsException if make(std::istream&) throws.
+ //!
+ //! \complexity
+ //! As for make(std::istream&), with an additional \f$O(L)\f$ space for
+ //! the input stream's copy of \p input.
+ template
+ [[nodiscard]] std::enable_if_t,
+ Return>
+ make(std::string const& input);
+} // namespace libsemigroups
+
+#include "word-graph-io.tpp"
+#endif // LIBSEMIGROUPS_WORD_GRAPH_IO_HPP_
diff --git a/include/libsemigroups/word-graph-io.tpp b/include/libsemigroups/word-graph-io.tpp
new file mode 100644
index 000000000..59843a6c3
--- /dev/null
+++ b/include/libsemigroups/word-graph-io.tpp
@@ -0,0 +1,161 @@
+//
+// libsemigroups - C++ library for semigroups and monoids
+// Copyright (C) 2026 James D. Mitchell
+//
+// 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 .
+//
+
+// Template implementations of KBMAG serialization.
+
+#include // for uint64_t
+#include // for numeric_limits
+#include // for istringstream, ostringstream
+#include // for vector
+
+#include "detail/fmt.hpp" // for fmt::format
+#include "detail/kbmag.hpp" // for KbmagReader, KbmagWriter
+#include "exception.hpp" // for LIBSEMIGROUPS_EXCEPTION
+
+namespace libsemigroups {
+ namespace word_graph {
+ template
+ void write_kbmag(std::ostream& output,
+ WordGraph const& wg,
+ kbmagformat format) {
+ if (format != kbmagformat::dense && format != kbmagformat::sparse) {
+ LIBSEMIGROUPS_EXCEPTION(
+ "expected dense or sparse KBMAG format, found {}",
+ static_cast(format));
+ }
+ auto const n = wg.number_of_nodes();
+ auto const d = wg.out_degree();
+ auto const max = std::numeric_limits::max();
+ if (n > max || d > max) {
+ LIBSEMIGROUPS_EXCEPTION(
+ "expected KBMAG node count and out-degree at most {}, found {} "
+ "nodes and out-degree {}",
+ max,
+ n,
+ d);
+ }
+ libsemigroups::detail::KbmagWriter writer(output);
+ writer.write(
+ "word_graph := rec(\n isFSA := true,\n alphabet := rec(type := "
+ "\"simple\", size := ");
+ writer.write(static_cast(d));
+ writer.write("),\n states := rec(type := \"simple\", size := ");
+ writer.write(static_cast(n));
+ writer.write("),\n flags := [\"DFA\"],\n initial := ");
+ writer.write(n == 0 ? "[]" : "[1]");
+ writer.write(",\n accepting := [],\n table := rec(format := ");
+ writer.write(format == kbmagformat::dense ? "\"dense deterministic\""
+ : "\"sparse\"");
+ writer.write(", numTransitions := ");
+ writer.write(static_cast(wg.number_of_edges()));
+ writer.write(", transitions := [");
+ for (size_t s = 0; s < n; ++s) {
+ writer.write(s == 0 ? "\n[" : ",\n[");
+ bool first = true;
+ for (size_t a = 0; a < d; ++a) {
+ auto const t
+ = wg.target_no_checks(static_cast(s), static_cast(a));
+ if (t != UNDEFINED && t >= n) {
+ LIBSEMIGROUPS_EXCEPTION(
+ "expected target less than {} at source {}, label {}, found {}",
+ n,
+ s,
+ a,
+ t);
+ }
+ if (format == kbmagformat::sparse && t == UNDEFINED) {
+ continue;
+ }
+ if (!first) {
+ writer.write(",");
+ }
+ first = false;
+ if (format == kbmagformat::sparse) {
+ writer.write("[");
+ writer.write(static_cast(a) + 1);
+ writer.write(",");
+ }
+ writer.write(t == UNDEFINED ? uint64_t{0}
+ : static_cast(t) + 1);
+ if (format == kbmagformat::sparse) {
+ writer.write("]");
+ }
+ }
+ writer.write("]");
+ }
+ writer.write("\n])\n);\n");
+ writer.flush();
+ }
+
+ template
+ std::string to_kbmag_string(WordGraph const& wg, kbmagformat format) {
+ std::ostringstream output;
+ write_kbmag(output, wg, format);
+ return output.str();
+ }
+ } // namespace word_graph
+
+ template
+ std::enable_if_t, Return>
+ make(std::istream& input) {
+ using node_type = typename Return::node_type;
+ detail::KbmagReader reader(input);
+ auto const n = reader.number_of_nodes();
+ auto const d = reader.out_degree();
+ auto const max = std::numeric_limits::max();
+ if (n > max || d > max) {
+ reader.fail(
+ fmt::format("expected node count and alphabet size at most {}, "
+ "found {} nodes and alphabet size {}",
+ max,
+ n,
+ d));
+ }
+ auto const max_size = std::vector().max_size();
+ if (n > std::numeric_limits::max()
+ || d > std::numeric_limits::max()
+ || (d != 0 && n > max_size / d)) {
+ reader.fail(
+ fmt::format("the {} by {} transition table exceeds the maximum "
+ "storage size of {} entries",
+ n,
+ d,
+ max_size));
+ }
+ Return result(static_cast(n), static_cast(d));
+ for (size_t s = 0; s < n; ++s) {
+ auto const& row = reader.read_row();
+ for (size_t a = 0; a < d; ++a) {
+ if (row[a] != 0) {
+ result.target_no_checks(static_cast(s),
+ static_cast(a),
+ static_cast(row[a] - 1));
+ }
+ }
+ }
+ reader.finish();
+ return result;
+ }
+
+ template
+ std::enable_if_t, Return>
+ make(std::string const& input) {
+ std::istringstream stream(input);
+ return make(stream);
+ }
+} // namespace libsemigroups
diff --git a/include/libsemigroups/word-graph.hpp b/include/libsemigroups/word-graph.hpp
index ab38824ab..aada43929 100644
--- a/include/libsemigroups/word-graph.hpp
+++ b/include/libsemigroups/word-graph.hpp
@@ -17,13 +17,14 @@
//
// This file contains declarations related to word graphs (which are basically
-// deterministic automata without initial or accept states).
+// deterministic automata rooted at node 0, without accept states).
#ifndef LIBSEMIGROUPS_WORD_GRAPH_HPP_
#define LIBSEMIGROUPS_WORD_GRAPH_HPP_
#include "word-graph-class.hpp"
#include "word-graph-helpers.hpp"
+#include "word-graph-io.hpp"
#include "word-graph-helpers-deprecated.hpp"
diff --git a/src/detail/kbmag.cpp b/src/detail/kbmag.cpp
new file mode 100644
index 000000000..b5ade6472
--- /dev/null
+++ b/src/detail/kbmag.cpp
@@ -0,0 +1,598 @@
+//
+// libsemigroups - C++ library for semigroups and monoids
+// Copyright (C) 2026 James D. Mitchell
+//
+// 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 .
+//
+
+// Streaming KBMAG parsing and buffered decimal output.
+
+#include "libsemigroups/detail/kbmag.hpp"
+
+#include // for fill
+#include // for from_chars, to_chars
+#include // for istream
+#include // for ostream
+#include // for errc
+
+#include "libsemigroups/detail/fmt.hpp" // for fmt::format
+#include "libsemigroups/exception.hpp" // for LIBSEMIGROUPS_EXCEPTION
+
+namespace libsemigroups {
+ namespace detail {
+ namespace {
+ bool letter(int c) {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
+ }
+ bool digit(int c) {
+ return c >= '0' && c <= '9';
+ }
+ bool whitespace(int c) {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
+ || c == '\v';
+ }
+ } // namespace
+
+ [[noreturn]] void KbmagReader::fail(std::string const& reason) const {
+ LIBSEMIGROUPS_EXCEPTION("invalid KBMAG input at line {}, column {}: {}",
+ _token_line,
+ _token_column,
+ reason);
+ }
+
+ int KbmagReader::peek() {
+ auto c = _input.peek();
+ if (_input.bad() || (_input.fail() && !_input.eof())) {
+ fail("failed to read from the input stream");
+ }
+ return c;
+ }
+
+ int KbmagReader::get() {
+ int c = _input.get();
+ if (_input.bad() || (_input.fail() && !_input.eof())) {
+ fail("failed to read from the input stream");
+ }
+ if (c == '\n') {
+ ++_line;
+ _column = 1;
+ } else if (c != std::char_traits::eof()) {
+ ++_column;
+ }
+ return c;
+ }
+
+ void KbmagReader::next() {
+ // Whitespace and GAP comments are insignificant. Backslash-newline
+ // continuations are handled below as well as inside individual tokens.
+ while (true) {
+ while (whitespace(peek())) {
+ get();
+ }
+ if (peek() == '#') {
+ while (peek() != '\n' && peek() != std::char_traits::eof()) {
+ get();
+ }
+ } else if (peek() == '\\') {
+ get();
+ if (peek() == '\r') {
+ get();
+ }
+ if (get() != '\n') {
+ fail("expected a newline after a backslash");
+ }
+ } else {
+ break;
+ }
+ }
+ _token_line = _line;
+ _token_column = _column;
+ _token.clear();
+ int c = get();
+ if (c == std::char_traits::eof()) {
+ _kind = kind::end;
+ return;
+ }
+ if (c == '"' || c == '\'') {
+ _kind = kind::string;
+ int quote = c;
+ while ((c = get()) != quote) {
+ if (c == std::char_traits::eof()) {
+ fail("unterminated quoted string");
+ }
+ if (c == '\\') {
+ c = get();
+ if (c == std::char_traits::eof()) {
+ fail("unterminated escape in quoted string");
+ }
+ if (c == '\r' && peek() == '\n') {
+ get();
+ continue;
+ }
+ if (c == '\n') {
+ continue;
+ }
+ if (c == 'n') {
+ c = '\n';
+ } else if (c == 't') {
+ c = '\t';
+ } else if (c == 'r') {
+ c = '\r';
+ }
+ }
+ _token.push_back(static_cast(c));
+ }
+ return;
+ }
+ _token.push_back(static_cast(c));
+ if (letter(c) || digit(c) || (c == '-' && digit(peek()))) {
+ _kind = letter(c) ? kind::identifier : kind::number;
+ while (true) {
+ c = peek();
+ if (digit(c) || (_kind == kind::identifier && letter(c))) {
+ _token.push_back(static_cast(get()));
+ } else if (c == '\\') {
+ get();
+ if (peek() == '\r') {
+ get();
+ }
+ if (get() != '\n') {
+ fail("expected a newline after a backslash");
+ }
+ } else {
+ break;
+ }
+ }
+ return;
+ }
+ _kind = kind::punctuation;
+ if ((c == ':' && peek() == '=') || (c == '.' && peek() == '.')) {
+ _token.push_back(static_cast(get()));
+ }
+ }
+
+ bool KbmagReader::is(std::string_view value) const {
+ return _kind != kind::string && _kind != kind::end && _token == value;
+ }
+
+ bool KbmagReader::accept(std::string_view value) {
+ if (!is(value)) {
+ return false;
+ }
+ next();
+ return true;
+ }
+
+ void KbmagReader::expect(std::string_view value) {
+ if (!accept(value)) {
+ fail(fmt::format("expected '{}', found {}",
+ value,
+ _kind == kind::end ? "end of input"
+ : fmt::format("'{}'", _token)));
+ }
+ }
+
+ uint64_t KbmagReader::number(std::string_view description,
+ bool allow_negative) {
+ bool negative = !_token.empty() && _token[0] == '-';
+ if (_kind != kind::number || (negative && !allow_negative)) {
+ fail(fmt::format("expected {} to be a nonnegative integer, found '{}'",
+ description,
+ _token));
+ }
+ uint64_t value = 0;
+ auto const* first = _token.data() + (negative ? 1 : 0);
+ auto const* last = _token.data() + _token.size();
+ auto parsed = std::from_chars(first, last, value);
+ if (parsed.ec != std::errc() || parsed.ptr != last) {
+ fail(fmt::format("{} '{}' exceeds the supported 64-bit integer range",
+ description,
+ _token));
+ }
+ next();
+ return negative ? 0 : value;
+ }
+
+ std::string KbmagReader::string() {
+ if (_kind != kind::string) {
+ fail(fmt::format("expected a quoted string, found '{}'", _token));
+ }
+ auto value = _token;
+ next();
+ return value;
+ }
+
+ std::string KbmagReader::field(std::unordered_set& seen) {
+ if (_kind != kind::identifier) {
+ fail(fmt::format("expected a record field name, found '{}'", _token));
+ }
+ auto name = _token;
+ if (!seen.insert(name).second) {
+ fail(fmt::format("duplicate record field '{}'", name));
+ }
+ next();
+ expect(":=");
+ return name;
+ }
+
+ void KbmagReader::skip_value() {
+ // Skip an expression without interpreting it. A delimiter stack avoids
+ // recursion and does not mistake punctuation in strings for delimiters.
+ std::vector closing;
+ bool consumed = false;
+ while (_kind != kind::end) {
+ if (closing.empty() && (is(",") || is(")"))) {
+ break;
+ }
+ if (is("(") || is("[") || is("{")) {
+ closing.push_back(is("(") ? ')' : (is("[") ? ']' : '}'));
+ } else if (is(")") || is("]") || is("}")) {
+ if (closing.empty() || _token[0] != closing.back()) {
+ fail(fmt::format("unmatched delimiter '{}' in additional field",
+ _token));
+ }
+ closing.pop_back();
+ }
+ consumed = true;
+ next();
+ }
+ if (!consumed || !closing.empty() || _kind == kind::end) {
+ fail("expected a complete value for the additional field");
+ }
+ }
+
+ uint64_t KbmagReader::read_set(std::string const& name) {
+ expect("rec");
+ expect("(");
+ std::unordered_set seen;
+ uint64_t size = 0;
+ bool first = true;
+ while (!accept(")")) {
+ if (!first) {
+ expect(",");
+ }
+ first = false;
+ auto key = field(seen);
+ if (key == "type") {
+ auto value = string();
+ if (value != "simple") {
+ fail(fmt::format(
+ "expected {}.type 'simple', found '{}'", name, value));
+ }
+ } else if (key == "size") {
+ size = number(name + ".size");
+ } else {
+ skip_value();
+ }
+ }
+ if (!seen.count("type") || !seen.count("size")) {
+ fail(fmt::format("{} requires both 'type' and 'size' fields", name));
+ }
+ return size;
+ }
+
+ void KbmagReader::read_flags() {
+ expect("[");
+ bool dfa = false;
+ bool first = true;
+ while (!accept("]")) {
+ if (!first) {
+ expect(",");
+ }
+ first = false;
+ auto value = string();
+ if (value == "NFA" || value == "MIDFA") {
+ fail(fmt::format("expected a DFA, found flag '{}'", value));
+ }
+ dfa = dfa || value == "DFA";
+ }
+ if (!dfa) {
+ fail("expected flags to include 'DFA'");
+ }
+ }
+
+ void KbmagReader::read_initial() {
+ if (!_fields.count("states")) {
+ fail("expected 'states' before 'initial'");
+ }
+ expect("[");
+ if (_number_of_nodes != 0) {
+ auto value = number("initial state");
+ if (value != 1) {
+ fail(fmt::format(
+ "expected initial state 1 (WordGraph node 0), found {}", value));
+ }
+ if (accept("..")) {
+ expect("1");
+ }
+ }
+ if (!is("]")) {
+ fail(
+ fmt::format("expected initial list {}, found additional value '{}'",
+ _number_of_nodes == 0 ? "[]" : "[1]",
+ _token));
+ }
+ expect("]");
+ }
+
+ void KbmagReader::read_accepting() {
+ expect("[");
+ if (!is("]")) {
+ fail(fmt::format("expected accepting := [] because word graphs have no "
+ "accepting states, found '{}'",
+ _token));
+ }
+ expect("]");
+ }
+
+ void KbmagReader::require_header() const {
+ for (auto name :
+ {"isFSA", "alphabet", "states", "flags", "initial", "accepting"}) {
+ if (!_fields.count(name)) {
+ fail(fmt::format("missing required field '{}' before 'table'", name));
+ }
+ }
+ }
+
+ void KbmagReader::read_table_field(std::string const& name) {
+ if (name == "format") {
+ auto value = string();
+ if (value != "sparse" && value != "dense deterministic") {
+ fail(fmt::format("expected table format 'dense deterministic' or "
+ "'sparse', found '{}'",
+ value));
+ }
+ _sparse = value == "sparse";
+ } else if (name == "defaultTarget") {
+ if (!_table_fields.count("format") || !_sparse) {
+ fail("defaultTarget is only supported after format := 'sparse'");
+ }
+ _default_target = number("defaultTarget");
+ if (_default_target == 0 || _default_target > _number_of_nodes) {
+ fail(fmt::format("expected defaultTarget in [1, {}], found {}",
+ _number_of_nodes,
+ _default_target));
+ }
+ } else if (name == "numTransitions") {
+ _expected_transitions = number("numTransitions");
+ } else if (name == "filename") {
+ fail("external transition files are unsupported; expected "
+ "'transitions'");
+ } else {
+ skip_value();
+ }
+ }
+
+ KbmagReader::KbmagReader(std::istream& input) : _input(input) {
+ next();
+ if (!is("rec")) {
+ if (_kind != kind::identifier) {
+ fail("expected 'rec' or a GAP variable assignment");
+ }
+ next();
+ while (accept(".")) {
+ if (_kind != kind::identifier && _kind != kind::number) {
+ fail("expected a component name in the GAP variable assignment");
+ }
+ next();
+ }
+ expect(":=");
+ }
+ expect("rec");
+ expect("(");
+ bool first = true;
+ while (!is(")")) {
+ if (!first) {
+ expect(",");
+ }
+ first = false;
+ auto name = field(_fields);
+ if (name == "isFSA") {
+ expect("true");
+ } else if (name == "alphabet") {
+ _out_degree = read_set(name);
+ } else if (name == "states") {
+ _number_of_nodes = read_set(name);
+ } else if (name == "flags") {
+ read_flags();
+ } else if (name == "initial") {
+ read_initial();
+ } else if (name == "accepting") {
+ read_accepting();
+ } else if (name == "table") {
+ require_header();
+ expect("rec");
+ expect("(");
+ bool first_table = true;
+ while (!is(")")) {
+ if (!first_table) {
+ expect(",");
+ }
+ first_table = false;
+ auto key = field(_table_fields);
+ if (key == "transitions") {
+ if (!_table_fields.count("format")) {
+ fail("expected table 'format' before 'transitions'");
+ }
+ expect("[");
+ return;
+ }
+ read_table_field(key);
+ }
+ fail("missing required table field 'transitions'");
+ } else {
+ skip_value();
+ }
+ }
+ fail("missing required field 'table'");
+ }
+
+ std::vector const& KbmagReader::read_row() {
+ if (is("]")) {
+ fail(fmt::format("expected {} transition rows, found {} (missing "
+ "row for source {})",
+ _number_of_nodes,
+ _source,
+ _source));
+ }
+ if (_source != 0) {
+ expect(",");
+ }
+ if (!is("[")) {
+ fail(fmt::format("expected transition row for source {}, found '{}'",
+ _source,
+ _token));
+ }
+ expect("[");
+ _row.assign(static_cast(_out_degree), _default_target);
+ if (_sparse) {
+ _seen.assign(static_cast(_out_degree), false);
+ bool first = true;
+ while (!accept("]")) {
+ if (!first) {
+ expect(",");
+ }
+ first = false;
+ expect("[");
+ auto a = number("sparse label");
+ if (a == 0 || a > _out_degree) {
+ fail(fmt::format("expected sparse label in [1, {}] at source {}, "
+ "found {}",
+ _out_degree,
+ _source,
+ a));
+ }
+ if (_seen[a - 1]) {
+ fail(fmt::format("repeated sparse label {} at source {}; expected "
+ "at most one target per label",
+ a,
+ _source));
+ }
+ _seen[a - 1] = true;
+ expect(",");
+ auto t = number("sparse target");
+ if (t == 0 || t > _number_of_nodes) {
+ fail(fmt::format("expected sparse target in [1, {}] at source {}, "
+ "label {}, found {}",
+ _number_of_nodes,
+ _source,
+ a,
+ t));
+ }
+ _row[a - 1] = t;
+ expect("]");
+ }
+ } else {
+ for (uint64_t a = 0; a < _out_degree; ++a) {
+ if (a != 0) {
+ if (is("]")) {
+ fail(fmt::format("expected {} dense entries at source {}, "
+ "found {}",
+ _out_degree,
+ _source,
+ a));
+ }
+ expect(",");
+ }
+ if (is(",") || is("]")) {
+ continue;
+ }
+ auto t = number("dense target", true);
+ if (t > _number_of_nodes) {
+ fail(fmt::format("expected dense target at most {} at source {}, "
+ "label {}, found {}",
+ _number_of_nodes,
+ _source,
+ a,
+ t));
+ }
+ _row[a] = t;
+ }
+ if (!is("]")) {
+ fail(fmt::format("expected {} dense entries at source {}; "
+ "found additional value '{}'",
+ _out_degree,
+ _source,
+ _token));
+ }
+ expect("]");
+ }
+ for (auto t : _row) {
+ _transitions += t != 0;
+ }
+ ++_source;
+ return _row;
+ }
+
+ void KbmagReader::finish() {
+ if (is(",") || is("[")) {
+ fail(fmt::format("expected {} transition rows; found an additional "
+ "row for source {}",
+ _number_of_nodes,
+ _source));
+ }
+ expect("]");
+ while (accept(",")) {
+ auto key = field(_table_fields);
+ if (key == "format" || key == "defaultTarget") {
+ fail(fmt::format("expected '{}' before 'transitions'", key));
+ }
+ read_table_field(key);
+ }
+ expect(")");
+ while (accept(",")) {
+ auto key = field(_fields);
+ skip_value();
+ }
+ expect(")");
+ accept(";");
+ if (_kind != kind::end) {
+ fail(fmt::format("expected end of input, found '{}'", _token));
+ }
+ if (_table_fields.count("numTransitions")
+ && _expected_transitions != _transitions) {
+ fail(fmt::format(
+ "numTransitions declares {} edges, but the table has {}",
+ _expected_transitions,
+ _transitions));
+ }
+ }
+
+ KbmagWriter::KbmagWriter(std::ostream& output) : _output(output) {
+ _buffer.reserve(65536);
+ }
+
+ void KbmagWriter::write(std::string_view text) {
+ _buffer.append(text);
+ if (_buffer.size() >= 65536) {
+ flush();
+ }
+ }
+
+ void KbmagWriter::write(uint64_t value) {
+ char buffer[20];
+ auto end = std::to_chars(buffer, buffer + sizeof(buffer), value).ptr;
+ write(std::string_view(buffer, end - buffer));
+ }
+
+ void KbmagWriter::flush() {
+ _output.write(_buffer.data(),
+ static_cast(_buffer.size()));
+ if (!_output) {
+ LIBSEMIGROUPS_EXCEPTION("failed to write KBMAG output to the stream");
+ }
+ _buffer.clear();
+ }
+ } // namespace detail
+} // namespace libsemigroups
diff --git a/tests/test-docs-code-examples.cpp b/tests/test-docs-code-examples.cpp
index 3aa0f08ea..2094cd65a 100644
--- a/tests/test-docs-code-examples.cpp
+++ b/tests/test-docs-code-examples.cpp
@@ -577,10 +577,23 @@ namespace libsemigroups {
// 0121212121_w};
}
+ // word-graph-io.hpp: Line 93
+ LIBSEMIGROUPS_TEST_CASE("docs",
+ "033",
+ "./include/libsemigroups/word-graph-io.hpp:93",
+ "[docs][quick][no-valgrind]") {
+ WordGraph graph(2, 2);
+ graph.target(0, 0, 1);
+ graph.target(0, 1, 1);
+ auto text = word_graph::to_kbmag_string(graph);
+ auto copy = make>(text);
+ copy == graph; // returns true
+ }
+
// word-graph-view-helpers.hpp: Line 458
LIBSEMIGROUPS_TEST_CASE(
"docs",
- "033",
+ "034",
"./include/libsemigroups/word-graph-view-helpers.hpp:458",
"[docs][quick][no-valgrind]") {
WordGraph wgv;
@@ -594,7 +607,7 @@ namespace libsemigroups {
// word-graph-view-helpers.hpp: Line 518
LIBSEMIGROUPS_TEST_CASE(
"docs",
- "034",
+ "035",
"./include/libsemigroups/word-graph-view-helpers.hpp:518",
"[docs][quick][no-valgrind]") {
WordGraph wg;
@@ -614,7 +627,7 @@ namespace libsemigroups {
// word-graph-view-helpers.hpp: Line 960
LIBSEMIGROUPS_TEST_CASE(
"docs",
- "035",
+ "036",
"./include/libsemigroups/word-graph-view-helpers.hpp:960",
"[docs][quick][no-valgrind]") {
WordGraph wg;
@@ -634,7 +647,7 @@ namespace libsemigroups {
// word-graph-view-helpers.hpp: Line 1079
LIBSEMIGROUPS_TEST_CASE(
"docs",
- "036",
+ "037",
"./include/libsemigroups/word-graph-view-helpers.hpp:1079",
"[docs][quick][no-valgrind]") {
auto wgv = make>(5, {{0, 0}, {1, 1}, {2}, {3, 3}});
@@ -643,7 +656,7 @@ namespace libsemigroups {
// word-range-class.hpp: Line 214
LIBSEMIGROUPS_TEST_CASE("docs",
- "037",
+ "038",
"./include/libsemigroups/word-range-class.hpp:214",
"[docs][quick][no-valgrind]") {
std::vector(
@@ -654,7 +667,7 @@ namespace libsemigroups {
// word-range-class.hpp: Line 342
LIBSEMIGROUPS_TEST_CASE("docs",
- "038",
+ "039",
"./include/libsemigroups/word-range-class.hpp:342",
"[docs][quick][no-valgrind]") {
std::vector(cbegin_wislo(Alphabet(2), {0}, {0, 0, 0}),
@@ -664,7 +677,7 @@ namespace libsemigroups {
// word-range-class.hpp: Line 792
LIBSEMIGROUPS_TEST_CASE("docs",
- "039",
+ "040",
"./include/libsemigroups/word-range-class.hpp:792",
"[docs][quick][no-valgrind]") {
WordRange words;
@@ -677,7 +690,7 @@ namespace libsemigroups {
// word-range-class.hpp: Line 1249
LIBSEMIGROUPS_TEST_CASE("docs",
- "040",
+ "041",
"./include/libsemigroups/word-range-class.hpp:1249",
"[docs][quick][no-valgrind]") {
StringRange strings;
@@ -690,7 +703,7 @@ namespace libsemigroups {
// words-helpers.hpp: Line 440
LIBSEMIGROUPS_TEST_CASE("docs",
- "041",
+ "042",
"./include/libsemigroups/words-helpers.hpp:440",
"[docs][quick][no-valgrind]") {
using namespace words;
diff --git a/tests/test-word-graph-io.cpp b/tests/test-word-graph-io.cpp
new file mode 100644
index 000000000..36bb631a4
--- /dev/null
+++ b/tests/test-word-graph-io.cpp
@@ -0,0 +1,381 @@
+//
+// libsemigroups - C++ library for semigroups and monoids
+// Copyright (C) 2026 James D. Mitchell
+//
+// 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 // for uint8_t, uint16_t, uint32_t, uint64_t
+#include // for hex, showbase
+#include // for ios_base
+#include // for istream
+#include // for locale, numpunct
+#include // for ostream
+#include // for istringstream, ostringstream
+#include // for streambuf
+#include // for string
+#include // for pair
+#include // for vector
+
+#include "test-main.hpp"
+
+#include "libsemigroups/exception.hpp"
+#include "libsemigroups/word-graph.hpp"
+
+namespace libsemigroups {
+ namespace {
+ std::string record(std::string const& rows = "[[2,2],[1,2]]",
+ std::string const& format = "dense deterministic",
+ std::string const& nodes = "2",
+ std::string const& degree = "2") {
+ return "rec(isFSA:=true,alphabet:=rec(type:=\"simple\",size:=" + degree
+ + "),states:=rec(type:=\"simple\",size:=" + nodes
+ + "),flags:=[\"DFA\"],initial:=" + (nodes == "0" ? "[]" : "[1]")
+ + ",accepting:=[],table:=rec(format:=\"" + format
+ + "\",transitions:=" + rows + "))";
+ }
+
+ std::string replace(std::string text,
+ std::string const& from,
+ std::string const& to) {
+ auto pos = text.find(from);
+ REQUIRE(pos != std::string::npos);
+ text.replace(pos, from.size(), to);
+ return text;
+ }
+
+ template
+ void check_invalid(std::string const& input, std::string const& reason) {
+ CAPTURE(input);
+ REQUIRE_THROWS_AS(make>(input), LibsemigroupsException);
+ try {
+ (void) make>(input);
+ } catch (LibsemigroupsException const& e) {
+ auto message = chomp(e.what());
+ REQUIRE(message.find("invalid KBMAG input at line ") == 0);
+ REQUIRE(message.find(", column ") != std::string::npos);
+ REQUIRE(message.find(reason) != std::string::npos);
+ }
+ }
+
+ class ChunkedInputBuffer : public std::streambuf {
+ public:
+ explicit ChunkedInputBuffer(std::string const& text) : _text(text) {}
+
+ protected:
+ int_type underflow() override {
+ if (_pos == _text.size()) {
+ return traits_type::eof();
+ }
+ _char = _text[_pos++];
+ setg(&_char, &_char, &_char + 1);
+ return traits_type::to_int_type(_char);
+ }
+
+ private:
+ std::string const& _text;
+ size_t _pos = 0;
+ char _char = 0;
+ };
+
+ class FailingOutputBuffer : public std::streambuf {
+ protected:
+ std::streamsize xsputn(char const*, std::streamsize n) override {
+ return n == 0 ? 0 : n - 1;
+ }
+ };
+
+ class GroupedNumbers : public std::numpunct {
+ protected:
+ char do_thousands_sep() const override {
+ return '_';
+ }
+ std::string do_grouping() const override {
+ return "\1";
+ }
+ };
+ } // namespace
+
+ LIBSEMIGROUPS_TEMPLATE_TEST_CASE("WordGraphIO",
+ "000",
+ "KBMAG example",
+ "[quick][word-graph-io]",
+ uint8_t,
+ uint16_t,
+ uint32_t,
+ uint64_t) {
+ auto graph = make>(2, {{1, 1}, {0, 1}});
+ REQUIRE(make>(record()) == graph);
+ auto text = word_graph::to_kbmag_string(graph);
+ REQUIRE(text
+ == "word_graph := rec(\n"
+ " isFSA := true,\n"
+ " alphabet := rec(type := \"simple\", size := 2),\n"
+ " states := rec(type := \"simple\", size := 2),\n"
+ " flags := [\"DFA\"],\n"
+ " initial := [1],\n"
+ " accepting := [],\n"
+ " table := rec(format := \"dense deterministic\", "
+ "numTransitions := 4, transitions "
+ ":= [\n"
+ "[2,2],\n[1,2]\n])\n);\n");
+ REQUIRE(make>(text) == graph);
+ auto sparse
+ = word_graph::to_kbmag_string(graph, word_graph::kbmagformat::sparse);
+ REQUIRE(sparse.find("[[1,2],[2,2]],\n[[1,1],[2,2]]") != std::string::npos);
+ REQUIRE(make>(sparse) == graph);
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "001",
+ "KBMAG dimensions and labels",
+ "[quick][word-graph-io]") {
+ for (uint32_t n = 0; n < 8; ++n) {
+ for (uint32_t d = 0; d < 7; ++d) {
+ WordGraph graph(n, d);
+ for (uint32_t s = 0; s < n; ++s) {
+ for (uint32_t a = 0; a < d; ++a) {
+ if ((s + 3 * a) % 4 != 0) {
+ graph.target(s, a, (n + 2 * s - (a % n)) % n);
+ }
+ }
+ }
+ for (auto format : {word_graph::kbmagformat::dense,
+ word_graph::kbmagformat::sparse}) {
+ auto text = word_graph::to_kbmag_string(graph, format);
+ auto decoded = make>(text);
+ REQUIRE(decoded == graph);
+ REQUIRE(decoded.number_of_nodes() == n);
+ REQUIRE(decoded.out_degree() == d);
+ REQUIRE(text.find(n == 0 ? "initial := []" : "initial := [1]")
+ != std::string::npos);
+ }
+ }
+ }
+ // Isolated nodes and an entirely unused alphabet must not disappear.
+ WordGraph empty_edges(4, 7);
+ REQUIRE(make>(word_graph::to_kbmag_string(
+ empty_edges, word_graph::kbmagformat::sparse))
+ == empty_edges);
+ WordGraph parallel(1, 300);
+ for (uint16_t a = 0; a < 300; ++a) {
+ parallel.target(0, a, 0);
+ }
+ REQUIRE(make>(word_graph::to_kbmag_string(
+ parallel, word_graph::kbmagformat::sparse))
+ == parallel);
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "002",
+ "KBMAG syntax and sparse defaults",
+ "[quick][word-graph-io]") {
+ auto graph = make>(2, {{1, 1}, {0, 1}});
+ std::string text = R"( # Fields may contain GAP comments and whitespace.
+_RWS.wa := rec(
+ isFSA := true,
+ states := rec(type := "simple", size := 2),
+ alphabet := rec(type := "simple", size := 2),
+ extra := rec(values := ["comma, close) and # inside a string", [1, 2]]),
+ flags := ["DFA", "dense"],
+ accepting := [],
+ initial := [1..1],
+ table := rec(format := "sparse", numTransitions := 4,
+ defaultTarget := 2, transitions := [[], [[1,1]]]),
+ trailing := ["escaped \" quote", rec(a := true)]
+); # End of record.
+)";
+ REQUIRE(make>(text) == graph);
+ auto continued = replace(record(), "isFSA", "isF\\\nSA");
+ continued = replace(continued, "size:=2", "size:=\\\r\n2");
+ continued = replace(continued, "simple", "sim\\\r\nple");
+ REQUIRE(make>(continued) == graph);
+ auto partial = make>(record("[[,2],[1,-7]]"));
+ REQUIRE(partial.target(0, 0) == UNDEFINED);
+ REQUIRE(partial.target(0, 1) == 1);
+ REQUIRE(partial.target(1, 0) == 0);
+ REQUIRE(partial.target(1, 1) == UNDEFINED);
+ REQUIRE(make>(
+ record("[[2,2],[1,2]]", "dense deterministic"))
+ == graph);
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "003",
+ "KBMAG stream handling",
+ "[quick][word-graph-io]") {
+ auto text = record();
+ ChunkedInputBuffer buffer(text);
+ std::istream input(&buffer);
+ auto graph = make>(input);
+ REQUIRE(graph == make>(2, {{1, 1}, {0, 1}}));
+ std::ostringstream output;
+ output.imbue(std::locale(std::locale::classic(), new GroupedNumbers));
+ output << std::hex << std::showbase;
+ output.width(30);
+ auto flags = output.flags();
+ word_graph::write_kbmag(output, graph);
+ REQUIRE(output.str() == word_graph::to_kbmag_string(graph));
+ REQUIRE(output.flags() == flags);
+ REQUIRE(output.width() == 30);
+ FailingOutputBuffer failed_buffer;
+ std::ostream failed_output(&failed_buffer);
+ REQUIRE_EXCEPTION_MSG(word_graph::write_kbmag(failed_output, graph),
+ "failed to write KBMAG output to the stream");
+ failed_output.clear();
+ failed_output.exceptions(std::ios::badbit);
+ REQUIRE_THROWS_AS(word_graph::write_kbmag(failed_output, graph),
+ std::ios_base::failure);
+ std::istringstream failed_input(text);
+ failed_input.setstate(std::ios::badbit);
+ REQUIRE_THROWS_AS(make>(failed_input),
+ LibsemigroupsException);
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "004",
+ "KBMAG malformed records",
+ "[quick][word-graph-io]") {
+ check_invalid("", "expected 'rec' or a GAP variable assignment");
+ check_invalid(replace(record(), "isFSA:=true", "isFSA:=false"),
+ "expected 'true'");
+ check_invalid(replace(record(), "isFSA:=true,", ""),
+ "missing required field 'isFSA'");
+ check_invalid(replace(record(), "isFSA:=true", "isFSA:=true,isFSA:=true"),
+ "duplicate record field 'isFSA'");
+ check_invalid(
+ replace(record(), "type:=\"simple\"", "type:=\"identifiers\""),
+ "expected alphabet.type 'simple', found 'identifiers'");
+ check_invalid(replace(record(), "type:=\"simple\",", ""),
+ "requires both 'type' and 'size'");
+ check_invalid(replace(record(), "flags:=[\"DFA\"]", "flags:=[]"),
+ "include 'DFA'");
+ check_invalid(replace(record(), "\"DFA\"", "\"NFA\""),
+ "expected a DFA, found flag 'NFA'");
+ check_invalid(replace(record(), "initial:=[1]", "initial:=[2]"),
+ "expected initial state 1 (WordGraph node 0), found 2");
+ check_invalid(replace(record(), "initial:=[1]", "initial:=[1,2]"),
+ "expected initial list [1]");
+ check_invalid(replace(record(), "accepting:=[]", "accepting:=[1]"),
+ "word graphs have no accepting states");
+ check_invalid(record("[]", "dense nondeterministic"),
+ "expected table format");
+ check_invalid(
+ replace(record(), "transitions:=[[2,2],[1,2]]", "filename:=\"x\""),
+ "external transition files are unsupported");
+ check_invalid(replace(record(), "format:=\"dense deterministic\",", ""),
+ "expected table 'format'");
+ check_invalid(record() + " trailing", "expected end of input");
+ check_invalid(
+ replace(record(), "accepting:=[]", "accepting:=[],extra:=([)]"),
+ "unmatched delimiter");
+ check_invalid(record() + "\"unfinished", "unterminated quoted string");
+ // Truncation at every byte must either identify the error or be a complete
+ // record (only the full input is complete here).
+ auto text = record();
+ for (size_t i = 0; i < text.size(); ++i) {
+ CAPTURE(i);
+ REQUIRE_THROWS_AS(make>(text.substr(0, i)),
+ LibsemigroupsException);
+ }
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "005",
+ "KBMAG transition diagnostics",
+ "[quick][word-graph-io]") {
+ check_invalid(
+ record("[[3,2],[1,2]]"),
+ "expected dense target at most 2 at source 0, label 0, found 3");
+ check_invalid(record("[[1],[1,2]]"),
+ "expected 2 dense entries at source 0, found 1");
+ check_invalid(record("[[1,2,1],[1,2]]"),
+ "expected 2 dense entries at source 0; found additional");
+ check_invalid(record("[[1,2]]"), "expected 2 transition rows, found 1");
+ check_invalid(record("[[1,2],[1,2],[1,2]]"),
+ "expected 2 transition rows; found an additional row");
+ check_invalid(record("[[[0,1]],[]]", "sparse"),
+ "expected sparse label in [1, 2] at source 0, found 0");
+ check_invalid(record("[[[3,1]],[]]", "sparse"),
+ "expected sparse label in [1, 2] at source 0, found 3");
+ check_invalid(
+ record("[[[1,3]],[]]", "sparse"),
+ "expected sparse target in [1, 2] at source 0, label 1, found 3");
+ check_invalid(record("[[[1,0]],[]]", "sparse"),
+ "expected sparse target in [1, 2]");
+ check_invalid(record("[[[1,1],[1,2]],[]]", "sparse"),
+ "repeated sparse label 1 at source 0");
+ check_invalid(
+ replace(record(), "transitions:=", "numTransitions:=3,transitions:="),
+ "numTransitions declares 3 edges, but the table has 4");
+ check_invalid(
+ replace(record(), "transitions:=", "defaultTarget:=1,transitions:="),
+ "defaultTarget is only supported after format := 'sparse'");
+ check_invalid(replace(record("[[],[]]", "sparse"),
+ "transitions:=",
+ "defaultTarget:=3,transitions:="),
+ "expected defaultTarget in [1, 2], found 3");
+ auto invalid = WordGraph(2, 1);
+ invalid.target_no_checks(1, 0, 2);
+ REQUIRE_EXCEPTION_MSG(
+ (void) word_graph::to_kbmag_string(invalid),
+ "expected target less than 2 at source 1, label 0, found 2");
+ REQUIRE_EXCEPTION_MSG(
+ (void) word_graph::to_kbmag_string(
+ WordGraph(), static_cast(9)),
+ "expected dense or sparse KBMAG format, found 9");
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "006",
+ "KBMAG bounds before allocation",
+ "[quick][word-graph-io]") {
+ check_invalid(record("[]", "dense deterministic", "256", "1"),
+ "expected node count and alphabet size at most 255, "
+ "found 256 nodes and alphabet size 1");
+ check_invalid(record("[]", "dense deterministic", "1", "256"),
+ "expected node count and alphabet size at most 255, "
+ "found 1 nodes and alphabet size 256");
+ check_invalid(
+ record("[]", "dense deterministic", "2", "18446744073709551616"),
+ "64-bit integer range");
+ check_invalid(record("[]", "dense deterministic", "2", "-1"),
+ "alphabet.size to be a nonnegative integer");
+ check_invalid(
+ record("[]", "dense deterministic", "4294967296", "4294967296"),
+ "exceeds the maximum storage size");
+ REQUIRE_EXCEPTION_MSG(
+ (void) word_graph::to_kbmag_string(WordGraph(256, 0)),
+ "expected KBMAG node count and out-degree at most 255, found 256 nodes "
+ "and out-degree 0");
+ }
+
+ LIBSEMIGROUPS_TEST_CASE("WordGraphIO",
+ "007",
+ "KBMAG buffered output",
+ "[quick][word-graph-io]") {
+ WordGraph graph(7000, 5);
+ for (uint32_t s = 0; s < 7000; ++s) {
+ for (uint32_t a = 0; a < 5; ++a) {
+ graph.target(s, a, (3 * s + a) % 7000);
+ }
+ }
+ for (auto format :
+ {word_graph::kbmagformat::dense, word_graph::kbmagformat::sparse}) {
+ auto text = word_graph::to_kbmag_string(graph, format);
+ REQUIRE(text.size() > 65536);
+ REQUIRE(make>(text) == graph);
+ }
+ }
+} // namespace libsemigroups