zero-sum-sequences 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (37) hide show
  1. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/PKG-INFO +101 -1
  2. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/README.md +100 -0
  3. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/pyproject.toml +1 -1
  4. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/__init__.py +15 -1
  5. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/additive_sequence.py +89 -1
  6. zero_sum_sequences-0.2.0/src/zero_sum_sequences/atom_catalogue.py +353 -0
  7. zero_sum_sequences-0.2.0/src/zero_sum_sequences/automorphisms.py +534 -0
  8. zero_sum_sequences-0.2.0/src/zero_sum_sequences/factorization_cache.py +350 -0
  9. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/parents.py +75 -0
  10. zero_sum_sequences-0.2.0/tests/test_atom_catalogue_index.py +207 -0
  11. zero_sum_sequences-0.2.0/tests/test_automorphisms.py +406 -0
  12. zero_sum_sequences-0.2.0/tests/test_factorization_cache.py +250 -0
  13. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_orbits_sage.py +29 -0
  14. zero_sum_sequences-0.1.2/src/zero_sum_sequences/atom_catalogue.py +0 -92
  15. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/.gitignore +0 -0
  16. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/LICENSE +0 -0
  17. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/benchmarks/README.md +0 -0
  18. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/benchmarks/__init__.py +0 -0
  19. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/benchmarks/benchmark_factorization.py +0 -0
  20. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/benchmarks/factorization_cases.py +0 -0
  21. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/notebooks/tutorial.ipynb +0 -0
  22. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/factorization.py +0 -0
  23. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/orbits.py +0 -0
  24. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/src/zero_sum_sequences/relations.py +0 -0
  25. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/groups.py +0 -0
  26. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_additive_sequence.py +0 -0
  27. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_atom_catalogue.py +0 -0
  28. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_factorization.py +0 -0
  29. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_factorization_benchmarks.py +0 -0
  30. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_factorization_enumeration.py +0 -0
  31. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_factorization_relations.py +0 -0
  32. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_generic_parents.py +0 -0
  33. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_orbits.py +0 -0
  34. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_package_metadata.py +0 -0
  35. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_parents.py +0 -0
  36. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_support_catalogue.py +0 -0
  37. {zero_sum_sequences-0.1.2 → zero_sum_sequences-0.2.0}/tests/test_targeted_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zero-sum-sequences
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Computations with additive and zero-sum sequences
5
5
  Project-URL: Repository, https://github.com/behackl/zero-sum-sequences
6
6
  Author: Benjamin Hackl
@@ -312,6 +312,106 @@ Automatic discovery raises `AutomorphismActionUnavailable` when the parent
312
312
  does not expose suitable generators, in which case an explicit action is
313
313
  required.
314
314
 
315
+ ## Materialized automorphism groups
316
+
317
+ For a finite parent the whole automorphism group can be materialized, which
318
+ gives stabilizers, canonical orbit representatives, transporters and
319
+ classification of many objects at once:
320
+
321
+ ```python
322
+ G = FiniteAdditiveGroup.cyclic_product(2, 4)
323
+ C2xC4 = AdditiveSequenceSpace(G, davenport_bound=5)
324
+ group = C2xC4.automorphism_group() # AutomorphismGroup(order=8)
325
+
326
+ x = C2xC4([(1, 1), (1, 1), (0, 2)])
327
+ group.orbit(x) # the four images, sorted
328
+ len(group.stabilizer(x)) # 2
329
+ form = group.canonical_form(x) # orbit minimum + smallest automorphism reaching it
330
+ form.automorphism.generator_images() # ((1, 0), (1, 1)): the "matrix", column by column
331
+ group.transporter(x, group.apply(form.automorphism, x))
332
+
333
+ atoms = tuple(C2xC4.enumerate_atom_catalogue())
334
+ group.orbit_representatives(atoms) # 11 orbits: representative -> members
335
+ ```
336
+
337
+ The group acts on sequences and on tuples of sequences; tuples are treated as
338
+ multisets unless `ordered=True` (the canonical form of a tuple also records
339
+ the permutation that sorts the image). Canonical forms are minima with
340
+ respect to the total order on sequences (length first, then the sorted term
341
+ list, exposed through `<`), so representatives do not depend on how the group
342
+ was found.
343
+
344
+ The parent decides how the group is obtained: a `FiniteAdditiveGroup` may
345
+ receive a callable `automorphism_group=` returning an `AutomorphismGroup`
346
+ built from any element type with `apply_term`, `compose` and `inverse`
347
+ callbacks (for example units acting on a cyclic group, or CAS matrices);
348
+ otherwise its `automorphism_generators` are closed under composition.
349
+ Orbit traversals cost proportionally to the orbit size; stabilizers and
350
+ smallest transporters scan the whole group once, so they are cheap for
351
+ groups of a few thousand elements and expensive beyond that.
352
+ `AutomorphismGroupUnavailable` is raised when neither a provider nor
353
+ generators exist.
354
+
355
+ ## Indexed and serialized catalogues
356
+
357
+ An `AtomCatalogue` stores its atoms in the sequence order (length first,
358
+ then terms), so `catalogue.index(atom)` and `catalogue[i]` form a stable
359
+ identifier namespace. It can be filtered, classified into automorphism
360
+ orbits, and written to or read from JSON lines:
361
+
362
+ ```python
363
+ catalogue = C2xC4.enumerate_atom_catalogue()
364
+ catalogue.index(atom), catalogue[7], atom in catalogue
365
+ short = catalogue.restrict(max_length=3) # also restrict(support=...)
366
+ for orbit in catalogue.orbits(): # AtomOrbit(representative, indices, stabilizer_order)
367
+ ...
368
+ catalogue.representative(atom) # orbit minimum of an atom
369
+ catalogue.transporter(atom) # an automorphism mapping atom to it
370
+ catalogue.permutation(group.elements[1]) # an automorphism as an index permutation
371
+
372
+ catalogue.to_jsonl("atoms.jsonl", annotate=lambda atom: {"label": ...})
373
+ same = AtomCatalogue.from_jsonl("atoms.jsonl", C2xC4)
374
+ same.annotations[atom]["label"]
375
+ catalogue.digest() # sha256 of the atoms, annotation-independent
376
+ ```
377
+
378
+ `from_jsonl` checks the order, the indices and the digest, and re-verifies
379
+ every record as an atom unless `verify=False`. Terms are serialized through
380
+ the parent: `FiniteAdditiveGroup` accepts `encode_term=`/`decode_term=`,
381
+ coordinate groups and Sage vector spaces use lists of integers by default,
382
+ and `sequence.encode()` / `space.decode(data)` apply the codec to whole
383
+ sequences.
384
+
385
+ ## Memoized factorization queries
386
+
387
+ A `FactorizationCache` answers length-set questions over one catalogue with
388
+ caching, and can exploit that length sets are automorphism invariants:
389
+
390
+ ```python
391
+ cache = C2xC4.factorization_cache(catalogue)
392
+ cache.length_set(x), cache.minimum(x), cache.maximum(x)
393
+ cache.has_length(x, 3), cache.witness(x, 3), cache.witnesses(x)
394
+ cache.statistics() # hits, misses, solver builds
395
+
396
+ aware = C2xC4.factorization_cache(catalogue, group=group)
397
+ aware.length_set(group.apply(a, x)) == aware.length_set(x) # one solve per orbit
398
+ aware.witness(group.apply(a, x), 3) # transported back through a^-1
399
+
400
+ cache.to_jsonl("lengths.jsonl") # bound to catalogue.digest()
401
+ FactorizationCache.from_jsonl("lengths.jsonl", catalogue)
402
+ ```
403
+
404
+ Length sets are kept without bound; solvers are kept in a bounded LRU
405
+ (`maxsize=`) so that follow-up questions about a recent sequence reuse its
406
+ remainder graph. With `group=` every query is keyed by a canonical form.
407
+ The default is the full canonical form, whose orbit traversal is worthwhile
408
+ when solving is the expensive part; for large groups pass
409
+ `canonicalize=AnchoredCanonicalizer(catalogue, group)`, which only
410
+ transports the largest atom dividing the sequence to its orbit
411
+ representative and reduces under that representative's stabilizer. Any
412
+ callable returning an automorphic image of its argument (optionally with the
413
+ automorphism) is accepted as `canonicalize=`.
414
+
315
415
  ## Benchmarks
316
416
 
317
417
  Run the short-to-very-long performance corpus with:
@@ -292,6 +292,106 @@ Automatic discovery raises `AutomorphismActionUnavailable` when the parent
292
292
  does not expose suitable generators, in which case an explicit action is
293
293
  required.
294
294
 
295
+ ## Materialized automorphism groups
296
+
297
+ For a finite parent the whole automorphism group can be materialized, which
298
+ gives stabilizers, canonical orbit representatives, transporters and
299
+ classification of many objects at once:
300
+
301
+ ```python
302
+ G = FiniteAdditiveGroup.cyclic_product(2, 4)
303
+ C2xC4 = AdditiveSequenceSpace(G, davenport_bound=5)
304
+ group = C2xC4.automorphism_group() # AutomorphismGroup(order=8)
305
+
306
+ x = C2xC4([(1, 1), (1, 1), (0, 2)])
307
+ group.orbit(x) # the four images, sorted
308
+ len(group.stabilizer(x)) # 2
309
+ form = group.canonical_form(x) # orbit minimum + smallest automorphism reaching it
310
+ form.automorphism.generator_images() # ((1, 0), (1, 1)): the "matrix", column by column
311
+ group.transporter(x, group.apply(form.automorphism, x))
312
+
313
+ atoms = tuple(C2xC4.enumerate_atom_catalogue())
314
+ group.orbit_representatives(atoms) # 11 orbits: representative -> members
315
+ ```
316
+
317
+ The group acts on sequences and on tuples of sequences; tuples are treated as
318
+ multisets unless `ordered=True` (the canonical form of a tuple also records
319
+ the permutation that sorts the image). Canonical forms are minima with
320
+ respect to the total order on sequences (length first, then the sorted term
321
+ list, exposed through `<`), so representatives do not depend on how the group
322
+ was found.
323
+
324
+ The parent decides how the group is obtained: a `FiniteAdditiveGroup` may
325
+ receive a callable `automorphism_group=` returning an `AutomorphismGroup`
326
+ built from any element type with `apply_term`, `compose` and `inverse`
327
+ callbacks (for example units acting on a cyclic group, or CAS matrices);
328
+ otherwise its `automorphism_generators` are closed under composition.
329
+ Orbit traversals cost proportionally to the orbit size; stabilizers and
330
+ smallest transporters scan the whole group once, so they are cheap for
331
+ groups of a few thousand elements and expensive beyond that.
332
+ `AutomorphismGroupUnavailable` is raised when neither a provider nor
333
+ generators exist.
334
+
335
+ ## Indexed and serialized catalogues
336
+
337
+ An `AtomCatalogue` stores its atoms in the sequence order (length first,
338
+ then terms), so `catalogue.index(atom)` and `catalogue[i]` form a stable
339
+ identifier namespace. It can be filtered, classified into automorphism
340
+ orbits, and written to or read from JSON lines:
341
+
342
+ ```python
343
+ catalogue = C2xC4.enumerate_atom_catalogue()
344
+ catalogue.index(atom), catalogue[7], atom in catalogue
345
+ short = catalogue.restrict(max_length=3) # also restrict(support=...)
346
+ for orbit in catalogue.orbits(): # AtomOrbit(representative, indices, stabilizer_order)
347
+ ...
348
+ catalogue.representative(atom) # orbit minimum of an atom
349
+ catalogue.transporter(atom) # an automorphism mapping atom to it
350
+ catalogue.permutation(group.elements[1]) # an automorphism as an index permutation
351
+
352
+ catalogue.to_jsonl("atoms.jsonl", annotate=lambda atom: {"label": ...})
353
+ same = AtomCatalogue.from_jsonl("atoms.jsonl", C2xC4)
354
+ same.annotations[atom]["label"]
355
+ catalogue.digest() # sha256 of the atoms, annotation-independent
356
+ ```
357
+
358
+ `from_jsonl` checks the order, the indices and the digest, and re-verifies
359
+ every record as an atom unless `verify=False`. Terms are serialized through
360
+ the parent: `FiniteAdditiveGroup` accepts `encode_term=`/`decode_term=`,
361
+ coordinate groups and Sage vector spaces use lists of integers by default,
362
+ and `sequence.encode()` / `space.decode(data)` apply the codec to whole
363
+ sequences.
364
+
365
+ ## Memoized factorization queries
366
+
367
+ A `FactorizationCache` answers length-set questions over one catalogue with
368
+ caching, and can exploit that length sets are automorphism invariants:
369
+
370
+ ```python
371
+ cache = C2xC4.factorization_cache(catalogue)
372
+ cache.length_set(x), cache.minimum(x), cache.maximum(x)
373
+ cache.has_length(x, 3), cache.witness(x, 3), cache.witnesses(x)
374
+ cache.statistics() # hits, misses, solver builds
375
+
376
+ aware = C2xC4.factorization_cache(catalogue, group=group)
377
+ aware.length_set(group.apply(a, x)) == aware.length_set(x) # one solve per orbit
378
+ aware.witness(group.apply(a, x), 3) # transported back through a^-1
379
+
380
+ cache.to_jsonl("lengths.jsonl") # bound to catalogue.digest()
381
+ FactorizationCache.from_jsonl("lengths.jsonl", catalogue)
382
+ ```
383
+
384
+ Length sets are kept without bound; solvers are kept in a bounded LRU
385
+ (`maxsize=`) so that follow-up questions about a recent sequence reuse its
386
+ remainder graph. With `group=` every query is keyed by a canonical form.
387
+ The default is the full canonical form, whose orbit traversal is worthwhile
388
+ when solving is the expensive part; for large groups pass
389
+ `canonicalize=AnchoredCanonicalizer(catalogue, group)`, which only
390
+ transports the largest atom dividing the sequence to its orbit
391
+ representative and reduces under that representative's stabilizer. Any
392
+ callable returning an automorphic image of its argument (optionally with the
393
+ automorphism) is accepted as `canonicalize=`.
394
+
295
395
  ## Benchmarks
296
396
 
297
397
  Run the short-to-very-long performance corpus with:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "zero-sum-sequences"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Computations with additive and zero-sum sequences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -3,8 +3,15 @@
3
3
  from importlib.metadata import version as _distribution_version
4
4
 
5
5
  from .additive_sequence import AdditiveSequence, AdditiveSequenceSpace
6
- from .atom_catalogue import AtomCatalogue
6
+ from .atom_catalogue import AtomCatalogue, AtomOrbit
7
+ from .automorphisms import (
8
+ Automorphism,
9
+ AutomorphismGroup,
10
+ AutomorphismGroupUnavailable,
11
+ CanonicalForm,
12
+ )
7
13
  from .factorization import Factorization, FactorizationSolver
14
+ from .factorization_cache import AnchoredCanonicalizer, FactorizationCache
8
15
  from .orbits import (
9
16
  AutomorphismAction,
10
17
  AutomorphismActionUnavailable,
@@ -17,12 +24,19 @@ __all__ = [
17
24
  "AdditiveSequence",
18
25
  "AdditiveSequenceSpace",
19
26
  "AtomCatalogue",
27
+ "AtomOrbit",
20
28
  "Factorization",
21
29
  "FactorizationRelation",
30
+ "AnchoredCanonicalizer",
31
+ "FactorizationCache",
22
32
  "FactorizationSolver",
23
33
  "FiniteAdditiveGroup",
34
+ "Automorphism",
24
35
  "AutomorphismAction",
25
36
  "AutomorphismActionUnavailable",
37
+ "AutomorphismGroup",
38
+ "AutomorphismGroupUnavailable",
39
+ "CanonicalForm",
26
40
  "OrbitWitness",
27
41
  ]
28
42
  __version__ = _distribution_version("zero-sum-sequences")
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import functools
5
6
  import itertools
6
7
  from collections import Counter
7
8
  from collections.abc import Callable, Iterable, Iterator, Mapping
@@ -74,7 +75,12 @@ class AdditiveSequenceSpace(Generic[Element]):
74
75
  A positive upper bound for the parent's Davenport constant.
75
76
  """
76
77
 
77
- __slots__ = ("_parent", "_davenport_bound", "_automorphism_action")
78
+ __slots__ = (
79
+ "_parent",
80
+ "_davenport_bound",
81
+ "_automorphism_action",
82
+ "_automorphism_group",
83
+ )
78
84
 
79
85
  def __init__(self, parent, *, davenport_bound: int) -> None:
80
86
  if not callable(parent):
@@ -88,6 +94,52 @@ class AdditiveSequenceSpace(Generic[Element]):
88
94
  name="Davenport bound",
89
95
  )
90
96
  self._automorphism_action = None
97
+ self._automorphism_group = None
98
+
99
+ def factorization_cache(self, catalogue, *, group=None, canonicalize=None, maxsize: int | None = 1024):
100
+ """A :class:`FactorizationCache` over ``catalogue`` for this space."""
101
+
102
+ from .factorization_cache import FactorizationCache
103
+
104
+ if catalogue.space is not self:
105
+ raise TypeError("the catalogue belongs to a different space")
106
+ return FactorizationCache(
107
+ catalogue, group=group, canonicalize=canonicalize, maxsize=maxsize
108
+ )
109
+
110
+ def encode_term(self, term: Element) -> object:
111
+ """JSON-compatible encoding of one term, delegated to the parent."""
112
+
113
+ encoder = getattr(self._parent, "encode_term", None)
114
+ if callable(encoder):
115
+ return encoder(term)
116
+ from .parents import default_encode_term
117
+
118
+ return default_encode_term(term)
119
+
120
+ def decode_term(self, data: object) -> Element:
121
+ """Inverse of :meth:`encode_term`, delegated to the parent."""
122
+
123
+ decoder = getattr(self._parent, "decode_term", None)
124
+ if callable(decoder):
125
+ return _immutable_term(decoder(data))
126
+ return _immutable_term(self._parent(data))
127
+
128
+ def decode(self, data: Iterable[object]) -> AdditiveSequence[Element]:
129
+ """Construct a sequence from a list of encoded terms."""
130
+
131
+ return self(self.decode_term(item) for item in data)
132
+
133
+ def automorphism_group(self):
134
+ """Return the materialized automorphism group of the additive parent.
135
+
136
+ The parent may supply the group itself; otherwise it is the closure
137
+ of the parent's automorphism generators. The result is cached.
138
+ """
139
+
140
+ from .automorphisms import automorphism_group
141
+
142
+ return automorphism_group(self)
91
143
 
92
144
  @property
93
145
  def base_parent(self):
@@ -226,6 +278,7 @@ class AdditiveSequenceSpace(Generic[Element]):
226
278
  )
227
279
 
228
280
 
281
+ @functools.total_ordering
229
282
  class AdditiveSequence(Generic[Element]):
230
283
  """An immutable finite multiset in an :class:`AdditiveSequenceSpace`.
231
284
 
@@ -258,6 +311,28 @@ class AdditiveSequence(Generic[Element]):
258
311
  self._length = sum(count for _, count in items)
259
312
  self._hash = hash((space, items))
260
313
 
314
+ @classmethod
315
+ def _from_items(
316
+ cls, space: AdditiveSequenceSpace[Element], items: tuple[tuple[Element, int], ...]
317
+ ) -> AdditiveSequence[Element]:
318
+ """Fast constructor from sorted ``(term, count)`` items of canonical terms."""
319
+
320
+ sequence = cls.__new__(cls)
321
+ sequence._space = space
322
+ sequence._items = items
323
+ sequence._length = sum(count for _, count in items)
324
+ sequence._hash = hash((space, items))
325
+ return sequence
326
+
327
+ def _map_canonical(self, mapping: Callable[[Element], Element]) -> AdditiveSequence[Element]:
328
+ """Image under a term map whose values are canonical parent elements."""
329
+
330
+ counts: dict[Element, int] = {}
331
+ for term, count in self._items:
332
+ image = mapping(term)
333
+ counts[image] = counts.get(image, 0) + count
334
+ return AdditiveSequence._from_items(self._space, tuple(sorted(counts.items())))
335
+
261
336
  def parent(self) -> AdditiveSequenceSpace[Element]:
262
337
  """Return the configured sequence space."""
263
338
 
@@ -558,6 +633,19 @@ class AdditiveSequence(Generic[Element]):
558
633
  def __rmul__(self, repetitions: object) -> Self:
559
634
  return self * repetitions
560
635
 
636
+ def encode(self) -> list[object]:
637
+ """The sorted term list with multiplicities, terms encoded by the space."""
638
+
639
+ return [self._space.encode_term(term) for term in self]
640
+
641
+ def __lt__(self, other: object) -> bool:
642
+ """Total order: shorter sequences first, then the sorted term lists."""
643
+
644
+ if not isinstance(other, AdditiveSequence):
645
+ return NotImplemented
646
+ self._require_same_space(other)
647
+ return (self._length, tuple(self)) < (other._length, tuple(other))
648
+
561
649
  def __eq__(self, other: object) -> bool:
562
650
  if not isinstance(other, AdditiveSequence):
563
651
  return NotImplemented