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,385 @@
|
|
|
1
|
+
"""Memoized factorizations of additive sequences into atoms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeAlias
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
from .additive_sequence import AdditiveSequence, Element, _non_negative_integer
|
|
12
|
+
from .atom_catalogue import AtomCatalogue
|
|
13
|
+
|
|
14
|
+
Factorization: TypeAlias = tuple[AdditiveSequence[Element], ...]
|
|
15
|
+
MultiplicityState: TypeAlias = tuple[int, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _atom_divisors(
|
|
19
|
+
sequence: AdditiveSequence[Element],
|
|
20
|
+
) -> Iterator[AdditiveSequence[Element]]:
|
|
21
|
+
"""Yield every distinct atom divisor within the configured bound."""
|
|
22
|
+
|
|
23
|
+
maximum_length = min(
|
|
24
|
+
len(sequence),
|
|
25
|
+
sequence.parent().davenport_bound,
|
|
26
|
+
)
|
|
27
|
+
zero = sequence.parent().base_parent.zero()
|
|
28
|
+
for candidate in sequence.subsequences(
|
|
29
|
+
nonempty=True,
|
|
30
|
+
max_length=maximum_length,
|
|
31
|
+
):
|
|
32
|
+
if candidate.is_atom() and zero not in candidate:
|
|
33
|
+
yield candidate
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class FactorizationStatistics:
|
|
38
|
+
"""Size statistics for one memoized factorization problem."""
|
|
39
|
+
|
|
40
|
+
candidate_atoms: int
|
|
41
|
+
states: int
|
|
42
|
+
transitions: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class _IndexedAtom(Generic[Element]):
|
|
47
|
+
sequence: AdditiveSequence[Element]
|
|
48
|
+
sparse_counts: tuple[tuple[int, int], ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FactorizationSolver(Generic[Element]):
|
|
52
|
+
"""Solve one reduced factorization problem on immutable multiplicity states.
|
|
53
|
+
|
|
54
|
+
Relevant nonzero atoms are discovered once, or filtered from a complete
|
|
55
|
+
:class:`AtomCatalogue`, and encoded as sparse count vectors. The input
|
|
56
|
+
must contain no identity terms; equal remainder sequences become one state
|
|
57
|
+
in an acyclic graph.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
sequence: AdditiveSequence[Element],
|
|
63
|
+
*,
|
|
64
|
+
atom_catalogue: AtomCatalogue[Element] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
if not isinstance(sequence, AdditiveSequence):
|
|
67
|
+
raise TypeError("expected an additive sequence")
|
|
68
|
+
if atom_catalogue is not None:
|
|
69
|
+
if not isinstance(atom_catalogue, AtomCatalogue):
|
|
70
|
+
raise TypeError("atom_catalogue must be an AtomCatalogue")
|
|
71
|
+
if atom_catalogue.space is not sequence.parent():
|
|
72
|
+
raise TypeError("atom catalogue and sequence use different spaces")
|
|
73
|
+
if sequence.parent().base_parent.zero() in sequence:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"reduced factorizations require a sequence without zero terms"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.sequence = sequence
|
|
79
|
+
self.davenport_bound = sequence.parent().davenport_bound
|
|
80
|
+
self.support = sequence.support
|
|
81
|
+
self._support_index = {
|
|
82
|
+
term: index for index, term in enumerate(self.support)
|
|
83
|
+
}
|
|
84
|
+
multiplicities = sequence.multiplicities
|
|
85
|
+
self.initial_state: MultiplicityState = tuple(
|
|
86
|
+
multiplicities[term] for term in self.support
|
|
87
|
+
)
|
|
88
|
+
self.zero_state: MultiplicityState = (0,) * len(self.support)
|
|
89
|
+
|
|
90
|
+
candidates: Iterable[AdditiveSequence[Element]]
|
|
91
|
+
if atom_catalogue is None:
|
|
92
|
+
candidates = _atom_divisors(sequence)
|
|
93
|
+
else:
|
|
94
|
+
candidates = atom_catalogue.divisors(sequence)
|
|
95
|
+
self._atoms = self._index_atoms(candidates)
|
|
96
|
+
|
|
97
|
+
by_term: list[list[int]] = [[] for _ in self.support]
|
|
98
|
+
for atom_index, atom in enumerate(self._atoms):
|
|
99
|
+
for term_index, _ in atom.sparse_counts:
|
|
100
|
+
by_term[term_index].append(atom_index)
|
|
101
|
+
self._atoms_by_term = tuple(tuple(indices) for indices in by_term)
|
|
102
|
+
self._transitions: dict[
|
|
103
|
+
MultiplicityState, tuple[tuple[int, MultiplicityState], ...]
|
|
104
|
+
] | None = None
|
|
105
|
+
self._length_bits: dict[MultiplicityState, int] | None = None
|
|
106
|
+
|
|
107
|
+
def _index_atoms(
|
|
108
|
+
self, atoms: Iterable[AdditiveSequence[Element]]
|
|
109
|
+
) -> tuple[_IndexedAtom[Element], ...]:
|
|
110
|
+
indexed_by_counts: dict[MultiplicityState, _IndexedAtom[Element]] = {}
|
|
111
|
+
for atom in atoms:
|
|
112
|
+
if atom.parent() is not self.sequence.parent():
|
|
113
|
+
raise TypeError("candidate atom and sequence use different spaces")
|
|
114
|
+
counts = [0] * len(self.support)
|
|
115
|
+
valid = True
|
|
116
|
+
for term, count in atom.multiplicities.items():
|
|
117
|
+
index = self._support_index.get(term)
|
|
118
|
+
if index is None or count > self.initial_state[index]:
|
|
119
|
+
valid = False
|
|
120
|
+
break
|
|
121
|
+
counts[index] = count
|
|
122
|
+
if not valid or not atom:
|
|
123
|
+
continue
|
|
124
|
+
count_tuple = tuple(counts)
|
|
125
|
+
indexed_by_counts[count_tuple] = _IndexedAtom(
|
|
126
|
+
sequence=atom,
|
|
127
|
+
sparse_counts=tuple(
|
|
128
|
+
(index, count)
|
|
129
|
+
for index, count in enumerate(count_tuple)
|
|
130
|
+
if count
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
return tuple(
|
|
134
|
+
sorted(
|
|
135
|
+
indexed_by_counts.values(),
|
|
136
|
+
key=lambda atom: (len(atom.sequence), tuple(atom.sequence)),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _state_transitions(
|
|
141
|
+
self, state: MultiplicityState
|
|
142
|
+
) -> tuple[tuple[int, MultiplicityState], ...]:
|
|
143
|
+
if state == self.zero_state:
|
|
144
|
+
return ()
|
|
145
|
+
first_term = next(index for index, count in enumerate(state) if count)
|
|
146
|
+
transitions = []
|
|
147
|
+
for atom_index in self._atoms_by_term[first_term]:
|
|
148
|
+
atom = self._atoms[atom_index]
|
|
149
|
+
if all(state[index] >= count for index, count in atom.sparse_counts):
|
|
150
|
+
remainder = list(state)
|
|
151
|
+
for index, count in atom.sparse_counts:
|
|
152
|
+
remainder[index] -= count
|
|
153
|
+
transitions.append((atom_index, tuple(remainder)))
|
|
154
|
+
return tuple(transitions)
|
|
155
|
+
|
|
156
|
+
def _build_state_graph(self) -> None:
|
|
157
|
+
if self._transitions is not None:
|
|
158
|
+
return
|
|
159
|
+
transitions: dict[
|
|
160
|
+
MultiplicityState, tuple[tuple[int, MultiplicityState], ...]
|
|
161
|
+
] = {}
|
|
162
|
+
pending = [self.initial_state]
|
|
163
|
+
discovered = {self.initial_state}
|
|
164
|
+
while pending:
|
|
165
|
+
state = pending.pop()
|
|
166
|
+
state_transitions = self._state_transitions(state)
|
|
167
|
+
transitions[state] = state_transitions
|
|
168
|
+
for _, remainder in state_transitions:
|
|
169
|
+
if remainder not in discovered:
|
|
170
|
+
discovered.add(remainder)
|
|
171
|
+
pending.append(remainder)
|
|
172
|
+
self._transitions = transitions
|
|
173
|
+
|
|
174
|
+
def _solve_lengths(self) -> None:
|
|
175
|
+
if self._length_bits is not None:
|
|
176
|
+
return
|
|
177
|
+
self._build_state_graph()
|
|
178
|
+
assert self._transitions is not None
|
|
179
|
+
length_bits: dict[MultiplicityState, int] = {}
|
|
180
|
+
for state in sorted(self._transitions, key=sum):
|
|
181
|
+
if state == self.zero_state:
|
|
182
|
+
length_bits[state] = 1
|
|
183
|
+
continue
|
|
184
|
+
bits = 0
|
|
185
|
+
for _, remainder in self._transitions[state]:
|
|
186
|
+
bits |= length_bits[remainder] << 1
|
|
187
|
+
length_bits[state] = bits
|
|
188
|
+
self._length_bits = length_bits
|
|
189
|
+
|
|
190
|
+
def _sequence_from_state(
|
|
191
|
+
self, state: MultiplicityState
|
|
192
|
+
) -> AdditiveSequence[Element]:
|
|
193
|
+
return self.sequence.parent().from_multiplicities(
|
|
194
|
+
{
|
|
195
|
+
term: state[index]
|
|
196
|
+
for index, term in enumerate(self.support)
|
|
197
|
+
if state[index]
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def statistics(self) -> FactorizationStatistics:
|
|
203
|
+
"""Return atom, state and transition counts for this problem."""
|
|
204
|
+
|
|
205
|
+
self._build_state_graph()
|
|
206
|
+
assert self._transitions is not None
|
|
207
|
+
return FactorizationStatistics(
|
|
208
|
+
candidate_atoms=len(self._atoms),
|
|
209
|
+
states=len(self._transitions),
|
|
210
|
+
transitions=sum(len(edges) for edges in self._transitions.values()),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def length_set(self) -> set[int]:
|
|
214
|
+
"""Return the complete set of attained factorization lengths."""
|
|
215
|
+
|
|
216
|
+
self._solve_lengths()
|
|
217
|
+
assert self._length_bits is not None
|
|
218
|
+
bits = self._length_bits[self.initial_state]
|
|
219
|
+
return {
|
|
220
|
+
length
|
|
221
|
+
for length in range(bits.bit_length())
|
|
222
|
+
if bits & (1 << length)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def factorization_witnesses(self) -> dict[int, Factorization]:
|
|
226
|
+
"""Return one deterministic factorization per attained length."""
|
|
227
|
+
|
|
228
|
+
self._solve_lengths()
|
|
229
|
+
assert self._transitions is not None
|
|
230
|
+
assert self._length_bits is not None
|
|
231
|
+
witnesses = {}
|
|
232
|
+
for length in sorted(self.length_set()):
|
|
233
|
+
state = self.initial_state
|
|
234
|
+
remaining_length = length
|
|
235
|
+
factors = []
|
|
236
|
+
while remaining_length:
|
|
237
|
+
for atom_index, remainder in self._transitions[state]:
|
|
238
|
+
if self._length_bits[remainder] & (1 << (remaining_length - 1)):
|
|
239
|
+
factors.append(self._atoms[atom_index].sequence)
|
|
240
|
+
state = remainder
|
|
241
|
+
remaining_length -= 1
|
|
242
|
+
break
|
|
243
|
+
else: # pragma: no cover - guards the dynamic-programming invariant
|
|
244
|
+
raise RuntimeError("could not reconstruct factorization witness")
|
|
245
|
+
witnesses[length] = tuple(factors)
|
|
246
|
+
return witnesses
|
|
247
|
+
|
|
248
|
+
def factorization_witness(
|
|
249
|
+
self,
|
|
250
|
+
factor_count: int,
|
|
251
|
+
*,
|
|
252
|
+
minimum_matching_factors: int = 0,
|
|
253
|
+
factor_predicate: Callable[[AdditiveSequence[Element]], bool] | None = None,
|
|
254
|
+
) -> Factorization | None:
|
|
255
|
+
"""Find a factorization of a requested length and optional profile.
|
|
256
|
+
|
|
257
|
+
When ``minimum_matching_factors`` is positive, at least that many
|
|
258
|
+
factors must satisfy ``factor_predicate``. The search reuses the
|
|
259
|
+
remainder DAG and its length bitsets rather than enumerating every
|
|
260
|
+
factorization.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
factor_count = _non_negative_integer(factor_count, name="factor count")
|
|
264
|
+
minimum_matching_factors = _non_negative_integer(
|
|
265
|
+
minimum_matching_factors,
|
|
266
|
+
name="minimum matching factors",
|
|
267
|
+
)
|
|
268
|
+
if minimum_matching_factors > factor_count:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
"minimum matching factors cannot exceed factor count"
|
|
271
|
+
)
|
|
272
|
+
if minimum_matching_factors and not callable(factor_predicate):
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"factor_predicate is required when matching factors are required"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self._solve_lengths()
|
|
278
|
+
assert self._transitions is not None
|
|
279
|
+
assert self._length_bits is not None
|
|
280
|
+
if not self._length_bits[self.initial_state] & (1 << factor_count):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
frames: list[tuple[MultiplicityState, int, int, int]] = [
|
|
284
|
+
(self.initial_state, factor_count, minimum_matching_factors, 0)
|
|
285
|
+
]
|
|
286
|
+
path: list[int] = []
|
|
287
|
+
dead: set[tuple[MultiplicityState, int, int]] = set()
|
|
288
|
+
|
|
289
|
+
while frames:
|
|
290
|
+
state, remaining, required_matches, next_transition = frames[-1]
|
|
291
|
+
key = (state, remaining, required_matches)
|
|
292
|
+
|
|
293
|
+
if remaining == 0:
|
|
294
|
+
if state == self.zero_state and required_matches == 0:
|
|
295
|
+
return tuple(self._atoms[index].sequence for index in path)
|
|
296
|
+
dead.add(key)
|
|
297
|
+
frames.pop()
|
|
298
|
+
if path:
|
|
299
|
+
path.pop()
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
transitions = self._transitions[state]
|
|
303
|
+
if next_transition >= len(transitions):
|
|
304
|
+
dead.add(key)
|
|
305
|
+
frames.pop()
|
|
306
|
+
if path:
|
|
307
|
+
path.pop()
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
frames[-1] = (
|
|
311
|
+
state,
|
|
312
|
+
remaining,
|
|
313
|
+
required_matches,
|
|
314
|
+
next_transition + 1,
|
|
315
|
+
)
|
|
316
|
+
atom_index, remainder = transitions[next_transition]
|
|
317
|
+
atom = self._atoms[atom_index].sequence
|
|
318
|
+
matches = factor_predicate is not None and factor_predicate(atom)
|
|
319
|
+
next_required_matches = max(0, required_matches - int(matches))
|
|
320
|
+
next_remaining = remaining - 1
|
|
321
|
+
next_key = (remainder, next_remaining, next_required_matches)
|
|
322
|
+
if next_required_matches > next_remaining or next_key in dead:
|
|
323
|
+
continue
|
|
324
|
+
if not self._length_bits[remainder] & (1 << next_remaining):
|
|
325
|
+
dead.add(next_key)
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
path.append(atom_index)
|
|
329
|
+
frames.append(
|
|
330
|
+
(remainder, next_remaining, next_required_matches, 0)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
def factorizations(self) -> Iterator[Factorization]:
|
|
336
|
+
"""Yield every unordered factorization exactly once."""
|
|
337
|
+
|
|
338
|
+
path: list[AdditiveSequence[Element]] = []
|
|
339
|
+
stack: list[tuple[MultiplicityState, int]] = [(self.initial_state, 0)]
|
|
340
|
+
while stack:
|
|
341
|
+
state, next_atom = stack[-1]
|
|
342
|
+
if state == self.zero_state:
|
|
343
|
+
yield tuple(path)
|
|
344
|
+
stack.pop()
|
|
345
|
+
if stack:
|
|
346
|
+
path.pop()
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if next_atom == len(self._atoms):
|
|
350
|
+
stack.pop()
|
|
351
|
+
if stack:
|
|
352
|
+
path.pop()
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
stack[-1] = (state, next_atom + 1)
|
|
356
|
+
atom = self._atoms[next_atom]
|
|
357
|
+
if all(state[index] >= count for index, count in atom.sparse_counts):
|
|
358
|
+
remainder = list(state)
|
|
359
|
+
for index, count in atom.sparse_counts:
|
|
360
|
+
remainder[index] -= count
|
|
361
|
+
path.append(atom.sequence)
|
|
362
|
+
stack.append((tuple(remainder), next_atom))
|
|
363
|
+
|
|
364
|
+
def digraph(self):
|
|
365
|
+
"""Return the memoized remainder graph as a NetworkX ``DiGraph``.
|
|
366
|
+
|
|
367
|
+
Each edge stores the removed atom in its ``"atom"`` attribute.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
self._build_state_graph()
|
|
371
|
+
assert self._transitions is not None
|
|
372
|
+
sequences = {
|
|
373
|
+
state: self._sequence_from_state(state)
|
|
374
|
+
for state in self._transitions
|
|
375
|
+
}
|
|
376
|
+
graph = nx.DiGraph()
|
|
377
|
+
graph.add_nodes_from(sequences.values())
|
|
378
|
+
for state, transitions in self._transitions.items():
|
|
379
|
+
for atom_index, remainder in transitions:
|
|
380
|
+
graph.add_edge(
|
|
381
|
+
sequences[state],
|
|
382
|
+
sequences[remainder],
|
|
383
|
+
atom=self._atoms[atom_index].sequence,
|
|
384
|
+
)
|
|
385
|
+
return graph
|