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.
@@ -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