zero-sum-sequences 0.1.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 (27) hide show
  1. zero_sum_sequences-0.1.0/.gitignore +9 -0
  2. zero_sum_sequences-0.1.0/LICENSE +21 -0
  3. zero_sum_sequences-0.1.0/PKG-INFO +228 -0
  4. zero_sum_sequences-0.1.0/README.md +208 -0
  5. zero_sum_sequences-0.1.0/benchmarks/README.md +32 -0
  6. zero_sum_sequences-0.1.0/benchmarks/__init__.py +1 -0
  7. zero_sum_sequences-0.1.0/benchmarks/benchmark_factorization.py +136 -0
  8. zero_sum_sequences-0.1.0/benchmarks/factorization_cases.py +120 -0
  9. zero_sum_sequences-0.1.0/notebooks/tutorial.ipynb +424 -0
  10. zero_sum_sequences-0.1.0/pyproject.toml +53 -0
  11. zero_sum_sequences-0.1.0/src/zero_sum_sequences/__init__.py +25 -0
  12. zero_sum_sequences-0.1.0/src/zero_sum_sequences/additive_sequence.py +531 -0
  13. zero_sum_sequences-0.1.0/src/zero_sum_sequences/atom_catalogue.py +92 -0
  14. zero_sum_sequences-0.1.0/src/zero_sum_sequences/factorization.py +385 -0
  15. zero_sum_sequences-0.1.0/src/zero_sum_sequences/orbits.py +533 -0
  16. zero_sum_sequences-0.1.0/src/zero_sum_sequences/parents.py +264 -0
  17. zero_sum_sequences-0.1.0/tests/groups.py +24 -0
  18. zero_sum_sequences-0.1.0/tests/test_additive_sequence.py +145 -0
  19. zero_sum_sequences-0.1.0/tests/test_atom_catalogue.py +151 -0
  20. zero_sum_sequences-0.1.0/tests/test_factorization.py +116 -0
  21. zero_sum_sequences-0.1.0/tests/test_factorization_benchmarks.py +40 -0
  22. zero_sum_sequences-0.1.0/tests/test_factorization_enumeration.py +176 -0
  23. zero_sum_sequences-0.1.0/tests/test_generic_parents.py +116 -0
  24. zero_sum_sequences-0.1.0/tests/test_orbits.py +202 -0
  25. zero_sum_sequences-0.1.0/tests/test_orbits_sage.py +81 -0
  26. zero_sum_sequences-0.1.0/tests/test_package_metadata.py +7 -0
  27. zero_sum_sequences-0.1.0/tests/test_parents.py +147 -0
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ __pycache__/
7
+ *.py[cod]
8
+ .ipynb_checkpoints/
9
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Hackl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.5
2
+ Name: zero-sum-sequences
3
+ Version: 0.1.0
4
+ Summary: Computations with additive and zero-sum sequences
5
+ Project-URL: Repository, https://github.com/behackl/zero-sum-sequences
6
+ Author: Benjamin Hackl
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: networkx>=3.2
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.3; extra == 'dev'
13
+ Provides-Extra: sage
14
+ Requires-Dist: sagelite[gap]==10.9.post1; extra == 'sage'
15
+ Provides-Extra: tutorial
16
+ Requires-Dist: ipykernel>=6.29; extra == 'tutorial'
17
+ Requires-Dist: matplotlib>=3.8; extra == 'tutorial'
18
+ Requires-Dist: nbconvert>=7.16; extra == 'tutorial'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Zero-sum sequences
22
+
23
+ `zero-sum-sequences` provides immutable finite additive sequences and tools for
24
+ enumerating their factorizations into minimal zero-sum sequences. The runtime
25
+ is ordinary Python with NetworkX; SageMath is supported as an optional source
26
+ of additive parents, but is not required.
27
+
28
+ The package keeps group-specific mathematics explicit. Callers configure the
29
+ ambient parent and provide an upper bound for its Davenport constant; the
30
+ package does not infer structural invariants from the parent.
31
+
32
+ ## Installation
33
+
34
+ Install the latest release from PyPI into Python 3.12 or newer:
35
+
36
+ ```console
37
+ python -m pip install zero-sum-sequences
38
+ ```
39
+
40
+ The optional `sage` extra installs the `sagelite` runtime on supported
41
+ platforms:
42
+
43
+ ```console
44
+ python -m pip install "zero-sum-sequences[sage]"
45
+ ```
46
+
47
+ For a reproducible development environment using the committed `uv.lock`,
48
+ clone the repository and run:
49
+
50
+ ```console
51
+ uv sync --extra dev
52
+ uv run python -m pytest
53
+ ```
54
+
55
+ Alternatively, install the package and its test tools with `pip`:
56
+
57
+ ```console
58
+ python -m pip install -e '.[dev]'
59
+ python -m pytest
60
+ ```
61
+
62
+ To include the Sage integration tests, use `uv sync --extra dev --extra sage`
63
+ or install the editable `.[dev,sage]` extra.
64
+
65
+ ## Tutorial
66
+
67
+ [![Launch the tutorial on Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/behackl/zero-sum-sequences/main?urlpath=lab/tree/notebooks/tutorial.ipynb)
68
+
69
+ The executable [tutorial](https://github.com/behackl/zero-sum-sequences/blob/main/notebooks/tutorial.ipynb) introduces the public API
70
+ with small hand-checkable examples in ordinary Python.
71
+ Its execution dependencies are available through
72
+ `uv sync --extra tutorial` or the corresponding `pip` extra.
73
+
74
+ ## Additive sequences
75
+
76
+ Configure an ambient parent and a Davenport upper bound once, then use the
77
+ resulting callable space to construct sequences. `FiniteAdditiveGroup` is a
78
+ small convenience adapter for groups represented by ordinary Python values:
79
+
80
+ ```python
81
+ from zero_sum_sequences import AdditiveSequenceSpace, FiniteAdditiveGroup
82
+
83
+ group = FiniteAdditiveGroup(
84
+ range(3),
85
+ zero=0,
86
+ add=lambda left, right: (left + right) % 3,
87
+ coerce=lambda value: int(value) % 3,
88
+ )
89
+ Sequences = AdditiveSequenceSpace(group, davenport_bound=3)
90
+
91
+ sequence = Sequences([1, 1, 2, 2])
92
+ sequence.is_zero_sum() # True
93
+ sequence.is_atom() # False: 1 * 2 is a proper zero-sum subsequence
94
+ sequence.multiplicities
95
+ ```
96
+
97
+ Existing algebra systems can be used directly. A compatible parent is
98
+ callable for coercion and provides `zero()`; its elements must be hashable and
99
+ mutually orderable. Elements may implement `+`, or the parent may provide
100
+ `add(left, right)`. Exhaustive catalogue enumeration additionally requires the
101
+ parent to be finite, iterable, and to provide `is_finite()`.
102
+
103
+ A sequence is immutable and hashable. Addition combines multisets,
104
+ subtraction removes a subsequence, and multiplication by a non-negative
105
+ integer repeats a sequence. Arithmetic preserves the sequence space, and the
106
+ empty sequence retains the base parent and its zero element.
107
+ `sequence.map_terms(mapping)` applies a map to every term and reconstructs the
108
+ result as a canonical multiset; pass `target_space=` when the image belongs to
109
+ a different sequence space.
110
+
111
+ The configured bound must not be smaller than the actual Davenport constant
112
+ when complete atom or factorization results are required.
113
+
114
+ ## Factorizations
115
+
116
+ The factorization engine indexes relevant atom divisors as sparse
117
+ multiplicity vectors and merges equal remainders in a directed acyclic graph.
118
+ Attained lengths are represented internally as integer bitsets.
119
+
120
+ ```python
121
+ lengths = sequence.length_set()
122
+ witnesses = sequence.factorization_witnesses()
123
+ factorizations = list(sequence.factorizations())
124
+ graph = sequence.factorization_digraph()
125
+ ```
126
+
127
+ `factorization_witnesses()` retains one factorization for every attained
128
+ length. Exhaustive `factorizations()` is necessarily output-sensitive, but it
129
+ emits each unordered factorization once. `factorization_digraph()` returns a
130
+ NetworkX `DiGraph` whose vertices are remainder sequences and whose edges
131
+ store the removed atom in their `"atom"` attribute.
132
+
133
+ For several queries against the same remainder DAG, use the public solver:
134
+
135
+ ```python
136
+ from zero_sum_sequences import FactorizationSolver
137
+
138
+ solver = FactorizationSolver(sequence)
139
+ solver.length_set()
140
+ solver.factorization_witnesses()
141
+ solver.statistics
142
+ solver.digraph()
143
+ ```
144
+
145
+ A complete precomputed catalogue can avoid rediscovering atoms:
146
+
147
+ ```python
148
+ from zero_sum_sequences import AtomCatalogue, FactorizationSolver
149
+
150
+ catalogue = AtomCatalogue(Sequences, atoms)
151
+ solver = FactorizationSolver(sequence, atom_catalogue=catalogue)
152
+ ```
153
+
154
+ For a small finite parent, a complete reduced catalogue can instead be
155
+ generated exhaustively through the configured Davenport bound:
156
+
157
+ ```python
158
+ catalogue = Sequences.enumerate_atom_catalogue()
159
+ ```
160
+
161
+ The parent must be a finite iterable additive group. Enumeration completes
162
+ each sorted prefix with its uniquely determined final term, rather than
163
+ testing multisets whose sum is nonzero. Completeness depends on the configured
164
+ Davenport bound being valid.
165
+
166
+ The caller is responsible for catalogue completeness. A catalogue used for a
167
+ complete result must contain every atom divisor relevant to the input.
168
+
169
+ Factorizations are computed in the reduced block monoid: identity terms are
170
+ not accepted by the solver and are not stored in an `AtomCatalogue`.
171
+
172
+ ## Automorphism orbits
173
+
174
+ Group automorphisms act on sequences term by term. Because an
175
+ `AdditiveSequence` is a multiset, the induced action automatically disregards
176
+ the order of its terms. The package can materialize an orbit, test orbit
177
+ membership, and return a shortest deterministic word in the configured
178
+ automorphism generators.
179
+
180
+ The convenience constructor for a product of cyclic groups configures the
181
+ coordinate group and generators of its full automorphism group together. For
182
+ example, the eight maximal-length atoms over $C_2\oplus C_4$ form one orbit:
183
+
184
+ ```python
185
+ G = FiniteAdditiveGroup.cyclic_product(2, 4)
186
+ C2xC4 = AdditiveSequenceSpace(G, davenport_bound=5)
187
+
188
+ atom = C2xC4([(0, 1)] * 3 + [(1, 0), (1, 1)])
189
+ other = C2xC4([(0, 1), (1, 0)] + [(1, 1)] * 3)
190
+
191
+ len(atom.orbit()) # 8
192
+ atom.is_in_same_orbit(other) # True
193
+ witness = atom.orbit_witness(other)
194
+ witness.show()
195
+ # (1, 0) ↦ (1, 0)
196
+ # (0, 1) ↦ (1, 1)
197
+ ```
198
+
199
+ Automorphism data is resolved only on the first orbit query and then cached on
200
+ the sequence space. A returned witness retains this context, so `show()` can
201
+ display the induced homomorphism on the base parent's distinguished additive
202
+ generators. `FiniteAdditiveGroup.cyclic_product(...)` supplies both the
203
+ standard additive generators and elementary coordinate scalings and shears.
204
+ Finite-dimensional Sage vector spaces over finite fields are recognized
205
+ automatically and use their basis and generators of their general linear
206
+ group. A custom `FiniteAdditiveGroup` can receive `additive_generators=` and
207
+ callable `automorphism_generators=` at construction; callers can also pass an
208
+ `AutomorphismAction` explicitly through the `action=` keyword.
209
+
210
+ Orbit traversal is breadth-first and therefore requires a finite orbit.
211
+ Automatic discovery raises `AutomorphismActionUnavailable` when the parent
212
+ does not expose suitable generators, in which case an explicit action is
213
+ required.
214
+
215
+ ## Benchmarks
216
+
217
+ Run the short-to-very-long performance corpus with:
218
+
219
+ ```console
220
+ python -m benchmarks.benchmark_factorization --enumerate
221
+ ```
222
+
223
+ The cases and their mathematical expectations are documented in
224
+ [`benchmarks/README.md`](https://github.com/behackl/zero-sum-sequences/blob/main/benchmarks/README.md).
225
+
226
+ ## License
227
+
228
+ The source code is available under the MIT License.
@@ -0,0 +1,208 @@
1
+ # Zero-sum sequences
2
+
3
+ `zero-sum-sequences` provides immutable finite additive sequences and tools for
4
+ enumerating their factorizations into minimal zero-sum sequences. The runtime
5
+ is ordinary Python with NetworkX; SageMath is supported as an optional source
6
+ of additive parents, but is not required.
7
+
8
+ The package keeps group-specific mathematics explicit. Callers configure the
9
+ ambient parent and provide an upper bound for its Davenport constant; the
10
+ package does not infer structural invariants from the parent.
11
+
12
+ ## Installation
13
+
14
+ Install the latest release from PyPI into Python 3.12 or newer:
15
+
16
+ ```console
17
+ python -m pip install zero-sum-sequences
18
+ ```
19
+
20
+ The optional `sage` extra installs the `sagelite` runtime on supported
21
+ platforms:
22
+
23
+ ```console
24
+ python -m pip install "zero-sum-sequences[sage]"
25
+ ```
26
+
27
+ For a reproducible development environment using the committed `uv.lock`,
28
+ clone the repository and run:
29
+
30
+ ```console
31
+ uv sync --extra dev
32
+ uv run python -m pytest
33
+ ```
34
+
35
+ Alternatively, install the package and its test tools with `pip`:
36
+
37
+ ```console
38
+ python -m pip install -e '.[dev]'
39
+ python -m pytest
40
+ ```
41
+
42
+ To include the Sage integration tests, use `uv sync --extra dev --extra sage`
43
+ or install the editable `.[dev,sage]` extra.
44
+
45
+ ## Tutorial
46
+
47
+ [![Launch the tutorial on Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/behackl/zero-sum-sequences/main?urlpath=lab/tree/notebooks/tutorial.ipynb)
48
+
49
+ The executable [tutorial](https://github.com/behackl/zero-sum-sequences/blob/main/notebooks/tutorial.ipynb) introduces the public API
50
+ with small hand-checkable examples in ordinary Python.
51
+ Its execution dependencies are available through
52
+ `uv sync --extra tutorial` or the corresponding `pip` extra.
53
+
54
+ ## Additive sequences
55
+
56
+ Configure an ambient parent and a Davenport upper bound once, then use the
57
+ resulting callable space to construct sequences. `FiniteAdditiveGroup` is a
58
+ small convenience adapter for groups represented by ordinary Python values:
59
+
60
+ ```python
61
+ from zero_sum_sequences import AdditiveSequenceSpace, FiniteAdditiveGroup
62
+
63
+ group = FiniteAdditiveGroup(
64
+ range(3),
65
+ zero=0,
66
+ add=lambda left, right: (left + right) % 3,
67
+ coerce=lambda value: int(value) % 3,
68
+ )
69
+ Sequences = AdditiveSequenceSpace(group, davenport_bound=3)
70
+
71
+ sequence = Sequences([1, 1, 2, 2])
72
+ sequence.is_zero_sum() # True
73
+ sequence.is_atom() # False: 1 * 2 is a proper zero-sum subsequence
74
+ sequence.multiplicities
75
+ ```
76
+
77
+ Existing algebra systems can be used directly. A compatible parent is
78
+ callable for coercion and provides `zero()`; its elements must be hashable and
79
+ mutually orderable. Elements may implement `+`, or the parent may provide
80
+ `add(left, right)`. Exhaustive catalogue enumeration additionally requires the
81
+ parent to be finite, iterable, and to provide `is_finite()`.
82
+
83
+ A sequence is immutable and hashable. Addition combines multisets,
84
+ subtraction removes a subsequence, and multiplication by a non-negative
85
+ integer repeats a sequence. Arithmetic preserves the sequence space, and the
86
+ empty sequence retains the base parent and its zero element.
87
+ `sequence.map_terms(mapping)` applies a map to every term and reconstructs the
88
+ result as a canonical multiset; pass `target_space=` when the image belongs to
89
+ a different sequence space.
90
+
91
+ The configured bound must not be smaller than the actual Davenport constant
92
+ when complete atom or factorization results are required.
93
+
94
+ ## Factorizations
95
+
96
+ The factorization engine indexes relevant atom divisors as sparse
97
+ multiplicity vectors and merges equal remainders in a directed acyclic graph.
98
+ Attained lengths are represented internally as integer bitsets.
99
+
100
+ ```python
101
+ lengths = sequence.length_set()
102
+ witnesses = sequence.factorization_witnesses()
103
+ factorizations = list(sequence.factorizations())
104
+ graph = sequence.factorization_digraph()
105
+ ```
106
+
107
+ `factorization_witnesses()` retains one factorization for every attained
108
+ length. Exhaustive `factorizations()` is necessarily output-sensitive, but it
109
+ emits each unordered factorization once. `factorization_digraph()` returns a
110
+ NetworkX `DiGraph` whose vertices are remainder sequences and whose edges
111
+ store the removed atom in their `"atom"` attribute.
112
+
113
+ For several queries against the same remainder DAG, use the public solver:
114
+
115
+ ```python
116
+ from zero_sum_sequences import FactorizationSolver
117
+
118
+ solver = FactorizationSolver(sequence)
119
+ solver.length_set()
120
+ solver.factorization_witnesses()
121
+ solver.statistics
122
+ solver.digraph()
123
+ ```
124
+
125
+ A complete precomputed catalogue can avoid rediscovering atoms:
126
+
127
+ ```python
128
+ from zero_sum_sequences import AtomCatalogue, FactorizationSolver
129
+
130
+ catalogue = AtomCatalogue(Sequences, atoms)
131
+ solver = FactorizationSolver(sequence, atom_catalogue=catalogue)
132
+ ```
133
+
134
+ For a small finite parent, a complete reduced catalogue can instead be
135
+ generated exhaustively through the configured Davenport bound:
136
+
137
+ ```python
138
+ catalogue = Sequences.enumerate_atom_catalogue()
139
+ ```
140
+
141
+ The parent must be a finite iterable additive group. Enumeration completes
142
+ each sorted prefix with its uniquely determined final term, rather than
143
+ testing multisets whose sum is nonzero. Completeness depends on the configured
144
+ Davenport bound being valid.
145
+
146
+ The caller is responsible for catalogue completeness. A catalogue used for a
147
+ complete result must contain every atom divisor relevant to the input.
148
+
149
+ Factorizations are computed in the reduced block monoid: identity terms are
150
+ not accepted by the solver and are not stored in an `AtomCatalogue`.
151
+
152
+ ## Automorphism orbits
153
+
154
+ Group automorphisms act on sequences term by term. Because an
155
+ `AdditiveSequence` is a multiset, the induced action automatically disregards
156
+ the order of its terms. The package can materialize an orbit, test orbit
157
+ membership, and return a shortest deterministic word in the configured
158
+ automorphism generators.
159
+
160
+ The convenience constructor for a product of cyclic groups configures the
161
+ coordinate group and generators of its full automorphism group together. For
162
+ example, the eight maximal-length atoms over $C_2\oplus C_4$ form one orbit:
163
+
164
+ ```python
165
+ G = FiniteAdditiveGroup.cyclic_product(2, 4)
166
+ C2xC4 = AdditiveSequenceSpace(G, davenport_bound=5)
167
+
168
+ atom = C2xC4([(0, 1)] * 3 + [(1, 0), (1, 1)])
169
+ other = C2xC4([(0, 1), (1, 0)] + [(1, 1)] * 3)
170
+
171
+ len(atom.orbit()) # 8
172
+ atom.is_in_same_orbit(other) # True
173
+ witness = atom.orbit_witness(other)
174
+ witness.show()
175
+ # (1, 0) ↦ (1, 0)
176
+ # (0, 1) ↦ (1, 1)
177
+ ```
178
+
179
+ Automorphism data is resolved only on the first orbit query and then cached on
180
+ the sequence space. A returned witness retains this context, so `show()` can
181
+ display the induced homomorphism on the base parent's distinguished additive
182
+ generators. `FiniteAdditiveGroup.cyclic_product(...)` supplies both the
183
+ standard additive generators and elementary coordinate scalings and shears.
184
+ Finite-dimensional Sage vector spaces over finite fields are recognized
185
+ automatically and use their basis and generators of their general linear
186
+ group. A custom `FiniteAdditiveGroup` can receive `additive_generators=` and
187
+ callable `automorphism_generators=` at construction; callers can also pass an
188
+ `AutomorphismAction` explicitly through the `action=` keyword.
189
+
190
+ Orbit traversal is breadth-first and therefore requires a finite orbit.
191
+ Automatic discovery raises `AutomorphismActionUnavailable` when the parent
192
+ does not expose suitable generators, in which case an explicit action is
193
+ required.
194
+
195
+ ## Benchmarks
196
+
197
+ Run the short-to-very-long performance corpus with:
198
+
199
+ ```console
200
+ python -m benchmarks.benchmark_factorization --enumerate
201
+ ```
202
+
203
+ The cases and their mathematical expectations are documented in
204
+ [`benchmarks/README.md`](https://github.com/behackl/zero-sum-sequences/blob/main/benchmarks/README.md).
205
+
206
+ ## License
207
+
208
+ The source code is available under the MIT License.
@@ -0,0 +1,32 @@
1
+ # Factorization benchmark corpus
2
+
3
+ The benchmark corpus is shared by the test suite and timing runner. It
4
+ separates input size from combinatorial difficulty and checks the sole
5
+ supported memoized factorization implementation.
6
+
7
+ | Case | Tier | Terms | Expected length set |
8
+ |---|---|---:|---|
9
+ | `c3-atom` | short | 3 | `{1}` |
10
+ | `c3-balanced-block` | short | 6 | `{2,3}` |
11
+ | `rank-three-inverse-pair` | short | 14 | `{2,3,4,5,7}` |
12
+ | `c3-balanced-power-7` | long | 42 | `[14,21]` |
13
+ | `c3-balanced-power-40` | long | 240 | `[80,120]` |
14
+ | `rank-three-inverse-power-3` | long | 42 | `[6,21]` |
15
+ | `c3-balanced-power-250` | very long | 1,500 | `[500,750]` |
16
+ | `c3-pure-power-5000` | very long | 15,000 | `{5000}` |
17
+
18
+ For the balanced `C_3` family, write `A = 1^3`, `B = 2^3`, and
19
+ `C = 1 * 2`. A factorization of `(A * B)^n` has `x` copies each of `A`
20
+ and `B`, and `3(n-x)` copies of `C`, for `0 <= x <= n`. Its length set is
21
+ therefore exactly `[2n,3n]`, and it has `n+1` unordered factorizations. The
22
+ pure-power case has one factorization of length 5,000.
23
+
24
+ The rank-three cases exercise a less artificial support and a larger
25
+ candidate-atom set. The runner reports candidate indexing, state-graph solve,
26
+ and safe exhaustive-enumeration timings while validating all expected results.
27
+
28
+ Run:
29
+
30
+ ```console
31
+ python -m benchmarks.benchmark_factorization --enumerate
32
+ ```
@@ -0,0 +1 @@
1
+ """Reproducible performance benchmarks for the computational package."""
@@ -0,0 +1,136 @@
1
+ """Benchmark the memoized factorization implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import platform
7
+ from collections.abc import Sequence
8
+ from time import perf_counter
9
+
10
+ from zero_sum_sequences import FactorizationSolver
11
+
12
+ from .factorization_cases import factorization_benchmark_cases
13
+
14
+
15
+ def elapsed(callable_):
16
+ start = perf_counter()
17
+ result = callable_()
18
+ return result, perf_counter() - start
19
+
20
+
21
+ def benchmark_case(
22
+ case, *, enumerate_factorizations: bool, repeats: int
23
+ ) -> dict[str, object]:
24
+ best_run = None
25
+ for _ in range(repeats):
26
+ solver, preparation_seconds = elapsed(
27
+ lambda: FactorizationSolver(case.sequence)
28
+ )
29
+ lengths, solve_seconds = elapsed(solver.length_set)
30
+ if best_run is None or preparation_seconds + solve_seconds < sum(best_run[1:]):
31
+ best_run = (solver, preparation_seconds, solve_seconds)
32
+ assert best_run is not None
33
+ solver, preparation_seconds, solve_seconds = best_run
34
+ lengths = solver.length_set()
35
+ if lengths != set(case.expected_lengths):
36
+ raise AssertionError(
37
+ f"{case.name}: expected {sorted(case.expected_lengths)}, "
38
+ f"got {sorted(lengths)}"
39
+ )
40
+ statistics = solver.statistics
41
+
42
+ factorization_count: int | None = None
43
+ enumeration_seconds: float | None = None
44
+ if enumerate_factorizations and case.enumerate_factorizations:
45
+ factorizations, enumeration_seconds = elapsed(
46
+ lambda: list(solver.factorizations())
47
+ )
48
+ factorization_count = len(factorizations)
49
+ if factorization_count != case.expected_factorizations:
50
+ raise AssertionError(
51
+ f"{case.name}: expected {case.expected_factorizations} "
52
+ f"factorizations, got {factorization_count}"
53
+ )
54
+
55
+ return {
56
+ "name": case.name,
57
+ "tier": case.tier,
58
+ "terms": len(case.sequence),
59
+ "lengths": len(lengths),
60
+ "atoms": statistics.candidate_atoms,
61
+ "states": statistics.states,
62
+ "transitions": statistics.transitions,
63
+ "prepare_ms": preparation_seconds * 1000,
64
+ "solve_ms": solve_seconds * 1000,
65
+ "factorizations": factorization_count,
66
+ "enumerate_ms": (
67
+ None if enumeration_seconds is None else enumeration_seconds * 1000
68
+ ),
69
+ }
70
+
71
+
72
+ def format_value(value: object) -> str:
73
+ if value is None:
74
+ return "—"
75
+ if isinstance(value, float):
76
+ return f"{value:.3f}"
77
+ return str(value)
78
+
79
+
80
+ def print_markdown(rows: list[dict[str, object]]) -> None:
81
+ columns = (
82
+ "name",
83
+ "tier",
84
+ "terms",
85
+ "lengths",
86
+ "atoms",
87
+ "states",
88
+ "transitions",
89
+ "prepare_ms",
90
+ "solve_ms",
91
+ "factorizations",
92
+ "enumerate_ms",
93
+ )
94
+ print("| " + " | ".join(columns) + " |")
95
+ print("| " + " | ".join("---" for _ in columns) + " |")
96
+ for row in rows:
97
+ print("| " + " | ".join(format_value(row[column]) for column in columns) + " |")
98
+
99
+
100
+ def build_parser() -> argparse.ArgumentParser:
101
+ parser = argparse.ArgumentParser(description=__doc__)
102
+ parser.add_argument(
103
+ "--enumerate",
104
+ action="store_true",
105
+ help="also enumerate unique factorizations for cases marked as safe",
106
+ )
107
+ parser.add_argument(
108
+ "--repeats",
109
+ type=int,
110
+ default=3,
111
+ help="number of timing repetitions; the minimum is reported",
112
+ )
113
+ return parser
114
+
115
+
116
+ def main(argv: Sequence[str] | None = None) -> int:
117
+ arguments = build_parser().parse_args(argv)
118
+ if arguments.repeats < 1:
119
+ raise SystemExit("--repeats must be positive")
120
+ print(f"Python {platform.python_version()}")
121
+ print()
122
+ cases = factorization_benchmark_cases()
123
+ rows = [
124
+ benchmark_case(
125
+ case,
126
+ enumerate_factorizations=arguments.enumerate,
127
+ repeats=arguments.repeats,
128
+ )
129
+ for case in cases
130
+ ]
131
+ print_markdown(rows)
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())