From e8d642c73dbbc42ccfbb3d2625233d0bf8e1f257 Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Thu, 24 Sep 2026 10:10:05 +0100 Subject: [PATCH] Add KBMAG serialization for WordGraph Read and write dense deterministic and sparse KBMAG records through streams or strings. Preserve transition labels, undefined transitions, parallel targets, isolated nodes, and the alphabet size. Use initial state 1 for nonempty graphs and an empty accepting list. Document the encoding and parsing algorithms and the supported numeric KBMAG subset. Report invalid values, fields, sources, and input locations. Include round-trip, malformed-input, stream, and documentation tests. Validated the quick WordGraphIO, WordGraph, and WordGraphView suites, documentation examples and build, pre-push hooks, and 24 round trips through KBMAG's fsafilter in dense and sparse formats. AI assistance: OpenAI Codex implemented the code, documentation, tests, and commit message. Co-authored-by: Codex OpenAI --- Makefile.am | 11 + include/libsemigroups/detail/kbmag.hpp | 102 ++++ include/libsemigroups/libsemigroups.hpp | 2 + include/libsemigroups/word-graph-io.hpp | 190 ++++++++ include/libsemigroups/word-graph-io.tpp | 161 +++++++ include/libsemigroups/word-graph.hpp | 3 +- src/detail/kbmag.cpp | 598 ++++++++++++++++++++++++ tests/test-docs-code-examples.cpp | 31 +- tests/test-word-graph-io.cpp | 381 +++++++++++++++ 9 files changed, 1469 insertions(+), 10 deletions(-) create mode 100644 include/libsemigroups/detail/kbmag.hpp create mode 100644 include/libsemigroups/word-graph-io.hpp create mode 100644 include/libsemigroups/word-graph-io.tpp create mode 100644 src/detail/kbmag.cpp create mode 100644 tests/test-word-graph-io.cpp 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