zero-sum-sequences 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zero_sum_sequences/__init__.py +25 -0
- zero_sum_sequences/additive_sequence.py +531 -0
- zero_sum_sequences/atom_catalogue.py +92 -0
- zero_sum_sequences/factorization.py +385 -0
- zero_sum_sequences/orbits.py +533 -0
- zero_sum_sequences/parents.py +264 -0
- zero_sum_sequences-0.1.0.dist-info/METADATA +228 -0
- zero_sum_sequences-0.1.0.dist-info/RECORD +10 -0
- zero_sum_sequences-0.1.0.dist-info/WHEEL +4 -0
- zero_sum_sequences-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tools for finite additive sequences and zero-sum computations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _distribution_version
|
|
4
|
+
|
|
5
|
+
from .additive_sequence import AdditiveSequence, AdditiveSequenceSpace
|
|
6
|
+
from .atom_catalogue import AtomCatalogue
|
|
7
|
+
from .factorization import FactorizationSolver
|
|
8
|
+
from .orbits import (
|
|
9
|
+
AutomorphismAction,
|
|
10
|
+
AutomorphismActionUnavailable,
|
|
11
|
+
OrbitWitness,
|
|
12
|
+
)
|
|
13
|
+
from .parents import FiniteAdditiveGroup
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AdditiveSequence",
|
|
17
|
+
"AdditiveSequenceSpace",
|
|
18
|
+
"AtomCatalogue",
|
|
19
|
+
"FactorizationSolver",
|
|
20
|
+
"FiniteAdditiveGroup",
|
|
21
|
+
"AutomorphismAction",
|
|
22
|
+
"AutomorphismActionUnavailable",
|
|
23
|
+
"OrbitWitness",
|
|
24
|
+
]
|
|
25
|
+
__version__ = _distribution_version("zero-sum-sequences")
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Immutable finite sequences over a configured additive parent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
8
|
+
from copy import copy
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from operator import add as default_add
|
|
11
|
+
from operator import index
|
|
12
|
+
from typing import TYPE_CHECKING, Generic, Self, TypeVar
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .atom_catalogue import AtomCatalogue
|
|
16
|
+
|
|
17
|
+
Element = TypeVar("Element")
|
|
18
|
+
TargetElement = TypeVar("TargetElement")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _non_negative_integer(value: object, *, name: str) -> int:
|
|
22
|
+
"""Return an exact indexable integer, rejecting booleans."""
|
|
23
|
+
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
26
|
+
try:
|
|
27
|
+
integer = index(value)
|
|
28
|
+
except TypeError:
|
|
29
|
+
raise ValueError(f"{name} must be a non-negative integer") from None
|
|
30
|
+
if integer < 0:
|
|
31
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
32
|
+
return integer
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _positive_integer(value: object, *, name: str) -> int:
|
|
36
|
+
if isinstance(value, bool):
|
|
37
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
38
|
+
try:
|
|
39
|
+
integer = index(value)
|
|
40
|
+
except TypeError:
|
|
41
|
+
raise ValueError(f"{name} must be a positive integer") from None
|
|
42
|
+
if integer < 1:
|
|
43
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
44
|
+
return integer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _immutable_term(term: Element) -> Element:
|
|
48
|
+
"""Return a hashable term, copying supported mutable elements if necessary."""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
hash(term)
|
|
52
|
+
except TypeError:
|
|
53
|
+
immutable_copy = copy(term)
|
|
54
|
+
set_immutable = getattr(immutable_copy, "set_immutable", None)
|
|
55
|
+
if not callable(set_immutable):
|
|
56
|
+
raise TypeError("additive-sequence terms must be hashable") from None
|
|
57
|
+
set_immutable()
|
|
58
|
+
hash(immutable_copy)
|
|
59
|
+
return immutable_copy
|
|
60
|
+
return term
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AdditiveSequenceSpace(Generic[Element]):
|
|
64
|
+
"""Configured constructor for sequences over one additive parent.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
parent:
|
|
69
|
+
A callable parent containing every sequence term and providing
|
|
70
|
+
``zero()``. Elements must be hashable and mutually orderable. They
|
|
71
|
+
may implement addition themselves, or the parent may provide
|
|
72
|
+
``add(left, right)``.
|
|
73
|
+
davenport_bound:
|
|
74
|
+
A positive upper bound for the parent's Davenport constant.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("_parent", "_davenport_bound", "_automorphism_action")
|
|
78
|
+
|
|
79
|
+
def __init__(self, parent, *, davenport_bound: int) -> None:
|
|
80
|
+
if not callable(parent):
|
|
81
|
+
raise TypeError("the additive parent must be callable")
|
|
82
|
+
zero = getattr(parent, "zero", None)
|
|
83
|
+
if not callable(zero):
|
|
84
|
+
raise TypeError("the additive parent must provide zero()")
|
|
85
|
+
self._parent = parent
|
|
86
|
+
self._davenport_bound = _positive_integer(
|
|
87
|
+
davenport_bound,
|
|
88
|
+
name="Davenport bound",
|
|
89
|
+
)
|
|
90
|
+
self._automorphism_action = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def base_parent(self):
|
|
94
|
+
"""Return the configured additive parent of the sequence terms."""
|
|
95
|
+
|
|
96
|
+
return self._parent
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def davenport_bound(self) -> int:
|
|
100
|
+
"""Return the configured upper bound for the Davenport constant."""
|
|
101
|
+
|
|
102
|
+
return self._davenport_bound
|
|
103
|
+
|
|
104
|
+
def __call__(self, terms: Iterable[Element] = ()) -> AdditiveSequence[Element]:
|
|
105
|
+
"""Construct a sequence, coercing every term through the parent."""
|
|
106
|
+
|
|
107
|
+
if isinstance(terms, AdditiveSequence) and terms.parent() is self:
|
|
108
|
+
return terms
|
|
109
|
+
return AdditiveSequence(self, terms)
|
|
110
|
+
|
|
111
|
+
def from_multiplicities(
|
|
112
|
+
self, multiplicities: Mapping[Element, int]
|
|
113
|
+
) -> AdditiveSequence[Element]:
|
|
114
|
+
"""Construct a sequence from non-negative term multiplicities."""
|
|
115
|
+
|
|
116
|
+
terms: list[Element] = []
|
|
117
|
+
for term, count in multiplicities.items():
|
|
118
|
+
try:
|
|
119
|
+
count = _non_negative_integer(count, name="multiplicities")
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"multiplicities must be non-negative integers"
|
|
123
|
+
) from None
|
|
124
|
+
terms.extend(itertools.repeat(term, count))
|
|
125
|
+
return self(terms)
|
|
126
|
+
|
|
127
|
+
def enumerate_atom_catalogue(self) -> AtomCatalogue[Element]:
|
|
128
|
+
"""Exhaustively enumerate reduced atoms through the configured bound.
|
|
129
|
+
|
|
130
|
+
The base parent must be a finite iterable additive group. For each
|
|
131
|
+
candidate length, sorted prefixes are completed by their uniquely
|
|
132
|
+
determined final term. The identity singleton is omitted, matching
|
|
133
|
+
the reduced-factorization convention of :class:`AtomCatalogue`. The
|
|
134
|
+
result is complete only when ``davenport_bound`` is a valid upper
|
|
135
|
+
bound for the base parent.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
from .atom_catalogue import AtomCatalogue
|
|
139
|
+
|
|
140
|
+
is_finite = getattr(self._parent, "is_finite", None)
|
|
141
|
+
if not callable(is_finite) or not is_finite():
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"atom catalogue enumeration requires a finite parent"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
parent_terms = tuple(self._parent)
|
|
148
|
+
except TypeError:
|
|
149
|
+
raise TypeError(
|
|
150
|
+
"atom catalogue enumeration requires an iterable parent"
|
|
151
|
+
) from None
|
|
152
|
+
|
|
153
|
+
terms = tuple(
|
|
154
|
+
sorted(
|
|
155
|
+
_immutable_term(self._parent(term))
|
|
156
|
+
for term in parent_terms
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
zero = _immutable_term(self._parent(self._parent.zero()))
|
|
160
|
+
operation = getattr(self._parent, "add", default_add)
|
|
161
|
+
if not callable(operation):
|
|
162
|
+
operation = default_add
|
|
163
|
+
|
|
164
|
+
inverse = {}
|
|
165
|
+
for term in terms:
|
|
166
|
+
for candidate in terms:
|
|
167
|
+
total = _immutable_term(
|
|
168
|
+
self._parent(operation(term, candidate))
|
|
169
|
+
)
|
|
170
|
+
if total == zero:
|
|
171
|
+
inverse[term] = candidate
|
|
172
|
+
break
|
|
173
|
+
else:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"atom catalogue enumeration requires additive inverses"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
nonzero_terms = tuple(term for term in terms if term != zero)
|
|
179
|
+
term_index = {
|
|
180
|
+
term: position for position, term in enumerate(nonzero_terms)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
atoms = []
|
|
184
|
+
for length in range(2, self._davenport_bound + 1):
|
|
185
|
+
for prefix_indices in itertools.combinations_with_replacement(
|
|
186
|
+
range(len(nonzero_terms)), length - 1
|
|
187
|
+
):
|
|
188
|
+
prefix = tuple(
|
|
189
|
+
nonzero_terms[position] for position in prefix_indices
|
|
190
|
+
)
|
|
191
|
+
prefix_total = _immutable_term(
|
|
192
|
+
self._parent(reduce(operation, prefix, zero))
|
|
193
|
+
)
|
|
194
|
+
final = inverse[prefix_total]
|
|
195
|
+
final_position = term_index.get(final)
|
|
196
|
+
if (
|
|
197
|
+
final_position is None
|
|
198
|
+
or final_position < prefix_indices[-1]
|
|
199
|
+
):
|
|
200
|
+
continue
|
|
201
|
+
candidate = self((*prefix, final))
|
|
202
|
+
if candidate.is_atom():
|
|
203
|
+
atoms.append(candidate)
|
|
204
|
+
return AtomCatalogue(self, atoms)
|
|
205
|
+
|
|
206
|
+
def __repr__(self) -> str:
|
|
207
|
+
return (
|
|
208
|
+
f"{type(self).__name__}({self._parent!r}, "
|
|
209
|
+
f"davenport_bound={self._davenport_bound})"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class AdditiveSequence(Generic[Element]):
|
|
214
|
+
"""An immutable finite multiset in an :class:`AdditiveSequenceSpace`.
|
|
215
|
+
|
|
216
|
+
Construct sequences by calling their configured space. Terms are coerced
|
|
217
|
+
through its parent, copied when necessary to make them immutable, and
|
|
218
|
+
stored as a sorted multiplicity table.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
__slots__ = ("_space", "_items", "_length", "_hash")
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
space: AdditiveSequenceSpace[Element],
|
|
226
|
+
terms: Iterable[Element] = (),
|
|
227
|
+
) -> None:
|
|
228
|
+
if not isinstance(space, AdditiveSequenceSpace):
|
|
229
|
+
raise TypeError("construct sequences through AdditiveSequenceSpace")
|
|
230
|
+
self._space = space
|
|
231
|
+
counts = Counter(
|
|
232
|
+
_immutable_term(space.base_parent(term))
|
|
233
|
+
for term in terms
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
items = tuple(sorted(counts.items()))
|
|
237
|
+
except TypeError as error:
|
|
238
|
+
raise TypeError(
|
|
239
|
+
"additive-sequence terms must be mutually orderable"
|
|
240
|
+
) from error
|
|
241
|
+
self._items: tuple[tuple[Element, int], ...] = items
|
|
242
|
+
self._length = sum(count for _, count in items)
|
|
243
|
+
self._hash = hash((space, items))
|
|
244
|
+
|
|
245
|
+
def parent(self) -> AdditiveSequenceSpace[Element]:
|
|
246
|
+
"""Return the configured sequence space."""
|
|
247
|
+
|
|
248
|
+
return self._space
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def support(self) -> tuple[Element, ...]:
|
|
252
|
+
"""The distinct terms, in canonical order."""
|
|
253
|
+
|
|
254
|
+
return tuple(term for term, _ in self._items)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def multiplicities(self) -> dict[Element, int]:
|
|
258
|
+
"""A copy of the term-to-multiplicity mapping."""
|
|
259
|
+
|
|
260
|
+
return dict(self._items)
|
|
261
|
+
|
|
262
|
+
def multiplicity(self, term: Element) -> int:
|
|
263
|
+
"""Return how often ``term`` occurs."""
|
|
264
|
+
|
|
265
|
+
coerced = _immutable_term(self._space.base_parent(term))
|
|
266
|
+
return next(
|
|
267
|
+
(count for candidate, count in self._items if candidate == coerced),
|
|
268
|
+
0,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def total(self):
|
|
272
|
+
"""Return the additive sum of the terms in the base parent."""
|
|
273
|
+
|
|
274
|
+
parent = self._space.base_parent
|
|
275
|
+
operation = getattr(parent, "add", default_add)
|
|
276
|
+
if not callable(operation):
|
|
277
|
+
operation = default_add
|
|
278
|
+
return reduce(operation, self, parent.zero())
|
|
279
|
+
|
|
280
|
+
def is_zero_sum(self) -> bool:
|
|
281
|
+
"""Return whether the sum of the sequence is zero."""
|
|
282
|
+
|
|
283
|
+
total = self.total()
|
|
284
|
+
is_zero = getattr(total, "is_zero", None)
|
|
285
|
+
if callable(is_zero):
|
|
286
|
+
return bool(is_zero())
|
|
287
|
+
return total == self._space.base_parent.zero()
|
|
288
|
+
|
|
289
|
+
def map_terms(
|
|
290
|
+
self,
|
|
291
|
+
mapping: Callable[[Element], TargetElement],
|
|
292
|
+
*,
|
|
293
|
+
target_space: AdditiveSequenceSpace[TargetElement] | None = None,
|
|
294
|
+
) -> AdditiveSequence[TargetElement]:
|
|
295
|
+
"""Apply ``mapping`` to every term and construct the image sequence.
|
|
296
|
+
|
|
297
|
+
By default the image is constructed in this sequence's space. A
|
|
298
|
+
different ``target_space`` may be supplied when the mapping takes
|
|
299
|
+
terms to another additive parent. The result is canonicalized by the
|
|
300
|
+
target space, so equal images are combined with their multiplicities.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
if not callable(mapping):
|
|
304
|
+
raise TypeError("mapping must be callable")
|
|
305
|
+
if target_space is None:
|
|
306
|
+
target_space = self._space
|
|
307
|
+
elif not isinstance(target_space, AdditiveSequenceSpace):
|
|
308
|
+
raise TypeError("target_space must be an AdditiveSequenceSpace")
|
|
309
|
+
return target_space(mapping(term) for term in self)
|
|
310
|
+
|
|
311
|
+
def orbit(self, *, action=None):
|
|
312
|
+
"""Return this sequence's finite automorphism orbit.
|
|
313
|
+
|
|
314
|
+
``action`` may be an :class:`AutomorphismAction` or an iterable of
|
|
315
|
+
callable term maps. When omitted, the action is resolved lazily from
|
|
316
|
+
the additive parent and cached on the sequence space.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
from .orbits import orbit
|
|
320
|
+
|
|
321
|
+
return orbit(self, action=action)
|
|
322
|
+
|
|
323
|
+
def is_in_same_orbit(
|
|
324
|
+
self,
|
|
325
|
+
other: AdditiveSequence[Element],
|
|
326
|
+
*,
|
|
327
|
+
action=None,
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Return whether ``other`` is in this sequence's action orbit."""
|
|
330
|
+
|
|
331
|
+
from .orbits import is_in_same_orbit
|
|
332
|
+
|
|
333
|
+
return is_in_same_orbit(self, other, action=action)
|
|
334
|
+
|
|
335
|
+
def orbit_witness(
|
|
336
|
+
self,
|
|
337
|
+
other: AdditiveSequence[Element],
|
|
338
|
+
*,
|
|
339
|
+
action=None,
|
|
340
|
+
):
|
|
341
|
+
"""Return a generator word mapping this sequence to ``other``.
|
|
342
|
+
|
|
343
|
+
The empty word witnesses equality. ``None`` is returned when the
|
|
344
|
+
sequences are not in the same orbit.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
from .orbits import orbit_witness
|
|
348
|
+
|
|
349
|
+
return orbit_witness(self, other, action=action)
|
|
350
|
+
|
|
351
|
+
def subsequences(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
nonempty: bool = True,
|
|
355
|
+
proper: bool = False,
|
|
356
|
+
max_length: int | None = None,
|
|
357
|
+
) -> Iterator[Self]:
|
|
358
|
+
"""Yield each distinct subsequence once, in deterministic order."""
|
|
359
|
+
|
|
360
|
+
if max_length is not None and max_length < 0:
|
|
361
|
+
raise ValueError("maximum length must be non-negative")
|
|
362
|
+
|
|
363
|
+
if max_length is None:
|
|
364
|
+
count_vectors = itertools.product(
|
|
365
|
+
*(range(count + 1) for _, count in self._items)
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
|
|
369
|
+
def bounded_count_vectors(
|
|
370
|
+
index: int, remaining: int, prefix: tuple[int, ...] = ()
|
|
371
|
+
) -> Iterator[tuple[int, ...]]:
|
|
372
|
+
if index == len(self._items):
|
|
373
|
+
yield prefix
|
|
374
|
+
return
|
|
375
|
+
maximum_count = min(self._items[index][1], remaining)
|
|
376
|
+
for count in range(maximum_count + 1):
|
|
377
|
+
yield from bounded_count_vectors(
|
|
378
|
+
index + 1,
|
|
379
|
+
remaining - count,
|
|
380
|
+
(*prefix, count),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
count_vectors = bounded_count_vectors(0, max_length)
|
|
384
|
+
|
|
385
|
+
for chosen_counts in count_vectors:
|
|
386
|
+
chosen_length = sum(chosen_counts)
|
|
387
|
+
if nonempty and chosen_length == 0:
|
|
388
|
+
continue
|
|
389
|
+
if proper and chosen_length == len(self):
|
|
390
|
+
continue
|
|
391
|
+
yield self._space.from_multiplicities(
|
|
392
|
+
{
|
|
393
|
+
term: count
|
|
394
|
+
for (term, _), count in zip(self._items, chosen_counts)
|
|
395
|
+
if count
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def is_atom(self) -> bool:
|
|
400
|
+
"""Return whether this is a nonempty minimal zero-sum sequence."""
|
|
401
|
+
|
|
402
|
+
if not self or not self.is_zero_sum():
|
|
403
|
+
return False
|
|
404
|
+
return not any(
|
|
405
|
+
subsequence.is_zero_sum()
|
|
406
|
+
for subsequence in self.subsequences(
|
|
407
|
+
nonempty=True,
|
|
408
|
+
proper=True,
|
|
409
|
+
max_length=len(self) // 2,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def factorization_solver(self, *, atom_catalogue=None):
|
|
414
|
+
"""Return a solver for reduced factorizations into nonzero atoms.
|
|
415
|
+
|
|
416
|
+
The sequence must not contain the identity term. The identity remains
|
|
417
|
+
a length-one atom in the full block monoid, but is excluded from the
|
|
418
|
+
reduced factorization convention used by this package.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
from .factorization import FactorizationSolver
|
|
422
|
+
|
|
423
|
+
return FactorizationSolver(self, atom_catalogue=atom_catalogue)
|
|
424
|
+
|
|
425
|
+
def factorizations(self, *, atom_catalogue=None):
|
|
426
|
+
"""Yield every unordered reduced factorization exactly once."""
|
|
427
|
+
|
|
428
|
+
yield from self.factorization_solver(
|
|
429
|
+
atom_catalogue=atom_catalogue
|
|
430
|
+
).factorizations()
|
|
431
|
+
|
|
432
|
+
def length_set(self, *, atom_catalogue=None) -> set[int]:
|
|
433
|
+
"""Return the complete set of attained reduced factorization lengths."""
|
|
434
|
+
|
|
435
|
+
return self.factorization_solver(atom_catalogue=atom_catalogue).length_set()
|
|
436
|
+
|
|
437
|
+
def factorization_witnesses(self, *, atom_catalogue=None):
|
|
438
|
+
"""Return one deterministic factorization per attained length."""
|
|
439
|
+
|
|
440
|
+
return self.factorization_solver(
|
|
441
|
+
atom_catalogue=atom_catalogue
|
|
442
|
+
).factorization_witnesses()
|
|
443
|
+
|
|
444
|
+
def factorization_digraph(self, *, atom_catalogue=None):
|
|
445
|
+
"""Return the memoized remainder DAG as a NetworkX directed graph."""
|
|
446
|
+
|
|
447
|
+
return self.factorization_solver(atom_catalogue=atom_catalogue).digraph()
|
|
448
|
+
|
|
449
|
+
def divides(self, other: AdditiveSequence[Element]) -> bool:
|
|
450
|
+
"""Return whether this sequence is a subsequence of ``other``."""
|
|
451
|
+
|
|
452
|
+
self._require_same_space(other)
|
|
453
|
+
other_counts = other.multiplicities
|
|
454
|
+
return all(other_counts.get(term, 0) >= count for term, count in self._items)
|
|
455
|
+
|
|
456
|
+
def _require_same_space(self, other: object) -> AdditiveSequence[Element]:
|
|
457
|
+
if not isinstance(other, AdditiveSequence):
|
|
458
|
+
raise TypeError("expected an additive sequence")
|
|
459
|
+
if self._space is not other._space:
|
|
460
|
+
raise TypeError("additive sequences belong to different spaces")
|
|
461
|
+
return other
|
|
462
|
+
|
|
463
|
+
def __iter__(self) -> Iterator[Element]:
|
|
464
|
+
for term, count in self._items:
|
|
465
|
+
yield from itertools.repeat(term, count)
|
|
466
|
+
|
|
467
|
+
def __len__(self) -> int:
|
|
468
|
+
return self._length
|
|
469
|
+
|
|
470
|
+
def __bool__(self) -> bool:
|
|
471
|
+
return self._length != 0
|
|
472
|
+
|
|
473
|
+
def __contains__(self, term: object) -> bool:
|
|
474
|
+
try:
|
|
475
|
+
coerced = _immutable_term(self._space.base_parent(term))
|
|
476
|
+
except (TypeError, ValueError):
|
|
477
|
+
return False
|
|
478
|
+
return any(candidate == coerced for candidate, _ in self._items)
|
|
479
|
+
|
|
480
|
+
def __add__(self, other: object) -> Self:
|
|
481
|
+
if not isinstance(other, AdditiveSequence):
|
|
482
|
+
return NotImplemented
|
|
483
|
+
self._require_same_space(other)
|
|
484
|
+
counts = self.multiplicities
|
|
485
|
+
for term, count in other._items:
|
|
486
|
+
counts[term] = counts.get(term, 0) + count
|
|
487
|
+
return self._space.from_multiplicities(counts)
|
|
488
|
+
|
|
489
|
+
def __sub__(self, other: object) -> Self:
|
|
490
|
+
if not isinstance(other, AdditiveSequence):
|
|
491
|
+
return NotImplemented
|
|
492
|
+
self._require_same_space(other)
|
|
493
|
+
if not other.divides(self):
|
|
494
|
+
raise ValueError(f"{other} is not a subsequence of {self}")
|
|
495
|
+
counts = self.multiplicities
|
|
496
|
+
for term, count in other._items:
|
|
497
|
+
counts[term] -= count
|
|
498
|
+
return self._space.from_multiplicities(counts)
|
|
499
|
+
|
|
500
|
+
def __mul__(self, repetitions: object) -> Self:
|
|
501
|
+
try:
|
|
502
|
+
repetitions = index(repetitions)
|
|
503
|
+
except TypeError:
|
|
504
|
+
return NotImplemented
|
|
505
|
+
if repetitions < 0:
|
|
506
|
+
raise ValueError("sequence repetitions must be non-negative")
|
|
507
|
+
return self._space.from_multiplicities(
|
|
508
|
+
{term: count * repetitions for term, count in self._items}
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def __rmul__(self, repetitions: object) -> Self:
|
|
512
|
+
return self * repetitions
|
|
513
|
+
|
|
514
|
+
def __eq__(self, other: object) -> bool:
|
|
515
|
+
if not isinstance(other, AdditiveSequence):
|
|
516
|
+
return NotImplemented
|
|
517
|
+
return self._space is other._space and self._items == other._items
|
|
518
|
+
|
|
519
|
+
def __hash__(self) -> int:
|
|
520
|
+
return self._hash
|
|
521
|
+
|
|
522
|
+
def __repr__(self) -> str:
|
|
523
|
+
return f"{self._space!r}({list(self)!r})"
|
|
524
|
+
|
|
525
|
+
def __str__(self) -> str:
|
|
526
|
+
if not self:
|
|
527
|
+
return "1"
|
|
528
|
+
return " · ".join(
|
|
529
|
+
str(term) if count == 1 else f"({term})^{count}"
|
|
530
|
+
for term, count in self._items
|
|
531
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Reusable indexed collections of atoms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from typing import Generic
|
|
8
|
+
|
|
9
|
+
from .additive_sequence import AdditiveSequence, AdditiveSequenceSpace, Element
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AtomCatalogue(Generic[Element]):
|
|
13
|
+
"""A collection of nonzero atoms indexed for fast divisor queries.
|
|
14
|
+
|
|
15
|
+
Factorizations in this package are taken in the reduced block monoid:
|
|
16
|
+
the identity element may be a length-one atom mathematically, but it is
|
|
17
|
+
deliberately not a factorization atom.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
space: AdditiveSequenceSpace[Element],
|
|
23
|
+
atoms: Iterable[AdditiveSequence[Element]],
|
|
24
|
+
) -> None:
|
|
25
|
+
if not isinstance(space, AdditiveSequenceSpace):
|
|
26
|
+
raise TypeError("expected an AdditiveSequenceSpace")
|
|
27
|
+
self.space = space
|
|
28
|
+
zero = space.base_parent.zero()
|
|
29
|
+
unique_atoms = set()
|
|
30
|
+
for atom in atoms:
|
|
31
|
+
if not isinstance(atom, AdditiveSequence):
|
|
32
|
+
raise TypeError("atom catalogue entries must be additive sequences")
|
|
33
|
+
if atom.parent() is not space:
|
|
34
|
+
raise TypeError("atom catalogue entries use a different space")
|
|
35
|
+
if not atom.is_atom():
|
|
36
|
+
raise ValueError("atom catalogue entries must be atoms")
|
|
37
|
+
if zero in atom:
|
|
38
|
+
raise ValueError("atom catalogue entries must not contain zero")
|
|
39
|
+
unique_atoms.add(atom)
|
|
40
|
+
self.atoms = tuple(
|
|
41
|
+
sorted(unique_atoms, key=lambda atom: (len(atom), tuple(atom)))
|
|
42
|
+
)
|
|
43
|
+
self.terms = tuple(
|
|
44
|
+
sorted({term for atom in self.atoms for term in atom.support})
|
|
45
|
+
)
|
|
46
|
+
self._term_index = {
|
|
47
|
+
term: index for index, term in enumerate(self.terms)
|
|
48
|
+
}
|
|
49
|
+
by_support_mask: dict[int, list[AdditiveSequence[Element]]] = defaultdict(list)
|
|
50
|
+
for atom in self.atoms:
|
|
51
|
+
support_mask = 0
|
|
52
|
+
for term in atom.support:
|
|
53
|
+
support_mask |= 1 << self._term_index[term]
|
|
54
|
+
by_support_mask[support_mask].append(atom)
|
|
55
|
+
self._by_support_mask = {
|
|
56
|
+
mask: tuple(mask_atoms)
|
|
57
|
+
for mask, mask_atoms in by_support_mask.items()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def __iter__(self) -> Iterator[AdditiveSequence[Element]]:
|
|
61
|
+
return iter(self.atoms)
|
|
62
|
+
|
|
63
|
+
def __len__(self) -> int:
|
|
64
|
+
return len(self.atoms)
|
|
65
|
+
|
|
66
|
+
def divisors(
|
|
67
|
+
self,
|
|
68
|
+
sequence: AdditiveSequence[Element],
|
|
69
|
+
) -> Iterator[AdditiveSequence[Element]]:
|
|
70
|
+
"""Yield catalogue atoms dividing ``sequence`` in canonical order."""
|
|
71
|
+
|
|
72
|
+
if sequence.parent() is not self.space:
|
|
73
|
+
raise TypeError("atom catalogue and sequence use different spaces")
|
|
74
|
+
|
|
75
|
+
target_counts = sequence.multiplicities
|
|
76
|
+
target_mask = 0
|
|
77
|
+
for term in sequence.support:
|
|
78
|
+
index = self._term_index.get(term)
|
|
79
|
+
if index is not None:
|
|
80
|
+
target_mask |= 1 << index
|
|
81
|
+
|
|
82
|
+
candidates = []
|
|
83
|
+
submask = target_mask
|
|
84
|
+
while submask:
|
|
85
|
+
candidates.extend(self._by_support_mask.get(submask, ()))
|
|
86
|
+
submask = (submask - 1) & target_mask
|
|
87
|
+
for atom in sorted(candidates, key=lambda item: (len(item), tuple(item))):
|
|
88
|
+
if all(
|
|
89
|
+
target_counts.get(term, 0) >= count
|
|
90
|
+
for term, count in atom.multiplicities.items()
|
|
91
|
+
):
|
|
92
|
+
yield atom
|