From 21d05084b459e19e2ff7d8e1b6e508bf5bd7fdd8 Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Tue, 24 Feb 2026 12:15:44 +0000 Subject: [PATCH 1/4] presentation: attempt to resolve #391 Assisted-by: Codex OpenAI --- .../presentation/__init__.py | 6 +- src/present.cpp | 299 ++++++++++++++++- tests/test_presentation_rules.py | 300 ++++++++++++++++++ 3 files changed, 594 insertions(+), 11 deletions(-) create mode 100644 tests/test_presentation_rules.py diff --git a/src/libsemigroups_pybind11/presentation/__init__.py b/src/libsemigroups_pybind11/presentation/__init__.py index 50c436ae..4350f236 100644 --- a/src/libsemigroups_pybind11/presentation/__init__.py +++ b/src/libsemigroups_pybind11/presentation/__init__.py @@ -169,13 +169,13 @@ def __init__(self: _Self, *args, **kwargs) -> None: @_copydoc(_PresentationWord.rules) @property - def rules(self: _Self) -> list[list[int] | str]: + def rules(self: _Self) -> list[list[int]] | list[str]: # pylint: disable=missing-function-docstring return _to_cxx(self).rules @rules.setter - def rules(self: _Self, val: list[list[int] | str]) -> None: - _to_cxx(self).rules = val + def rules(self: _Self, val: list[list[int]] | list[str]) -> None: + _to_cxx(self).rules = list(val) _copy_cxx_mem_fns(_PresentationWord, Presentation) diff --git a/src/present.cpp b/src/present.cpp index 3de09b2b..17d4924b 100644 --- a/src/present.cpp +++ b/src/present.cpp @@ -20,8 +20,12 @@ #include // for size_t // C++ stl headers.... -#include // for make_unique -#include // for string, basic_string, oper... +#include // for count, find +#include // for make_unique +#include // for runtime_error +#include // for string, basic_string, oper... +#include // for move +#include // for vector // libsemigroups.... #include // for operator==, UNDEFINED @@ -30,6 +34,7 @@ #include // for Presentation #include // for is_sorted #include // for word_type +#include // for operator+ // pybind11.... #include // for arg @@ -47,6 +52,240 @@ namespace libsemigroups { namespace py = pybind11; namespace { + // Only Presentation.rules creates these non-owning views. The property + // getter keeps the presentation alive; independent results are Python + // lists. AI assistance: OpenAI Codex helped implement and test these + // bindings. + template + class RulesView { + std::vector* _rules; + + public: + explicit RulesView(std::vector& rules) : _rules(&rules) {} + + std::vector& vector() const { + return *_rules; + } + }; + + struct RulesSlice { + py::ssize_t start, stop, step, length; + + RulesSlice(py::slice const& slice, size_t size) { + if (!slice.compute(size, &start, &stop, &step, &length)) { + throw py::error_already_set(); + } + } + }; + + template + std::vector copy_words(py::iterable const& words) { + std::vector result; + result.reserve(py::len_hint(words)); + for (auto word : words) { + result.push_back(word.cast()); + } + return result; + } + + template + void bind_rules_view(py::module& m, std::string const& name) { + using View = RulesView; + using Vector = std::vector; + using Index = py::ssize_t; + + auto wrap_index = [](Index i, size_t size) { + if (i < 0) { + i += static_cast(size); + } + if (i < 0 || static_cast(i) >= size) { + throw py::index_error(); + } + return i; + }; + + auto equals = [](View const& self, py::object other) { + if (!py::isinstance(other)) { + return false; + } + auto const sequence = other.cast(); + if (sequence.size() != self.vector().size()) { + return false; + } + for (size_t i = 0; i < sequence.size(); ++i) { + if (self.vector()[i] != sequence[i].cast()) { + return false; + } + } + return true; + }; + + py::class_(m, name.c_str()) + .def("__len__", [](View const& self) { return self.vector().size(); }) + .def("__bool__", + [](View const& self) { return !self.vector().empty(); }) + .def("__repr__", + [](View const& self) { + return py::repr(py::cast(self.vector())); + }) + .def("__eq__", equals) + .def("__ne__", + [equals](View const& self, py::object other) { + return !equals(self, other); + }) + .def("__getitem__", + [wrap_index](View const& self, Index i) -> Word { + return self.vector()[wrap_index(i, self.vector().size())]; + }) + .def( + "__getitem__", + [](View const& self, py::slice const& slice) { + RulesSlice indices(slice, self.vector().size()); + Vector result; + result.reserve(indices.length); + for (Index i = 0; i < indices.length; ++i) { + result.push_back( + self.vector()[indices.start + i * indices.step]); + } + return result; + }, + py::arg("s")) + .def("__iter__", + [](py::object self) { + // Index-based iteration retains the view and cannot hold a C++ + // iterator invalidated by replacing or resizing the rules + // vector. + auto* it = PySeqIter_New(self.ptr()); + if (it == nullptr) { + throw py::error_already_set(); + } + return py::reinterpret_steal(it); + }) + .def("__setitem__", + [wrap_index](View& self, Index i, Word const& word) { + self.vector()[wrap_index(i, self.vector().size())] = word; + }) + .def("__setitem__", + [](View& self, py::slice const& slice, Vector const& words) { + RulesSlice indices(slice, self.vector().size()); + if (static_cast(indices.length) != words.size()) { + throw std::runtime_error("Left and right hand size of slice " + "assignment have different sizes!"); + } + for (Index i = 0; i < indices.length; ++i) { + self.vector()[indices.start + i * indices.step] = words[i]; + } + }) + .def("__delitem__", + [wrap_index](View& self, Index i) { + auto& rules = self.vector(); + rules.erase(rules.begin() + wrap_index(i, rules.size())); + }) + .def("__delitem__", + [](View& self, py::slice const& slice) { + auto& rules = self.vector(); + RulesSlice indices(slice, rules.size()); + if (indices.length == 0) { + return; + } + if (indices.length == 1) { + rules.erase(rules.begin() + indices.start); + return; + } + if (indices.step < 0) { + indices.start += (indices.length - 1) * indices.step; + indices.step = -indices.step; + } + if (indices.step == 1) { + rules.erase(rules.begin() + indices.start, + rules.begin() + indices.start + indices.length); + } else { + // Delete in descending index order so remaining indices stay + // valid. + for (Index i = indices.length; i > 0; --i) { + rules.erase(rules.begin() + indices.start + + (i - 1) * indices.step); + } + } + }) + .def( + "append", + [](View& self, Word const& word) { + self.vector().push_back(word); + }, + py::arg("x")) + .def( + "extend", + [](View& self, py::iterable const& words) { + // Copy first: words may be this view, another view of the same + // presentation, or an iterator over either of them. + auto copy = copy_words(words); + auto& rules = self.vector(); + rules.insert(rules.end(), copy.begin(), copy.end()); + }, + py::arg("L")) + .def( + "insert", + [](View& self, Index i, Word const& word) { + auto& rules = self.vector(); + if (i < 0) { + i += static_cast(rules.size()); + } + if (i < 0 || static_cast(i) > rules.size()) { + throw py::index_error(); + } + rules.insert(rules.begin() + i, word); + }, + py::arg("i"), + py::arg("x")) + .def( + "pop", + [wrap_index](View& self, Index i) { + auto& rules = self.vector(); + i = wrap_index(i, rules.size()); + Word word = std::move(rules[i]); + rules.erase(rules.begin() + i); + return word; + }, + py::arg("i") = -1) + .def("clear", [](View& self) { self.vector().clear(); }) + .def( + "count", + [](View const& self, Word const& word) { + auto const& rules = self.vector(); + return std::count(rules.begin(), rules.end(), word); + }, + py::arg("x")) + .def( + "remove", + [](View& self, Word const& word) { + auto& rules = self.vector(); + auto it = std::find(rules.begin(), rules.end(), word); + if (it == rules.end()) { + throw py::value_error(); + } + rules.erase(it); + }, + py::arg("x")) + .def( + "__contains__", + [](View const& self, Word const& word) { + auto const& rules = self.vector(); + return std::find(rules.begin(), rules.end(), word) + != rules.end(); + }, + py::arg("x")) + .def("__add__", [](View const& self, py::object other) { + if (!py::isinstance(other)) { + throw py::type_error("unsupported operand type(s) for +"); + } + Vector result(self.vector()); + auto other_words = copy_words(other.cast()); + result.insert(result.end(), other_words.begin(), other_words.end()); + return result; + }); + } + template void bind_present(py::module& m, std::string const& name) { using Presentation_ = Presentation; @@ -72,18 +311,58 @@ available in the module :any:`libsemigroups_pybind11.presentation`.)pbdoc"); [](Presentation_ const& lhop, Presentation_ rhop) -> bool { return lhop == rhop; }); - thing.def_readwrite("rules", - &Presentation_::rules, - R"pbdoc( -Data member holding the rules of the presentation. -The rules can be altered using the member functions of ``list``, and the -presentation can be checked for validity using :any:`throw_if_bad_alphabet_or_rules`.)pbdoc"); + thing.def_property( + "rules", + py::cpp_function( + [](Presentation_& self) { return RulesView(self.rules); }, + py::return_value_policy::move, + py::keep_alive<0, 1>()), + [](Presentation_& self, std::vector rules) { + self.rules = std::move(rules); + }, + R"pbdoc( +Mutable view of the rules of the presentation. + +The view supports indexing, slicing, iteration, comparison, and the methods +``append``, ``extend``, ``insert``, ``pop``, ``clear``, ``count``, and ``remove``. +Changes to the view change the presentation. Assigning an iterable to this +property replaces the rules, and existing views see the replacement. The view +keeps the presentation alive. + +Use ``list(p.rules)`` to copy the rules. Slices and concatenations also return +ordinary Python lists. Views cannot be constructed independently of a +presentation. Individual words are returned as Python strings or lists; +changing an integer inside a returned list does not change the presentation. +Assign a whole word to change a rule. + +Slice assignment accepts Python lists and other sequences of words. The +replacement must have the same length as the slice. The view does not provide +every Python ``list`` method. + +The presentation can be checked for validity using +:any:`throw_if_bad_alphabet_or_rules`. + +.. doctest:: + + >>> from libsemigroups_pybind11 import Presentation + >>> p = Presentation("ab") + >>> p.rules = ["aa", "a"] + >>> rules = p.rules + >>> rules[0] = "ab" + >>> p.rules + ['ab', 'a'] + >>> rules.append("bb") + >>> p.rules + ['ab', 'a', 'bb'] +)pbdoc"); + thing.def(py::init<>(), R"pbdoc( :sig=(self: Presentation) -> None: Default constructor. Constructs an empty presentation with no rules and no alphabet.)pbdoc"); + thing.def( "copy", [](Presentation_ const& self) { @@ -97,9 +376,11 @@ Copy a :any:`Presentation` object. :returns: A copy. :rtype: Presentation )pbdoc"); + thing.def("__copy__", [](Presentation_ const& that) { return std::make_unique(that); }); + thing.def( "alphabet", [](Presentation_ const& self) { return self.alphabet(); }, @@ -2158,6 +2439,8 @@ defined in the alphabet, and that the inverses act as semigroup inverses. } // namespace void init_present(py::module& m) { + bind_rules_view(m, "RulesWord"); + bind_rules_view(m, "RulesString"); bind_present(m, "PresentationWord"); bind_present(m, "PresentationString"); } diff --git a/tests/test_presentation_rules.py b/tests/test_presentation_rules.py new file mode 100644 index 00000000..67f188cc --- /dev/null +++ b/tests/test_presentation_rules.py @@ -0,0 +1,300 @@ +# Copyright (c) 2026, James D. Mitchell +# +# Distributed under the terms of the GPL license version 3. +# +# The full license is in the file LICENSE, distributed with this software. +# AI assistance: OpenAI Codex helped implement these regression tests. + +# pylint: disable=missing-function-docstring + +"""Mutable presentation rules and their interaction with ordinary vector bindings.""" + +import gc + +import pytest + +from libsemigroups_pybind11 import ( + UNDEFINED, + Forest, + InversePresentation, + Presentation, + SimsRefinerFaithful, + forest, + presentation, +) + +pytestmark = pytest.mark.quick + + +@pytest.fixture(name="presentation_type", params=[Presentation, InversePresentation]) +def presentation_type_fixture(request): + return request.param + + +@pytest.fixture(name="words", params=[str, list]) +def words_fixture(request): + if request.param is str: + return ["a", "b", "ab", ""] + return [[0], [1], [0, 1], []] + + +@pytest.fixture(name="p") +def presentation_fixture(presentation_type, words): + result = presentation_type("ab" if isinstance(words[0], str) else [0, 1]) + result.rules = words + return result + + +def test_rules_access_and_comparison(p, words): + rules = p.rules + assert len(rules) == 4 + assert rules + assert list(rules) == words + assert rules[0] == words[0] + assert rules[-1] == words[-1] + assert rules == words + assert rules == tuple(words) + if isinstance(words[0], list): + assert rules == tuple(tuple(word) for word in words) + assert rules == p.rules + assert not rules != p.rules # noqa: SIM202 - exercise __ne__ separately + assert rules != words[:-1] + assert rules != object() + assert words[0] in rules + assert rules.count(words[0]) == 1 + assert repr(rules) == repr(words) + + +def test_rules_mutation(p, words): + rules = p.rules + rules[0] = words[1] + rules[-1] = words[2] + assert p.rules == [words[1], words[1], words[2], words[2]] + rules.append(words[0]) + rules.insert(0, words[3]) + rules.insert(-1, words[3]) + rules.insert(len(rules), words[1]) + assert p.rules == [ + words[3], + words[1], + words[1], + words[2], + words[2], + words[3], + words[0], + words[1], + ] + assert rules.pop() == words[1] + assert rules.pop(-2) == words[3] + assert rules.pop(0) == words[3] + rules.remove(words[1]) + del rules[-1] + assert p.rules == [words[1], words[2], words[2]] + assert rules.count(words[2]) == 2 + assert words[3] not in rules + with pytest.raises(ValueError): + rules.remove(words[3]) + rules.clear() + assert not rules + assert p.rules == [] + + +def test_rules_extend(p, words): + rules = p.rules + rules.extend(words) + rules.extend(iter(words)) + rules.extend(p.rules) + assert p.rules == words * 6 + rules.extend(iter(rules)) + assert p.rules == words * 12 + + +def test_rules_copies_and_slices_are_lists(p, words): + rules = p.rules + for independent in (list(rules), rules[:], rules[::-1]): + assert isinstance(independent, list) + expected = list(independent) + independent.append(words[0]) + assert list(independent) == expected + [words[0]] + assert p.rules == words + assert rules[1::2] == words[1::2] + assert rules[::-1] == words[::-1] + assert rules[1:1] == [] + + +def test_rules_cannot_be_constructed_independently(p, words): + rules_type = type(p.rules) + for args in ((), (words,), (p.rules,)): + with pytest.raises(TypeError): + rules_type(*args) + + +def test_rules_slice_assignment_and_deletion(p, words): + rules = p.rules + rules[1:3] = [words[0], words[1]] + assert p.rules == [words[0], words[0], words[1], words[3]] + rules[::-2] = (words[2], words[3]) + assert p.rules == [words[0], words[3], words[1], words[2]] + with pytest.raises(RuntimeError, match="different sizes"): + rules[1:2] = [] + with pytest.raises(TypeError): + rules[1:2] = [object()] + del rules[1::2] + assert p.rules == [words[0], words[1]] + del rules[::-1] + assert p.rules == [] + + +def test_rules_slice_assignment_from_an_alias(p, words): + p.rules[::-1] = p.rules + assert p.rules == words[::-1] + + +@pytest.mark.parametrize( + "selection", + [ + slice(None), + slice(None, None, -1), + slice(1, None, 2), + slice(None, None, -2), + slice(-3, -1), + slice(3, 0, -1), + slice(0, 3, -1), + slice(-100, 100, 3), + slice(100, -100, -3), + slice(None, None, 10**100), + slice(None, None, -(10**100)), + ], +) +def test_rules_slices_match_lists(p, words, selection): + expected = list(words) + assert p.rules[selection] == expected[selection] + replacement = list(reversed(expected[selection])) + p.rules[selection] = replacement + expected[selection] = replacement + assert p.rules == expected + del expected[selection] + del p.rules[selection] + assert p.rules == expected + + +def test_rules_concatenation(p, words): + rules = p.rules + for other in (words, tuple(words), p.rules): + combined = rules + other + assert isinstance(combined, list) + assert combined == words * 2 + combined.clear() + assert p.rules == words + p.rules += words + assert rules == words * 2 + with pytest.raises(TypeError): + _ = rules + 1 + + +def test_rules_views_follow_replacement_and_cpp_mutation(p, words): + rules = p.rules + other_view = p.rules + p.rules = iter(words[:2]) + assert rules == words[:2] + assert other_view == words[:2] + p.contains_empty_word(True) + presentation.add_rule(p, words[2], words[3]) + assert rules == words + p.rules = rules + assert rules == words + p.init() + assert rules == [] + rules.append(words[0]) + assert p.rules == words[:1] + + +def test_rules_view_keeps_presentation_alive(presentation_type, words): + p = presentation_type("ab" if isinstance(words[0], str) else [0, 1]) + p.rules = words + rules = p.rules + del p + gc.collect() + assert rules == words + rules.append(words[0]) + assert rules == words + words[:1] + + +def test_rules_iterator_keeps_presentation_alive(presentation_type, words): + p = presentation_type("ab" if isinstance(words[0], str) else [0, 1]) + p.rules = words + iterator = iter(p.rules) + del p + gc.collect() + assert list(iterator) == words + + +def test_rules_iterator_survives_replacement(p, words): + iterator = iter(p.rules) + assert next(iterator) == words[0] + p.rules = words * 20 + assert list(iterator) == (words * 20)[1:] + iterator = iter(p.rules) + p.rules.clear() + with pytest.raises(StopIteration): + next(iterator) + + +@pytest.mark.parametrize("index", [-5, 4]) +def test_rules_invalid_indices(p, words, index): + rules = p.rules + with pytest.raises(IndexError): + _ = rules[index] + with pytest.raises(IndexError): + rules[index] = words[0] + with pytest.raises(IndexError): + del rules[index] + with pytest.raises(IndexError): + rules.pop(index) + assert p.rules == words + + +def test_rules_invalid_values(p, words): + rules = p.rules + with pytest.raises(TypeError): + rules.append(object()) + with pytest.raises(TypeError): + rules[0] = object() + with pytest.raises(TypeError): + p.rules = [object()] + with pytest.raises(TypeError): + rules[:2] = [words[1], object()] + with pytest.raises(RuntimeError): + rules.extend([words[0], object()]) + assert p.rules == words + with pytest.raises(IndexError): + rules.insert(5, words[0]) + with pytest.raises(ValueError): + _ = rules[::0] + rules.clear() + with pytest.raises(IndexError): + rules.pop() + + +def test_integer_rule_elements_are_copies(): + p = Presentation([0, 1]) + p.rules = [[0], [1]] + word = p.rules[0] + word.append(1) + assert p.rules == [[0], [1]] + p.rules[0] = word + assert p.rules == [[0, 1], [1]] + + +def test_other_vector_bindings_still_convert_lists(): + tree = Forest([UNDEFINED, 0], [UNDEFINED, 0]) + assert "a" in str(forest.dot(tree, ["a"])) + forbidden = [[0], [0, 1]] + refiner = SimsRefinerFaithful(forbidden) + result = refiner.forbid() + assert isinstance(result, list) + assert result == forbidden + result.clear() + assert refiner.forbid() == forbidden + refiner.init([[1], []]) + assert refiner.forbid() == [[1], []] From 310340d7b121ab2edb45cf6a30ad9697bfb14072 Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Thu, 17 Sep 2026 14:46:20 +0100 Subject: [PATCH 2/4] Updates from code review Assisted-by: Codex OpenAI --- src/present.cpp | 103 +++++++++++++++++++---------- tests/test_presentation_rules.py | 109 +++++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 40 deletions(-) diff --git a/src/present.cpp b/src/present.cpp index 17d4924b..2296946c 100644 --- a/src/present.cpp +++ b/src/present.cpp @@ -20,9 +20,9 @@ #include // for size_t // C++ stl headers.... -#include // for count, find +#include // for clamp, count, find #include // for make_unique -#include // for runtime_error +#include // for invalid_argument #include // for string, basic_string, oper... #include // for move #include // for vector @@ -53,9 +53,17 @@ namespace libsemigroups { namespace { // Only Presentation.rules creates these non-owning views. The property - // getter keeps the presentation alive; independent results are Python - // lists. AI assistance: OpenAI Codex helped implement and test these - // bindings. + // getter keeps the presentation alive. The reason that we use RulesView + // and RulesSlice instead of making std::vector> or + // std::vector> pybind11 opaque types is the + // following. If declaring e.g. std::vector> opaque, + // then it has to be opaque in every translation unit, which we don't want + // (this is apparently a pybind11 requirement). So, instead we use these + // RulesView and RulesSlice structs. This means that other functions that + // return std::vector are copied and converted to python lists on every call + // to that function. + // + // AI assistance: OpenAI Codex helped implement and test these bindings. template class RulesView { std::vector* _rules; @@ -99,7 +107,7 @@ namespace libsemigroups { i += static_cast(size); } if (i < 0 || static_cast(i) >= size) { - throw py::index_error(); + throw py::index_error("list index out of range"); } return i; }; @@ -112,10 +120,14 @@ namespace libsemigroups { if (sequence.size() != self.vector().size()) { return false; } - for (size_t i = 0; i < sequence.size(); ++i) { - if (self.vector()[i] != sequence[i].cast()) { - return false; + try { + for (size_t i = 0; i < sequence.size(); ++i) { + if (self.vector()[i] != sequence[i].cast()) { + return false; + } } + } catch (py::cast_error const&) { + return false; } return true; }; @@ -167,13 +179,25 @@ namespace libsemigroups { }) .def("__setitem__", [](View& self, py::slice const& slice, Vector const& words) { - RulesSlice indices(slice, self.vector().size()); + auto& rules = self.vector(); + RulesSlice indices(slice, rules.size()); if (static_cast(indices.length) != words.size()) { - throw std::runtime_error("Left and right hand size of slice " - "assignment have different sizes!"); + if (indices.step == 1) { + // An empty slice can have stop < start, so use length. + auto first = rules.erase(rules.begin() + indices.start, + rules.begin() + indices.start + + indices.length); + rules.insert(first, words.begin(), words.end()); + return; + } + throw std::invalid_argument( + fmt::format("attempt to assign sequence of size {} to " + "extended slice of size {}", + words.size(), + indices.length)); } for (Index i = 0; i < indices.length; ++i) { - self.vector()[indices.start + i * indices.step] = words[i]; + rules[indices.start + i * indices.step] = words[i]; } }) .def("__delitem__", @@ -185,13 +209,10 @@ namespace libsemigroups { [](View& self, py::slice const& slice) { auto& rules = self.vector(); RulesSlice indices(slice, rules.size()); + // Normalizing an empty reverse slice can overflow the index. if (indices.length == 0) { return; } - if (indices.length == 1) { - rules.erase(rules.begin() + indices.start); - return; - } if (indices.step < 0) { indices.start += (indices.length - 1) * indices.step; indices.step = -indices.step; @@ -202,6 +223,7 @@ namespace libsemigroups { } else { // Delete in descending index order so remaining indices stay // valid. + // TODO use remove_if for (Index i = indices.length; i > 0; --i) { rules.erase(rules.begin() + indices.start + (i - 1) * indices.step); @@ -227,13 +249,12 @@ namespace libsemigroups { .def( "insert", [](View& self, Index i, Word const& word) { - auto& rules = self.vector(); + auto& rules = self.vector(); + auto const size = static_cast(rules.size()); if (i < 0) { - i += static_cast(rules.size()); - } - if (i < 0 || static_cast(i) > rules.size()) { - throw py::index_error(); + i += size; } + i = std::clamp(i, Index{0}, size); rules.insert(rules.begin() + i, word); }, py::arg("i"), @@ -262,7 +283,7 @@ namespace libsemigroups { auto& rules = self.vector(); auto it = std::find(rules.begin(), rules.end(), word); if (it == rules.end()) { - throw py::value_error(); + throw py::value_error("list.remove(x): x not in list"); } rules.erase(it); }, @@ -322,23 +343,24 @@ available in the module :any:`libsemigroups_pybind11.presentation`.)pbdoc"); self.rules = std::move(rules); }, R"pbdoc( -Mutable view of the rules of the presentation. +The rules of the presentation. -The view supports indexing, slicing, iteration, comparison, and the methods -``append``, ``extend``, ``insert``, ``pop``, ``clear``, ``count``, and ``remove``. -Changes to the view change the presentation. Assigning an iterable to this -property replaces the rules, and existing views see the replacement. The view -keeps the presentation alive. +For technical reasons, the object contained in this property is not really a +Python list, but some effort has been put into making it behave exactly like a +Python list in many cases. Use ``list(p.rules)`` to copy the rules. Slices and concatenations also return -ordinary Python lists. Views cannot be constructed independently of a -presentation. Individual words are returned as Python strings or lists; -changing an integer inside a returned list does not change the presentation. -Assign a whole word to change a rule. +ordinary Python lists. Individual words in ``p.rules`` are returned as +Python strings or lists; if the type of words in the presentation is +``list[int]``, then changing an integer inside an individual word in +``p.rules`` does not change the rules in the presentation. Assign a whole +word to change a rule. Slice assignment accepts Python lists and other sequences of words. The -replacement must have the same length as the slice. The view does not provide -every Python ``list`` method. +replacement can have a different length when the slice step is ``1`` (the +default), growing or shrinking the rules as needed. For any other step, the +replacement must have the same length as the slice. Assigning to an empty slice +with step ``1`` inserts words at the start of the slice. The presentation can be checked for validity using :any:`throw_if_bad_alphabet_or_rules`. @@ -355,6 +377,15 @@ The presentation can be checked for validity using >>> rules.append("bb") >>> p.rules ['ab', 'a', 'bb'] + >>> rules[1:2] = ["a", "ba", "b"] + >>> p.rules + ['ab', 'a', 'ba', 'b', 'bb'] + >>> rules[1:4] = [] + >>> p.rules + ['ab', 'bb'] + >>> rules[1:1] = ["aa", "a"] + >>> p.rules + ['ab', 'aa', 'a', 'bb'] )pbdoc"); thing.def(py::init<>(), R"pbdoc( @@ -2436,7 +2467,7 @@ defined in the alphabet, and that the inverses act as semigroup inverses. py::arg("p"), py::prepend()); } // bind_inverse_present - } // namespace + } // namespace void init_present(py::module& m) { bind_rules_view(m, "RulesWord"); diff --git a/tests/test_presentation_rules.py b/tests/test_presentation_rules.py index 67f188cc..ee5d6004 100644 --- a/tests/test_presentation_rules.py +++ b/tests/test_presentation_rules.py @@ -10,6 +10,7 @@ """Mutable presentation rules and their interaction with ordinary vector bindings.""" import gc +import sys import pytest @@ -65,6 +66,28 @@ def test_rules_access_and_comparison(p, words): assert repr(rules) == repr(words) +@pytest.mark.parametrize("other_word", ["a", [0, 1], None, object(), 0, [object()]]) +def test_rules_comparison_with_incompatible_elements(p, words, other_word): + p.rules = words[:1] + other = [other_word] + assert (p.rules == other) == (words[:1] == other) + assert (other == p.rules) == (other == words[:1]) + assert (p.rules != other) == (words[:1] != other) + assert (other != p.rules) == (other != words[:1]) + assert p.rules == words[:1] + + +def test_rules_comparison_with_incompatible_views(presentation_type): + strings = presentation_type("ab") + strings.rules = ["a"] + integers = presentation_type([0, 1]) + integers.rules = [[0, 1]] + assert not strings.rules == integers.rules # noqa: SIM201 - exercise __eq__ + assert not integers.rules == strings.rules # noqa: SIM201 - exercise __eq__ + assert strings.rules != integers.rules + assert integers.rules != strings.rules + + def test_rules_mutation(p, words): rules = p.rules rules[0] = words[1] @@ -99,6 +122,20 @@ def test_rules_mutation(p, words): assert p.rules == [] +@pytest.mark.parametrize( + "index", [-sys.maxsize - 1, -100, -5, -4, -1, 0, 1, 4, 5, 100, sys.maxsize] +) +@pytest.mark.parametrize("initially_empty", [False, True]) +def test_rules_insert_matches_lists(p, words, index, initially_empty): + expected = [] if initially_empty else list(words) + p.rules = expected + rules = p.rules + expected.insert(index, words[2]) + assert rules.insert(index, words[2]) is None + assert rules == expected + assert p.rules == expected + + def test_rules_extend(p, words): rules = p.rules rules.extend(words) @@ -135,8 +172,8 @@ def test_rules_slice_assignment_and_deletion(p, words): assert p.rules == [words[0], words[0], words[1], words[3]] rules[::-2] = (words[2], words[3]) assert p.rules == [words[0], words[3], words[1], words[2]] - with pytest.raises(RuntimeError, match="different sizes"): - rules[1:2] = [] + with pytest.raises(ValueError, match="sequence of size 0 to extended slice of size 2"): + rules[1::2] = [] with pytest.raises(TypeError): rules[1:2] = [object()] del rules[1::2] @@ -150,6 +187,64 @@ def test_rules_slice_assignment_from_an_alias(p, words): assert p.rules == words[::-1] +@pytest.mark.parametrize( + "selection", + [ + slice(None), + slice(1, 3), + slice(1, 3, 1), + slice(2, 2), + slice(3, 1), + slice(-3, -1), + slice(-100, 100), + slice(100, 200), + slice(-100, -50), + ], +) +@pytest.mark.parametrize("replacement_size", [0, 1, 6]) +@pytest.mark.parametrize("initially_empty", [False, True]) +def test_rules_slice_assignment_resizes_like_lists( + p, words, selection, replacement_size, initially_empty +): + expected = [] if initially_empty else list(words) + p.rules = expected + rules = p.rules + replacement = (words * 2)[:replacement_size] + expected[selection] = replacement + rules[selection] = replacement + assert rules == expected + assert p.rules == expected + + +@pytest.mark.parametrize("selection", [slice(1, 2), slice(2, 2), slice(3, 1)]) +@pytest.mark.parametrize("replacement_type", [list, tuple]) +def test_rules_slice_assignment_resizes_from_a_sequence(p, words, selection, replacement_type): + expected = list(words) + expected[selection] = expected + rules = p.rules + rules[selection] = replacement_type(p.rules) + assert rules == expected + assert p.rules == expected + + +def test_rules_slice_assignment_resizes_from_a_view(p, words): + expected = list(words) + expected[1:2] = expected + p.rules[1:2] = p.rules + assert p.rules == expected + + +@pytest.mark.parametrize("selection", [slice(None, None, 2), slice(None, None, -1), slice(2, 2, 2)]) +def test_rules_extended_slice_assignment_rejects_size_mismatch(p, words, selection): + expected = list(words) + with pytest.raises(ValueError) as list_error: + expected[selection] = words[:1] + with pytest.raises(ValueError) as rules_error: + p.rules[selection] = words[:1] + assert str(rules_error.value) == str(list_error.value) + assert p.rules == expected + + @pytest.mark.parametrize( "selection", [ @@ -164,6 +259,7 @@ def test_rules_slice_assignment_from_an_alias(p, words): slice(100, -100, -3), slice(None, None, 10**100), slice(None, None, -(10**100)), + slice(3, 3, -(10**100)), ], ) def test_rules_slices_match_lists(p, words, selection): @@ -264,13 +360,18 @@ def test_rules_invalid_values(p, words): p.rules = [object()] with pytest.raises(TypeError): rules[:2] = [words[1], object()] + with pytest.raises(TypeError): + rules[:1] = [words[1], object()] with pytest.raises(RuntimeError): rules.extend([words[0], object()]) assert p.rules == words - with pytest.raises(IndexError): - rules.insert(5, words[0]) + with pytest.raises(TypeError): + rules.insert(5, object()) with pytest.raises(ValueError): _ = rules[::0] + with pytest.raises(ValueError): + rules[::0] = words + assert p.rules == words rules.clear() with pytest.raises(IndexError): rules.pop() From df84397f5f53aeb67151d6a6b4ccbe3cb22ece7e Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Thu, 17 Sep 2026 15:33:05 +0100 Subject: [PATCH 3/4] Add missing list functions Assisted-by: Codex OpenAI --- src/present.cpp | 148 +++++++++++++++++--- tests/test_presentation_rules.py | 223 +++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 15 deletions(-) diff --git a/src/present.cpp b/src/present.cpp index 2296946c..5e060dc8 100644 --- a/src/present.cpp +++ b/src/present.cpp @@ -20,7 +20,8 @@ #include // for size_t // C++ stl headers.... -#include // for clamp, count, find +#include // for clamp, count, find, reverse +#include // for numeric_limits #include // for make_unique #include // for invalid_argument #include // for string, basic_string, oper... @@ -132,6 +133,24 @@ namespace libsemigroups { return true; }; + auto compare = [](View const& self, py::object other, int op) { + if (py::isinstance>(other)) { + other = py::cast(other.cast const&>().vector()); + } else if (py::isinstance>(other)) { + other + = py::cast(other.cast const&>().vector()); + } + if (!py::isinstance(other)) { + return py::reinterpret_borrow(Py_NotImplemented); + } + auto lhs = py::cast(self.vector()); + auto* result = PyObject_RichCompare(lhs.ptr(), other.ptr(), op); + if (result == nullptr) { + throw py::error_already_set(); + } + return py::reinterpret_steal(result); + }; + py::class_(m, name.c_str()) .def("__len__", [](View const& self) { return self.vector().size(); }) .def("__bool__", @@ -145,6 +164,22 @@ namespace libsemigroups { [equals](View const& self, py::object other) { return !equals(self, other); }) + .def("__lt__", + [compare](View const& self, py::object other) { + return compare(self, other, Py_LT); + }) + .def("__le__", + [compare](View const& self, py::object other) { + return compare(self, other, Py_LE); + }) + .def("__gt__", + [compare](View const& self, py::object other) { + return compare(self, other, Py_GT); + }) + .def("__ge__", + [compare](View const& self, py::object other) { + return compare(self, other, Py_GE); + }) .def("__getitem__", [wrap_index](View const& self, Index i) -> Word { return self.vector()[wrap_index(i, self.vector().size())]; @@ -270,6 +305,42 @@ namespace libsemigroups { }, py::arg("i") = -1) .def("clear", [](View& self) { self.vector().clear(); }) + .def("copy", [](View const& self) { return py::cast(self.vector()); }) + .def( + "index", + [](View const& self, + py::object value, + py::object start, + py::object stop) { + return py::cast(self.vector()) + .attr("index")(value, start, stop); + }, + py::arg("value"), + py::arg("start") = 0, + py::arg("stop") = std::numeric_limits::max(), + py::pos_only()) + .def("reverse", + [](View& self) { + auto& rules = self.vector(); + std::reverse(rules.begin(), rules.end()); + }) + .def( + "sort", + [](View& self, py::object key, py::object reverse) { + // Python's sort handles stability and arbitrary key objects. + // Do not overwrite changes made by a key callback. + auto const original = self.vector(); + auto sorted = py::cast(original); + sorted.attr("sort")(py::arg("key") = key, + py::arg("reverse") = reverse); + if (self.vector() != original) { + throw py::value_error("list modified during sort"); + } + self.vector() = sorted.template cast(); + }, + py::kw_only(), + py::arg("key") = py::none(), + py::arg("reverse") = false) .def( "count", [](View const& self, Word const& word) { @@ -296,14 +367,39 @@ namespace libsemigroups { != rules.end(); }, py::arg("x")) - .def("__add__", [](View const& self, py::object other) { - if (!py::isinstance(other)) { - throw py::type_error("unsupported operand type(s) for +"); - } - Vector result(self.vector()); - auto other_words = copy_words(other.cast()); - result.insert(result.end(), other_words.begin(), other_words.end()); - return result; + .def("__add__", + [](View const& self, py::object other) { + if (!py::isinstance(other)) { + throw py::type_error("unsupported operand type(s) for +"); + } + Vector result(self.vector()); + auto other_words + = copy_words(other.cast()); + result.insert( + result.end(), other_words.begin(), other_words.end()); + return result; + }) + .def("__iadd__", + [](py::object self, py::iterable other) { + auto words = copy_words(other); + auto& rules = self.cast().vector(); + rules.insert(rules.end(), words.begin(), words.end()); + return self; + }) + .def("__mul__", + [](View const& self, py::object n) { + return py::cast(self.vector()) * n; + }) + .def("__rmul__", + [](View const& self, py::object n) { + return n * py::cast(self.vector()); + }) + .def("__imul__", [](py::object self, py::object n) { + auto& rules = self.cast().vector(); + auto result = py::cast(rules); + result *= n; + rules = result.template cast(); + return self; }); } @@ -345,12 +441,15 @@ available in the module :any:`libsemigroups_pybind11.presentation`.)pbdoc"); R"pbdoc( The rules of the presentation. -For technical reasons, the object contained in this property is not really a -Python list, but some effort has been put into making it behave exactly like a -Python list in many cases. +For technical reasons, this property is not really a Python list, but some +effort has been put into making it behave exactly like a Python list in +many cases. + +Use ``list(p.rules)`` or ``p.rules.copy()`` to copy the rules. Slices, +concatenations, and repetition with ``*`` also return ordinary Python lists. +The ``+=`` and ``*=`` operators modify the rules in the presentation. -Use ``list(p.rules)`` to copy the rules. Slices and concatenations also return -ordinary Python lists. Individual words in ``p.rules`` are returned as +Individual words in ``p.rules`` are returned as Python strings or lists; if the type of words in the presentation is ``list[int]``, then changing an integer inside an individual word in ``p.rules`` does not change the rules in the presentation. Assign a whole @@ -386,6 +485,25 @@ The presentation can be checked for validity using >>> rules[1:1] = ["aa", "a"] >>> p.rules ['ab', 'aa', 'a', 'bb'] + >>> rules.index("aa") + 1 + >>> copied = rules.copy() + >>> rules.reverse() + >>> p.rules + ['bb', 'a', 'aa', 'ab'] + >>> rules.sort(key=len) + >>> p.rules + ['a', 'bb', 'aa', 'ab'] + >>> rules *= 2 + >>> len(p.rules) + 8 + >>> rules += ["b"] + >>> p.rules[-1] + 'b' + >>> copied + ['ab', 'aa', 'a', 'bb'] + >>> rules[:1] * 2 + ['a', 'a'] )pbdoc"); thing.def(py::init<>(), R"pbdoc( @@ -2467,7 +2585,7 @@ defined in the alphabet, and that the inverses act as semigroup inverses. py::arg("p"), py::prepend()); } // bind_inverse_present - } // namespace + } // namespace void init_present(py::module& m) { bind_rules_view(m, "RulesWord"); diff --git a/tests/test_presentation_rules.py b/tests/test_presentation_rules.py index ee5d6004..b44798e3 100644 --- a/tests/test_presentation_rules.py +++ b/tests/test_presentation_rules.py @@ -10,6 +10,7 @@ """Mutable presentation rules and their interaction with ordinary vector bindings.""" import gc +import operator import sys import pytest @@ -27,6 +28,16 @@ pytestmark = pytest.mark.quick +class _Index: # pylint: disable=too-few-public-methods + """Exercise the integer index protocol without inheriting from int.""" + + def __init__(self, value): + self.value = value + + def __index__(self): + return self.value + + @pytest.fixture(name="presentation_type", params=[Presentation, InversePresentation]) def presentation_type_fixture(request): return request.param @@ -159,6 +170,218 @@ def test_rules_copies_and_slices_are_lists(p, words): assert rules[1:1] == [] +def test_rules_copy_and_reverse(p, words): + rules = p.rules + copied = rules.copy() + assert isinstance(copied, list) + assert copied == words + assert rules.reverse() is None + assert p.rules == words[::-1] + assert copied == words + copied.clear() + assert p.rules == words[::-1] + rules.clear() + assert rules.reverse() is None + assert rules.sort() is None + assert rules.copy() == [] + + +@pytest.mark.parametrize( + "bounds", + [ + (), + (1,), + (-3,), + (0, 0), + (1, 3), + (2, 5), + (-100, 100), + (100,), + (10**100,), + (-(10**100), 10**100), + (_Index(2), _Index(8)), + (None,), + (1.5,), + (0, None), + ], +) +def test_rules_index_matches_lists(p, words, bounds): + expected = words * 2 + p.rules = expected + try: + result = expected.index(words[1], *bounds) + except (ValueError, TypeError) as error: + with pytest.raises(type(error)): + p.rules.index(words[1], *bounds) + else: + assert p.rules.index(words[1], *bounds) == result + assert p.rules == expected + + +def test_rules_index_missing_value_and_keywords(p): + with pytest.raises(ValueError): + p.rules.index(object()) + with pytest.raises(TypeError): + p.rules.index(value=p.rules[0]) + with pytest.raises(TypeError): + p.rules.index(p.rules[0], start=0) + + +@pytest.mark.parametrize("key", [None, len]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_rules_sort_matches_lists(p, words, key, reverse): + expected = words * 2 + p.rules = expected + rules = p.rules + expected.sort(key=key, reverse=reverse) + assert rules.sort(key=key, reverse=reverse) is None + assert rules == expected + assert p.rules == expected + + +def test_rules_sort_calls_key_once_per_word(p, words): + seen = [] + + def key(word): + seen.append(word) + return len(word) + + p.rules.sort(key=key) + assert seen == words + assert p.rules == sorted(words, key=len) + + +def test_rules_sort_errors(p, words): + def fail(_): + raise RuntimeError("key failed") + + with pytest.raises(RuntimeError, match="key failed"): + p.rules.sort(key=fail) + with pytest.raises(TypeError): + p.rules.sort(key=lambda _: object()) + with pytest.raises(TypeError): + p.rules.sort(len) + assert p.rules == words + + +def test_rules_sort_detects_callback_mutation(p, words): + def key(word): + if len(p.rules) == len(words): + p.rules.append(words[0]) + return len(word) + + with pytest.raises(ValueError, match="modified during sort"): + p.rules.sort(key=key) + assert p.rules == words + words[:1] + + +@pytest.mark.parametrize("compare", [operator.lt, operator.le, operator.gt, operator.ge]) +@pytest.mark.parametrize("selection", [slice(None), slice(1), slice(None, None, -1)]) +def test_rules_ordering_matches_lists(p, words, presentation_type, compare, selection): + other = words[selection] + assert compare(p.rules, other) == compare(words, other) + assert compare(other, p.rules) == compare(other, words) + q = presentation_type(p.alphabet()) + q.rules = other + assert compare(p.rules, q.rules) == compare(words, other) + assert compare(q.rules, p.rules) == compare(other, words) + assert compare(p.rules, p.rules) == compare(words, words) + with pytest.raises(TypeError): + compare(p.rules, tuple(other)) + with pytest.raises(TypeError): + compare(tuple(other), p.rules) + + +@pytest.mark.parametrize("compare", [operator.lt, operator.le, operator.gt, operator.ge]) +def test_rules_ordering_incompatible_words(presentation_type, compare): + strings = presentation_type("ab") + strings.rules = ["a"] + integers = presentation_type([0, 1]) + integers.rules = [[0]] + with pytest.raises(TypeError): + compare(strings.rules, integers.rules) + with pytest.raises(TypeError): + compare(integers.rules, strings.rules) + strings.rules.clear() + assert compare(strings.rules, integers.rules) == compare([], [[0]]) + + +def test_rules_inplace_add_keeps_the_view(p, words): + rules = p.rules + original = rules + other_view = p.rules + rules += rules + assert rules is original + assert other_view == words * 2 + rules += iter(other_view) + assert rules is original + assert p.rules == words * 4 + p.rules += tuple(words) + assert other_view == words * 5 + with pytest.raises(RuntimeError): + rules += [words[0], object()] + assert p.rules == words * 5 + + +@pytest.mark.parametrize("count", [-3, 0, 1, 2, False, True, _Index(3)]) +@pytest.mark.parametrize("initially_empty", [False, True]) +def test_rules_repetition_matches_lists(p, words, count, initially_empty): + expected = [] if initially_empty else list(words) + p.rules = expected + rules = p.rules + original = rules + for result in (rules * count, count * rules): + assert isinstance(result, list) + assert result == expected * count + result.append(words[0]) + assert p.rules == expected + rules *= count + assert rules is original + assert p.rules == expected * count + p.rules *= 2 + assert rules == expected * count * 2 + + +@pytest.mark.parametrize( + "count, error_type", + [ + (1.5, TypeError), + (None, TypeError), + (10**100, OverflowError), + (-(10**100), OverflowError), + (sys.maxsize, MemoryError), + ], +) +def test_rules_repetition_errors(p, words, count, error_type): + rules = p.rules + with pytest.raises(error_type): + _ = rules * count + with pytest.raises(error_type): + _ = count * rules + with pytest.raises(error_type): + rules *= count + assert p.rules == words + + +def test_rules_inplace_operations_keep_presentation_alive(presentation_type, words): + p = presentation_type("ab" if isinstance(words[0], str) else [0, 1]) + p.rules = words + rules = p.rules + del p + gc.collect() + rules += rules + rules *= 2 + assert rules == words * 4 + rules.reverse() + rules.sort() + assert rules == sorted(words * 4) + + +def test_rules_provide_all_named_list_methods(p): + methods = {name for name in dir(list) if not name.startswith("_")} + assert methods <= set(dir(p.rules)) + + def test_rules_cannot_be_constructed_independently(p, words): rules_type = type(p.rules) for args in ((), (words,), (p.rules,)): From 2e0eaca259dfb2dea59a463cd701def62a4f3c69 Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Fri, 18 Sep 2026 09:58:13 +0100 Subject: [PATCH 4/4] Improve complexity of delitem --- src/present.cpp | 21 +++++++++++++-------- tests/test_presentation_rules.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/present.cpp b/src/present.cpp index 5e060dc8..97f7802a 100644 --- a/src/present.cpp +++ b/src/present.cpp @@ -20,7 +20,7 @@ #include // for size_t // C++ stl headers.... -#include // for clamp, count, find, reverse +#include // for clamp, count, find, remove_if, reverse #include // for numeric_limits #include // for make_unique #include // for invalid_argument @@ -256,13 +256,18 @@ namespace libsemigroups { rules.erase(rules.begin() + indices.start, rules.begin() + indices.start + indices.length); } else { - // Delete in descending index order so remaining indices stay - // valid. - // TODO use remove_if - for (Index i = indices.length; i > 0; --i) { - rules.erase(rules.begin() + indices.start - + (i - 1) * indices.step); - } + // Compact the sliced range once, then erase the gap and + // shift the suffix. + auto const first = rules.begin() + indices.start; + auto const last + = first + (indices.length - 1) * indices.step + 1; + Index i = 0; + rules.erase(std::remove_if(first, + last, + [&](Word const&) { + return i++ % indices.step == 0; + }), + last); } }) .def( diff --git a/tests/test_presentation_rules.py b/tests/test_presentation_rules.py index b44798e3..c4eceb20 100644 --- a/tests/test_presentation_rules.py +++ b/tests/test_presentation_rules.py @@ -497,6 +497,31 @@ def test_rules_slices_match_lists(p, words, selection): assert p.rules == expected +@pytest.mark.parametrize( + "selection", + [ + slice(None, None, 2), + slice(None, None, -2), + slice(2, 13, 3), + slice(13, 2, -3), + slice(3, 12, 2), + slice(11, 2, -2), + slice(-2, None, -3), + slice(2, 3, 5), + slice(2, None, 10**100), + slice(17, None, -(10**100)), + ], +) +def test_rules_stepped_deletion_preserves_prefix_and_suffix(p, words, selection): + expected = [words[0] * i for i in range(20)] + p.rules = expected + rules = p.rules + del expected[selection] + del rules[selection] + assert rules == expected + assert p.rules == expected + + def test_rules_concatenation(p, words): rules = p.rules for other in (words, tuple(words), p.rules):