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,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