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