sievemin 0.1.1__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.
sievemin/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """sievemin: exact minimisation of Xenakis sieves."""
2
+ from .formula import Domain, parse, to_str, to_music21, evaluate, size, leaves, operators
3
+ from .solve import (Result, min_period, minimize, minimize_union, minimize_dnf,
4
+ minimize_general, factor_groups)
5
+
6
+ __version__ = '0.1.1'
7
+ from .tolerant import TolerantResult, minimize_union_tolerant
sievemin/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Command line: ``sievemin 0 2 4 5 7 9 11 --period 12``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from .formula import Domain, parse, evaluate, to_music21
7
+ from .solve import minimize
8
+
9
+
10
+ def main(argv=None):
11
+ ap = argparse.ArgumentParser(description='Exact minimum Xenakis sieve for a set of integers.')
12
+ ap.add_argument('points', nargs='*', type=int, help='the integers in the set')
13
+ ap.add_argument('--sieve', help='take the set from this sieve formula instead')
14
+ ap.add_argument('--period', type=int, help='match on all integers with this period')
15
+ ap.add_argument('--z', nargs=2, type=int, metavar=('LO', 'HI'),
16
+ help='match on the window LO..HI (default: min..max of the points)')
17
+ ap.add_argument('--language', choices=['union', 'dnf', 'general'], default='dnf')
18
+ ap.add_argument('--max-modulus', type=int, help='cap every leaf modulus')
19
+ ap.add_argument('--max-neg', type=int, default=2,
20
+ help='dnf: most complemented classes per term (default 2)')
21
+ ap.add_argument('--max-edits', type=int, default=0,
22
+ help='allow up to this many point exceptions (union language)')
23
+ ap.add_argument('--edit-cost', type=float, default=1,
24
+ help='cost of one exception in formula-size units (default 1)')
25
+ a = ap.parse_args(argv)
26
+
27
+ if a.period:
28
+ dom = Domain.periodic(a.period)
29
+ elif a.z:
30
+ dom = Domain.window(range(a.z[0], a.z[1] + 1))
31
+ elif a.points:
32
+ dom = Domain.window(range(min(a.points), max(a.points) + 1))
33
+ else:
34
+ ap.error('give --z or --period with --sieve')
35
+ target = evaluate(parse(a.sieve), dom) if a.sieve else dom.mask(a.points)
36
+
37
+ if a.max_edits:
38
+ from .tolerant import minimize_union_tolerant
39
+ r = minimize_union_tolerant(target, dom, max_edits=a.max_edits,
40
+ edit_cost=a.edit_cost, max_modulus=a.max_modulus)
41
+ print(r)
42
+ print(f'cost {r.cost:g} = size {r.size} + {a.edit_cost:g} x {r.edits} exceptions; '
43
+ f'optimal: {r.optimal}; {r.runtime * 1e3:.0f} ms; domain {dom!r}')
44
+ return
45
+ kw = {'max_modulus': a.max_modulus}
46
+ if a.language == 'dnf':
47
+ kw['max_neg'] = a.max_neg
48
+ r = minimize(target, dom, a.language, **kw)
49
+ print(to_music21(r.formula))
50
+ print(f'size {r.size} (leaves {r.leaves}, operators {r.operators}); '
51
+ f'optimal: {r.optimal}; {r.runtime * 1e3:.0f} ms; domain {dom!r}')
52
+
53
+
54
+ if __name__ == '__main__':
55
+ main()
sievemin/formula.py ADDED
@@ -0,0 +1,254 @@
1
+ """Sieve formulas: parse, print, evaluate and cost.
2
+
3
+ A formula is a small tree of tuples:
4
+
5
+ ('r', m, r) residue class m@r (all n with n % m == r)
6
+ ('-', child) complement
7
+ ('&', (c1, c2, ...)) intersection
8
+ ('|', (c1, c2, ...)) union
9
+
10
+ The string syntax is music21's: ``-`` complement, ``&`` intersection,
11
+ ``|`` union, with brackets ``() [] {}``. Precedence is ``-`` then ``&``
12
+ then ``|``. A bare modulus ``m`` means ``m@0``.
13
+
14
+ Cost (the number we minimise) is the formula *size*:
15
+ leaves + binary operators + complements. An n-ary ``&``/``|`` with k
16
+ children counts as k - 1 binary operators. ``leaves`` is also reported.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ __all__ = ['parse', 'to_str', 'to_music21', 'evaluate', 'size', 'leaves', 'operators',
23
+ 'moduli', 'Domain']
24
+
25
+
26
+ class Domain:
27
+ """The integer points a formula must reproduce exactly.
28
+
29
+ ``Domain.window(z)`` is music21's semantics: the formula must match the
30
+ target on the points of ``z`` and nothing is said about points outside.
31
+ ``Domain.periodic(P)`` means the formula must match on all integers,
32
+ which for sieves built from moduli dividing ``P`` is the same as
33
+ matching on ``0..P-1``.
34
+ """
35
+
36
+ def __init__(self, points, period=None):
37
+ self.points = sorted(set(int(p) for p in points))
38
+ if not self.points:
39
+ raise ValueError('empty domain')
40
+ self.period = period
41
+ self.index = {p: i for i, p in enumerate(self.points)}
42
+ self.n = len(self.points)
43
+ self.full = (1 << self.n) - 1
44
+
45
+ @classmethod
46
+ def window(cls, z):
47
+ return cls(z)
48
+
49
+ @classmethod
50
+ def periodic(cls, period):
51
+ return cls(range(period), period=period)
52
+
53
+ @property
54
+ def span(self):
55
+ return self.points[-1] - self.points[0]
56
+
57
+ def mask(self, values):
58
+ """Bitmask of the given integers (they must lie in the domain)."""
59
+ out = 0
60
+ for v in values:
61
+ if self.period is not None:
62
+ v %= self.period
63
+ if v not in self.index:
64
+ raise ValueError(f'{v} is outside the domain')
65
+ out |= 1 << self.index[v]
66
+ return out
67
+
68
+ def values(self, mask):
69
+ return [p for i, p in enumerate(self.points) if mask >> i & 1]
70
+
71
+ @property
72
+ def contiguous(self):
73
+ return self.span + 1 == self.n
74
+
75
+ def class_mask(self, m, r):
76
+ r %= m
77
+ if self.contiguous:
78
+ # bit i is point lo + i; shift a stride-m comb to the first hit
79
+ stride = self._comb(m)
80
+ return (stride << ((r - self.points[0]) % m)) & self.full
81
+ out = 0
82
+ for i, p in enumerate(self.points):
83
+ if p % m == r:
84
+ out |= 1 << i
85
+ return out
86
+
87
+ def _comb(self, m):
88
+ cache = self.__dict__.setdefault('_combs', {})
89
+ if m not in cache:
90
+ c = 0
91
+ for i in range(0, self.n, m):
92
+ c |= 1 << i
93
+ cache[m] = c
94
+ return cache[m]
95
+
96
+ def __repr__(self):
97
+ if self.period is not None:
98
+ return f'Domain.periodic({self.period})'
99
+ return f'Domain.window({self.points[0]}..{self.points[-1]}, n={self.n})'
100
+
101
+
102
+ # ---------------------------------------------------------------- parsing
103
+
104
+ _TOKEN = re.compile(r'\s*(?:(\d+)\s*@\s*(-?\d+)|(\d+)|([-&|()\[\]{}]))')
105
+ _OPEN = '([{'
106
+ _CLOSE = ')]}'
107
+
108
+
109
+ def _tokens(text):
110
+ pos, out = 0, []
111
+ text = text.strip()
112
+ while pos < len(text):
113
+ m = _TOKEN.match(text, pos)
114
+ if not m:
115
+ raise ValueError(f'cannot parse sieve at: {text[pos:]!r}')
116
+ if m.group(1):
117
+ out.append(('r', int(m.group(1)), int(m.group(2))))
118
+ elif m.group(3):
119
+ out.append(('r', int(m.group(3)), 0))
120
+ else:
121
+ out.append(m.group(4))
122
+ pos = m.end()
123
+ return out
124
+
125
+
126
+ def parse(text: str):
127
+ """Parse a music21-style sieve string into a formula tree."""
128
+ toks = _tokens(text)
129
+ pos = 0
130
+
131
+ def peek():
132
+ return toks[pos] if pos < len(toks) else None
133
+
134
+ def take():
135
+ nonlocal pos
136
+ pos += 1
137
+ return toks[pos - 1]
138
+
139
+ def p_or():
140
+ kids = [p_and()]
141
+ while peek() == '|':
142
+ take()
143
+ kids.append(p_and())
144
+ return kids[0] if len(kids) == 1 else ('|', tuple(kids))
145
+
146
+ def p_and():
147
+ kids = [p_not()]
148
+ while peek() == '&':
149
+ take()
150
+ kids.append(p_not())
151
+ return kids[0] if len(kids) == 1 else ('&', tuple(kids))
152
+
153
+ def p_not():
154
+ if peek() == '-':
155
+ take()
156
+ return ('-', p_not())
157
+ return p_atom()
158
+
159
+ def p_atom():
160
+ t = take() if peek() is not None else None
161
+ if isinstance(t, tuple):
162
+ m, r = t[1], t[2]
163
+ if m < 1:
164
+ raise ValueError('modulus must be >= 1')
165
+ return ('r', m, r % m)
166
+ if t in _OPEN:
167
+ inner = p_or()
168
+ if take() != _CLOSE[_OPEN.index(t)]:
169
+ raise ValueError('unbalanced brackets')
170
+ return inner
171
+ raise ValueError(f'unexpected token {t!r}')
172
+
173
+ tree = p_or()
174
+ if pos != len(toks):
175
+ raise ValueError(f'trailing tokens: {toks[pos:]}')
176
+ return tree
177
+
178
+
179
+ # --------------------------------------------------------------- printing
180
+
181
+ def to_str(f) -> str:
182
+ """music21-compatible string for a formula tree."""
183
+ kind = f[0]
184
+ if kind == 'r':
185
+ return f'{f[1]}@{f[2]}'
186
+ if kind == '-':
187
+ inner = to_str(f[1])
188
+ return '-' + (inner if f[1][0] == 'r' else f'({inner})')
189
+ parts = []
190
+ for c in f[1]:
191
+ s = to_str(c)
192
+ if kind == '&' and c[0] == '|':
193
+ s = f'({s})'
194
+ parts.append(s)
195
+ return kind.join(parts)
196
+
197
+
198
+ def to_music21(f) -> str:
199
+ """String that music21.sieve.Sieve accepts.
200
+
201
+ music21 10.5.2 crashes (ResidualException) on an unbracketed formula
202
+ that intersects a complemented class, e.g. ``3@1&-4@2``: its
203
+ ``_initCompression`` catches TypeError but the error raised is
204
+ ResidualException. Wrapping the whole formula in brackets sends it down
205
+ the other code path, which evaluates correctly.
206
+ """
207
+ s = to_str(f)
208
+ return f'({s})' if '-' in s else s
209
+
210
+
211
+ # ------------------------------------------------------------- evaluation
212
+
213
+ def evaluate(f, domain: Domain) -> int:
214
+ """Bitmask of the domain points the formula contains."""
215
+ kind = f[0]
216
+ if kind == 'r':
217
+ return domain.class_mask(f[1], f[2])
218
+ if kind == '-':
219
+ return domain.full & ~evaluate(f[1], domain)
220
+ masks = [evaluate(c, domain) for c in f[1]]
221
+ out = masks[0]
222
+ for m in masks[1:]:
223
+ out = out & m if kind == '&' else out | m
224
+ return out
225
+
226
+
227
+ # ------------------------------------------------------------------ cost
228
+
229
+ def leaves(f) -> int:
230
+ if f[0] == 'r':
231
+ return 1
232
+ if f[0] == '-':
233
+ return leaves(f[1])
234
+ return sum(leaves(c) for c in f[1])
235
+
236
+
237
+ def operators(f) -> int:
238
+ if f[0] == 'r':
239
+ return 0
240
+ if f[0] == '-':
241
+ return 1 + operators(f[1])
242
+ return len(f[1]) - 1 + sum(operators(c) for c in f[1])
243
+
244
+
245
+ def size(f) -> int:
246
+ return leaves(f) + operators(f)
247
+
248
+
249
+ def moduli(f) -> list[int]:
250
+ if f[0] == 'r':
251
+ return [f[1]]
252
+ if f[0] == '-':
253
+ return moduli(f[1])
254
+ return [m for c in f[1] for m in moduli(c)]
sievemin/solve.py ADDED
@@ -0,0 +1,587 @@
1
+ """Exact sieve minimisers.
2
+
3
+ Three formula languages, each solved to proven optimality:
4
+
5
+ ``minimize_union`` union of residue classes only (music21's output
6
+ language). Exact minimum number of classes, found as
7
+ an exact set cover (ILP, HiGHS via scipy). No bounds:
8
+ every distinct class on the domain is a candidate.
9
+
10
+ ``minimize_dnf`` union of terms ``P & -(N1|...|Nk)`` where P is a
11
+ residue class (written as an intersection of leaves if
12
+ leaf moduli are capped) or absent, and k <= ``max_neg``.
13
+ This is the shape of Xenakis's published sieves
14
+ (e.g. Nomos Alpha). Optional top-level complement.
15
+ Exact minimum size within the bounds (leaf modulus
16
+ cap, k), again by weighted set cover over *all* terms
17
+ of that shape.
18
+
19
+ ``minimize_general`` any formula of residue classes, ``-``, ``&``, ``|``.
20
+ Exact minimum size by exhaustive dynamic programming
21
+ over all 2^n subsets, so only for small domains
22
+ (n <= ~14 points, e.g. one octave of a 12-tone scale).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from itertools import product
29
+ from math import gcd
30
+
31
+ import numpy as np
32
+ from scipy.optimize import milp, LinearConstraint, Bounds
33
+ from scipy.sparse import csc_matrix
34
+
35
+ from .formula import Domain, evaluate, size, leaves, operators, to_str, moduli
36
+
37
+ __all__ = ['Result', 'min_period', 'minimize_union', 'minimize_dnf', 'minimize_general',
38
+ 'minimize', 'factor_groups']
39
+
40
+
41
+ @dataclass
42
+ class Result:
43
+ formula: tuple
44
+ language: str
45
+ optimal: bool
46
+ runtime: float
47
+ candidates: int = 0
48
+ bounds: dict = field(default_factory=dict)
49
+ lower_bound: float | None = None
50
+
51
+ @property
52
+ def text(self):
53
+ return to_str(self.formula)
54
+
55
+ @property
56
+ def size(self):
57
+ return size(self.formula)
58
+
59
+ @property
60
+ def leaves(self):
61
+ return leaves(self.formula)
62
+
63
+ @property
64
+ def operators(self):
65
+ return operators(self.formula)
66
+
67
+ @property
68
+ def chars(self):
69
+ return len(self.text)
70
+
71
+ @property
72
+ def period(self):
73
+ from math import lcm
74
+ return lcm(*moduli(self.formula))
75
+
76
+ def __str__(self):
77
+ return self.text
78
+
79
+
80
+ # ------------------------------------------------------------- helpers
81
+
82
+ def _divisors(n):
83
+ return [d for d in range(1, n + 1) if n % d == 0]
84
+
85
+
86
+ def _prime_powers(n):
87
+ out, p = [], 2
88
+ while p * p <= n:
89
+ if n % p == 0:
90
+ q = 1
91
+ while n % p == 0:
92
+ n //= p
93
+ q *= p
94
+ out.append(q)
95
+ p += 1
96
+ if n > 1:
97
+ out.append(n)
98
+ return out
99
+
100
+
101
+ def factor_groups(d, max_modulus=None):
102
+ """Fewest moduli <= max_modulus whose lcm is d (None if impossible).
103
+
104
+ An intersection of classes m1@r1 & m2@r2 ... is one class with modulus
105
+ lcm(m1, m2, ...), so this is the cheapest way to write a class of
106
+ modulus d with capped leaf moduli.
107
+ """
108
+ if max_modulus is None or d <= max_modulus:
109
+ return [d]
110
+ pp = _prime_powers(d)
111
+ if any(q > max_modulus for q in pp):
112
+ return None
113
+ for k in range(2, len(pp) + 1):
114
+ for assign in product(range(k), repeat=len(pp)):
115
+ groups = [1] * k
116
+ for q, g in zip(pp, assign):
117
+ groups[g] *= q
118
+ if all(1 < g <= max_modulus for g in groups):
119
+ return sorted(groups)
120
+ return None
121
+
122
+
123
+ def _class_formula(d, r, max_modulus=None):
124
+ groups = factor_groups(d, max_modulus)
125
+ lits = tuple(('r', g, r % g) for g in groups)
126
+ return lits[0] if len(lits) == 1 else ('&', lits)
127
+
128
+
129
+ def _leaf_moduli(domain: Domain, max_modulus=None):
130
+ if domain.period is not None:
131
+ ms = _divisors(domain.period)
132
+ else:
133
+ ms = list(range(1, domain.span + 2))
134
+ if max_modulus is not None:
135
+ ms = [m for m in ms if m <= max_modulus]
136
+ return ms
137
+
138
+
139
+ def _leaf_classes(domain, max_modulus=None, divides=None, shortest=False):
140
+ """Distinct non-empty class masks -> (m, r).
141
+
142
+ Keeps the smallest modulus for each mask, or with ``shortest`` the
143
+ shortest ``m@r`` string. With ``divides`` only moduli dividing that
144
+ number are used. Cached on the domain."""
145
+ cache = domain.__dict__.setdefault('_leafcache', {})
146
+ key = (max_modulus, divides, shortest)
147
+ if key in cache:
148
+ return cache[key]
149
+ out = {}
150
+ for m in _leaf_moduli(domain, max_modulus):
151
+ if divides is not None and divides % m:
152
+ continue
153
+ for r in range(m):
154
+ mk = domain.class_mask(m, r)
155
+ if not mk:
156
+ continue
157
+ old = out.get(mk)
158
+ if old is None or (shortest and len(f'{m}@{r}') < len(f'{old[0]}@{old[1]}')):
159
+ out[mk] = (m, r)
160
+ cache[key] = out
161
+ return out
162
+
163
+
164
+ def _positive_classes(domain, max_modulus=None):
165
+ """Distinct class masks -> (cost_in_leaves, d, r) for the positive part."""
166
+ if domain.period is not None:
167
+ ds = _divisors(domain.period)
168
+ else:
169
+ ds = list(range(1, domain.span + 2))
170
+ if max_modulus is not None:
171
+ # classes with d > span are singletons; find the cheapest such d
172
+ best = None
173
+ for d in range(domain.span + 2, 100 * (domain.span + 2)):
174
+ g = factor_groups(d, max_modulus)
175
+ if g is not None and (best is None or len(g) < best[0]):
176
+ best = (len(g), d)
177
+ if len(g) <= 2:
178
+ break
179
+ if best:
180
+ ds.append(best[1])
181
+ out = {}
182
+ for d in ds:
183
+ g = factor_groups(d, max_modulus)
184
+ if g is None:
185
+ continue
186
+ for r in range(d):
187
+ mk = domain.class_mask(d, r)
188
+ if not mk:
189
+ continue
190
+ key = (len(g), d, r)
191
+ if mk not in out or key < out[mk]:
192
+ out[mk] = key
193
+ return out
194
+
195
+
196
+ def _bits(mask):
197
+ i = 0
198
+ while mask:
199
+ if mask & 1:
200
+ yield i
201
+ mask >>= 1
202
+ i += 1
203
+
204
+
205
+ def _set_cover(target, columns, weights, secondary=None, tie_seconds=2.0):
206
+ """Exact set cover of the bits of ``target``, lexicographic objective.
207
+
208
+ Stage 1 minimises ``weights`` and proves optimality. If ``secondary`` is
209
+ given, stage 2 minimises it among covers with the same stage-1 cost,
210
+ under a time limit: the primary optimum is always proven, the tie-break
211
+ is best effort (``tie_proven`` says whether it was proven too).
212
+ Returns (chosen column indices, primary objective, proven, dual bound,
213
+ tie_proven).
214
+ """
215
+ sec = secondary or [0] * len(columns)
216
+ keep = _undominated(target, columns, weights, sec)
217
+ cols = [columns[j] for j in keep]
218
+ w = [weights[j] for j in keep]
219
+ chosen, obj, proven, bound = _ilp_cover(target, cols, w)
220
+ tie_proven = True
221
+ if secondary is not None:
222
+ s2 = [sec[j] for j in keep]
223
+ res = _ilp_cover(target, cols, s2, budget=(w, obj + 0.5),
224
+ time_limit=tie_seconds)
225
+ if res is not None:
226
+ ch2, _, p2, _ = res
227
+ if sum(w[j] for j in ch2) <= obj + 0.5:
228
+ chosen, tie_proven = ch2, p2
229
+ else:
230
+ tie_proven = False
231
+ else:
232
+ tie_proven = False
233
+ return [keep[j] for j in chosen], obj, proven, bound, tie_proven
234
+
235
+
236
+ def _undominated(target, columns, weights, secondary):
237
+ """Drop columns covered by another column no worse in both weights.
238
+
239
+ Exact: in any cover, such a column can be swapped for its dominator
240
+ without raising either cost. Ties are broken by index so exactly one of
241
+ two identical columns survives.
242
+ """
243
+ cols = [c & target for c in columns]
244
+ order = sorted(range(len(cols)),
245
+ key=lambda j: (-bin(cols[j]).count('1'), weights[j], secondary[j], j))
246
+ kept = []
247
+ for j in order:
248
+ c, w, s = cols[j], weights[j], secondary[j]
249
+ if not any(c & ~cols[i] == 0 and weights[i] <= w and secondary[i] <= s
250
+ for i in kept):
251
+ kept.append(j)
252
+ return sorted(kept)
253
+
254
+
255
+ def _ilp_cover(target, columns, weights, budget=None, time_limit=None):
256
+ rows = {b: i for i, b in enumerate(_bits(target))}
257
+ ri, ci = [], []
258
+ for j, col in enumerate(columns):
259
+ for b in _bits(col & target):
260
+ ri.append(rows[b])
261
+ ci.append(j)
262
+ A = csc_matrix((np.ones(len(ri)), (ri, ci)), shape=(len(rows), len(columns)))
263
+ cons = [LinearConstraint(A, lb=1, ub=np.inf)]
264
+ if budget is not None:
265
+ bw, cap = budget
266
+ cons.append(LinearConstraint(np.asarray([bw], dtype=float), lb=-np.inf, ub=cap))
267
+ opts = {'mip_rel_gap': 0, 'presolve': True}
268
+ if time_limit is not None:
269
+ opts['time_limit'] = time_limit
270
+ res = milp(c=np.asarray(weights, dtype=float), constraints=cons,
271
+ integrality=np.ones(len(columns)), bounds=Bounds(0, 1), options=opts)
272
+ if res.x is None:
273
+ if budget is not None:
274
+ return None
275
+ raise RuntimeError(f'ILP failed: {res.message}')
276
+ chosen = [j for j, v in enumerate(res.x) if v > 0.5]
277
+ bound = getattr(res, 'mip_dual_bound', None)
278
+ proven = res.status == 0 and (bound is None or res.fun - bound < 1e-6)
279
+ return chosen, res.fun, proven, bound
280
+
281
+
282
+ def _union(kids):
283
+ kids = tuple(kids)
284
+ return kids[0] if len(kids) == 1 else ('|', kids)
285
+
286
+
287
+ def _check(formula, target, domain):
288
+ got = evaluate(formula, domain)
289
+ if got != target:
290
+ raise AssertionError(f'internal error: {to_str(formula)} does not match target')
291
+
292
+
293
+ def _target(points, domain):
294
+ return domain.mask(points) if not isinstance(points, int) else points
295
+
296
+
297
+ # ----------------------------------------------------- union of classes
298
+
299
+ def _class_chars(m, r):
300
+ return len(f'{m}@{r}')
301
+
302
+
303
+ PERIOD_LIMIT = 200_000
304
+
305
+
306
+ def min_period(points, domain: Domain, max_modulus=None, limit=PERIOD_LIMIT):
307
+ """Smallest period (lcm of the moduli) of any union formula for the target.
308
+
309
+ L is achievable iff every target point lies in some class inside the
310
+ target whose modulus divides L (and is <= max_modulus). Searched for
311
+ L = 1..limit (periodic domains: divisors of the period, always found).
312
+ Returns None if no L <= limit works.
313
+ """
314
+ target = _target(points, domain)
315
+ ms = _leaf_moduli(domain, max_modulus)
316
+ cover = {}
317
+ for m in ms: # points coverable by an in-target class of modulus m
318
+ c = 0
319
+ for r in range(m):
320
+ mk = domain.class_mask(m, r)
321
+ if mk and mk & ~target == 0:
322
+ c |= mk
323
+ if c:
324
+ cover[m] = c
325
+ if domain.period is not None:
326
+ Ls = _divisors(domain.period)
327
+ else:
328
+ Ls = range(1, limit + 1)
329
+ good = sorted(cover)
330
+ for L in Ls:
331
+ got = 0
332
+ for m in good:
333
+ if m > L:
334
+ break
335
+ if L % m == 0:
336
+ got |= cover[m]
337
+ if got == target:
338
+ return L
339
+ return None
340
+
341
+
342
+ def minimize_union(points, domain: Domain, max_modulus=None,
343
+ objective='leaves') -> Result:
344
+ """Exact minimum union of residue classes equal to the target.
345
+
346
+ objective:
347
+ 'leaves' fewest classes (ties: fewest characters, then small moduli)
348
+ 'chars' shortest music21 string (ties: fewest classes)
349
+ 'period' smallest period, i.e. lcm of the moduli (then fewest classes)
350
+ """
351
+ t0 = time.perf_counter()
352
+ target = _target(points, domain)
353
+ if not target:
354
+ raise ValueError('empty target')
355
+ period = None
356
+ if objective == 'period':
357
+ period = min_period(target, domain, max_modulus)
358
+ if period is None:
359
+ raise ValueError(f'no union formula with period <= {PERIOD_LIMIT} '
360
+ 'within these bounds')
361
+ # dedupe by mask *after* restricting moduli to divisors of the period,
362
+ # or a class could be kept under a modulus that does not divide it
363
+ cls = {mk: mr for mk, mr in _leaf_classes(domain, max_modulus, divides=period,
364
+ shortest=objective == 'chars').items()
365
+ if mk & ~target == 0}
366
+ cols = list(cls)
367
+ if not cols or (target & ~_or(cols)):
368
+ raise ValueError('target cannot be written as a union of classes '
369
+ 'with these bounds')
370
+ chars = {c: _class_chars(*cls[c]) + 1 for c in cols} # +1 for the '|'
371
+ m1 = max(mr[0] for mr in cls.values()) + 1
372
+ if objective == 'chars':
373
+ prim = [chars[c] for c in cols]
374
+ sec = [1 for c in cols]
375
+ elif objective in ('leaves', 'period'):
376
+ prim = [1 for c in cols]
377
+ sec = [chars[c] * m1 + cls[c][0] for c in cols] # then fewest chars, small moduli
378
+ else:
379
+ raise ValueError(f'unknown objective {objective!r}')
380
+ chosen, obj, proven, bound, tie_ok = _set_cover(target, cols, prim, sec)
381
+ terms = sorted((cls[cols[j]] for j in chosen))
382
+ f = _union(('r', m, r) for m, r in terms)
383
+ _check(f, target, domain)
384
+ return Result(f, 'union', proven, time.perf_counter() - t0, len(cols),
385
+ {'max_modulus': max_modulus, 'objective': objective,
386
+ 'tie_break_proven': tie_ok}, bound)
387
+
388
+
389
+ def _or(masks):
390
+ out = 0
391
+ for m in masks:
392
+ out |= m
393
+ return out
394
+
395
+
396
+ # ------------------------------------------------------------- DNF terms
397
+
398
+ def _term_formula(pos, negs, max_modulus):
399
+ parts = []
400
+ if pos is not None:
401
+ _, d, r = pos
402
+ pf = _class_formula(d, r, max_modulus)
403
+ parts.extend(pf[1] if pf[0] == '&' else (pf,))
404
+ if negs:
405
+ parts.append(('-', _union(('r', m, r) for m, r in sorted(negs))))
406
+ return parts[0] if len(parts) == 1 else ('&', tuple(parts))
407
+
408
+
409
+ def _dnf_terms(target, domain, max_modulus, max_neg, limit):
410
+ leafs = _leaf_classes(domain, max_modulus)
411
+ pos = _positive_classes(domain, max_modulus)
412
+ by_point = [[] for _ in range(domain.n)]
413
+ for mk, (m, r) in sorted(leafs.items(), key=lambda kv: kv[1]):
414
+ for b in _bits(mk):
415
+ by_point[b].append((mk, (m, r)))
416
+ full = domain.full
417
+ terms = {} # mask -> (size, pos, negs)
418
+
419
+ def add(mask, p, negs):
420
+ lp = 0 if p is None else p[0]
421
+ k = len(negs)
422
+ sz = (2 * lp - 1 if lp else 0) + (2 * k if k else 0) + (1 if lp and k else 0)
423
+ old = terms.get(mask)
424
+ if old is None or sz < old[0]:
425
+ terms[mask] = (sz, p, tuple(negs))
426
+ if len(terms) > limit:
427
+ raise OverflowError
428
+
429
+ def grow(P, cur, bad, negs, p):
430
+ if not bad:
431
+ add(cur, p, negs)
432
+ return
433
+ if len(negs) == max_neg:
434
+ return
435
+ b = (bad & -bad).bit_length() - 1
436
+ seen = set()
437
+ for nm, mr in by_point[b]:
438
+ rel = nm & P
439
+ if rel in seen:
440
+ continue
441
+ seen.add(rel)
442
+ new = cur & ~nm
443
+ if not new & target:
444
+ continue
445
+ grow(P, new, bad & ~nm, negs + [mr], p)
446
+
447
+ starts = [(full, None)] + [(mk, key) for mk, key in pos.items() if mk & target]
448
+ for P, p in starts:
449
+ bad = P & ~target
450
+ if p is None and not bad:
451
+ continue # the target is everything; handled by caller
452
+ grow(P, P, bad, [], p)
453
+ return terms
454
+
455
+
456
+ def minimize_dnf(points, domain: Domain, max_modulus=None, max_neg=2,
457
+ complement=True, limit=2_000_000) -> Result:
458
+ """Smallest union of ``P & -(N1|..|Nk)`` terms (k <= max_neg).
459
+
460
+ Optimal over all such formulas whose leaf moduli are <= ``max_modulus``
461
+ (None = no cap). With ``complement`` the whole formula may also be
462
+ ``-(...)`` of such a union for the complement set; the cheaper wins.
463
+ """
464
+ t0 = time.perf_counter()
465
+ target = _target(points, domain)
466
+ if not target:
467
+ raise ValueError('empty target')
468
+ if target == domain.full:
469
+ return Result(('r', 1, 0), 'dnf', True, time.perf_counter() - t0, 1,
470
+ {'max_modulus': max_modulus, 'max_neg': max_neg,
471
+ 'complement': complement})
472
+ best, ncand, proven_all = None, 0, True
473
+ jobs = [(target, False)]
474
+ if complement:
475
+ jobs.append((domain.full & ~target, True))
476
+ for tg, neg in jobs:
477
+ terms = _dnf_terms(tg, domain, max_modulus, max_neg, limit)
478
+ ncand += len(terms)
479
+ cols = list(terms)
480
+ if tg & ~_or(cols):
481
+ continue
482
+ # size first; ties: fewer complemented classes
483
+ w = [terms[c][0] + 1 for c in cols]
484
+ chosen, obj, proven, _, _ = _set_cover(tg, cols, w)
485
+ proven_all &= proven
486
+ f = _union(_term_formula(terms[cols[j]][1], terms[cols[j]][2], max_modulus)
487
+ for j in sorted(chosen, key=lambda j: (terms[cols[j]][1] or (0, 0, 0),
488
+ terms[cols[j]][2])))
489
+ if neg:
490
+ f = ('-', f)
491
+ if best is None or (size(f), leaves(f)) < (size(best), leaves(best)):
492
+ best = f
493
+ if best is None:
494
+ raise ValueError('no formula within these bounds')
495
+ _check(best, target, domain)
496
+ return Result(best, 'dnf', proven_all, time.perf_counter() - t0, ncand,
497
+ {'max_modulus': max_modulus, 'max_neg': max_neg,
498
+ 'complement': complement})
499
+
500
+
501
+ # ---------------------------------------------------- general formulas
502
+
503
+ def minimize_general(points, domain: Domain, max_modulus=None, max_points=14,
504
+ max_size=40) -> Result:
505
+ """Smallest formula of any shape (``-``, ``&``, ``|`` over classes).
506
+
507
+ Dynamic programming over every subset of the domain by increasing size,
508
+ the classic way to find minimum Boolean formulas. Exact, but needs
509
+ 2^n memory and time, so the domain is capped at ``max_points``.
510
+ """
511
+ t0 = time.perf_counter()
512
+ target = _target(points, domain)
513
+ n = domain.n
514
+ if n > max_points:
515
+ raise ValueError(f'domain has {n} points; general search is capped at {max_points}')
516
+ N = 1 << n
517
+ full = N - 1
518
+ INF = 255
519
+ cost = np.full(N, INF, dtype=np.uint8)
520
+ op = np.zeros(N, dtype=np.uint8) # 0 leaf, 1 not, 2 and, 3 or
521
+ left = np.zeros(N, dtype=np.int64)
522
+ right = np.zeros(N, dtype=np.int64)
523
+ leaf_of = {}
524
+ for mk, (m, r) in _leaf_classes(domain, max_modulus).items():
525
+ cost[mk] = 1
526
+ leaf_of[mk] = (m, r)
527
+ # the empty set and the full set are expressible (m@r & -m@r) but are
528
+ # never useful as sub-formulas of a minimum formula for a proper target
529
+ levels = {1: np.array(sorted(leaf_of), dtype=np.int64)}
530
+ c = 1
531
+ while cost[target] == INF:
532
+ c += 1
533
+ if c > max_size:
534
+ raise ValueError('no formula found within max_size')
535
+ new = []
536
+ prev = levels.get(c - 1)
537
+ if prev is not None and len(prev):
538
+ comp = full ^ prev
539
+ sel = cost[comp] > c
540
+ idx = comp[sel]
541
+ cost[idx] = c
542
+ op[idx] = 1
543
+ left[idx] = prev[sel]
544
+ new.append(idx)
545
+ for i in range(1, (c - 1) // 2 + 1):
546
+ j = c - 1 - i
547
+ A, B = levels.get(i), levels.get(j)
548
+ if A is None or B is None or not len(A) or not len(B):
549
+ continue
550
+ for a in A:
551
+ for code, vals in ((2, a & B), (3, a | B)):
552
+ sel = cost[vals] > c
553
+ if not sel.any():
554
+ continue
555
+ idx = vals[sel]
556
+ cost[idx] = c
557
+ op[idx] = code
558
+ left[idx] = a
559
+ right[idx] = B[sel]
560
+ new.append(idx)
561
+ levels[c] = np.unique(np.concatenate(new)) if new else np.array([], dtype=np.int64)
562
+
563
+ def build(mk):
564
+ o = op[mk]
565
+ if o == 0:
566
+ m, r = leaf_of[int(mk)]
567
+ return ('r', m, r)
568
+ if o == 1:
569
+ return ('-', build(left[mk]))
570
+ a, b = build(left[mk]), build(right[mk])
571
+ kind = '&' if o == 2 else '|'
572
+ kids = []
573
+ for x in (a, b):
574
+ kids.extend(x[1] if x[0] == kind else (x,))
575
+ return (kind, tuple(kids))
576
+
577
+ f = build(target)
578
+ _check(f, target, domain)
579
+ assert size(f) == c
580
+ return Result(f, 'general', True, time.perf_counter() - t0, int((cost < INF).sum()),
581
+ {'max_modulus': max_modulus})
582
+
583
+
584
+ def minimize(points, domain: Domain, language='dnf', **kw) -> Result:
585
+ fn = {'union': minimize_union, 'dnf': minimize_dnf,
586
+ 'general': minimize_general}[language]
587
+ return fn(points, domain, **kw)
sievemin/tolerant.py ADDED
@@ -0,0 +1,152 @@
1
+ """Error-tolerant sieves: a formula plus a few point exceptions.
2
+
3
+ Given a target set T, find a union of residue classes F and at most
4
+ ``max_edits`` exceptions such that
5
+
6
+ (F's points + added) - removed == T
7
+
8
+ exactly, minimising the description length
9
+
10
+ cost = size(F) + edit_cost * (number of exceptions)
11
+
12
+ where size(F) is the usual leaves + operators (2n - 1 for n classes).
13
+
14
+ ``edit_cost`` (lambda) is the price of one exception, in the same units as
15
+ formula size. The default 1 makes an exception slightly cheaper than an
16
+ extra singleton class in a union (a leaf plus a ``|`` = 2). In the synthetic
17
+ study (bench/tolerant_study.md) lambda = 1 recovered a planted 2-3 class
18
+ sieve from 1/2/3 flipped notes in 100/96/95% of trials; lambda = 2 in
19
+ 100/77/49% and lambda = 3 in 28/13/3%. With ``edit_cost = 2`` an exception
20
+ is never cheaper than writing the point as its own class, so the cost can
21
+ never be worse than the exact formula's size.
22
+
23
+ Solved exactly as one ILP (HiGHS): binary x_c per candidate class, binary
24
+ a_p per target point (exception: add p), binary d_q per non-target point
25
+ (exception: remove q).
26
+
27
+ for p in T: sum(x_c : p in c) + a_p >= 1
28
+ for q not in T: d_q >= x_c for every class c containing q
29
+ sum(a) + sum(d) <= max_edits
30
+
31
+ Candidates are the classes (music21-style moduli, optionally capped) with at
32
+ most ``max_edits`` points outside T: a class with more could never be used.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import time
37
+ from dataclasses import dataclass
38
+
39
+ import numpy as np
40
+ from scipy.optimize import milp, LinearConstraint, Bounds
41
+ from scipy.sparse import coo_matrix
42
+
43
+ from .formula import Domain, to_str, size
44
+ from .solve import _leaf_classes, _bits, _target, _union, _divisors
45
+
46
+ __all__ = ['TolerantResult', 'minimize_union_tolerant']
47
+
48
+
49
+ @dataclass
50
+ class TolerantResult:
51
+ formula: tuple
52
+ added: list # points the formula misses (add them)
53
+ removed: list # points the formula has but the target lacks
54
+ edit_cost: float
55
+ optimal: bool
56
+ runtime: float
57
+ candidates: int
58
+
59
+ @property
60
+ def text(self):
61
+ return to_str(self.formula)
62
+
63
+ @property
64
+ def size(self):
65
+ return size(self.formula)
66
+
67
+ @property
68
+ def edits(self):
69
+ return len(self.added) + len(self.removed)
70
+
71
+ @property
72
+ def cost(self):
73
+ return self.size + self.edit_cost * self.edits
74
+
75
+ def __str__(self):
76
+ parts = [self.text]
77
+ if self.added:
78
+ parts.append('add ' + ','.join(map(str, self.added)))
79
+ if self.removed:
80
+ parts.append('remove ' + ','.join(map(str, self.removed)))
81
+ return ' '.join(parts)
82
+
83
+
84
+ def minimize_union_tolerant(points, domain: Domain, max_edits=1, edit_cost=1,
85
+ max_modulus=None, time_limit=None) -> TolerantResult:
86
+ t0 = time.perf_counter()
87
+ target = _target(points, domain)
88
+ if not target:
89
+ raise ValueError('empty target')
90
+ outside = domain.full & ~target
91
+ cls = {mk: mr for mk, mr in _leaf_classes(domain, max_modulus).items()
92
+ if bin(mk & outside).count('1') <= max_edits}
93
+ cols = list(cls)
94
+ T = list(_bits(target))
95
+ O = sorted({b for c in cols for b in _bits(c & outside)})
96
+ nx, na, nd = len(cols), len(T), len(O)
97
+ ia = {b: nx + i for i, b in enumerate(T)}
98
+ idd = {b: nx + na + i for i, b in enumerate(O)}
99
+ nvar = nx + na + nd
100
+
101
+ rows, cols_i, vals, lb, ub = [], [], [], [], []
102
+ r = 0
103
+ cover = {b: [] for b in T}
104
+ for j, c in enumerate(cols):
105
+ for b in _bits(c & target):
106
+ cover[b].append(j)
107
+ for b in T: # cover or add
108
+ for j in cover[b]:
109
+ rows.append(r); cols_i.append(j); vals.append(1)
110
+ rows.append(r); cols_i.append(ia[b]); vals.append(1)
111
+ lb.append(1); ub.append(np.inf); r += 1
112
+ for j, c in enumerate(cols): # outside point used => removed
113
+ for b in _bits(c & outside):
114
+ rows += [r, r]; cols_i += [idd[b], j]; vals += [1, -1]
115
+ lb.append(0); ub.append(np.inf); r += 1
116
+ for v in range(nx, nvar): # edit budget
117
+ rows.append(r); cols_i.append(v); vals.append(1)
118
+ lb.append(-np.inf); ub.append(max_edits); r += 1
119
+ for j in range(nx): # a formula has >= 1 class
120
+ rows.append(r); cols_i.append(j); vals.append(1)
121
+ lb.append(1); ub.append(np.inf); r += 1
122
+
123
+ A = coo_matrix((vals, (rows, cols_i)), shape=(r, nvar)).tocsr()
124
+ # objective: 2 per class (leaf + union op; the -1 for the first class is
125
+ # a constant), edit_cost per exception; tiny tie-break to small moduli
126
+ eps = 1e-4 / max(1, max(m for m, _ in cls.values()))
127
+ c = np.array([2 + eps * cls[col][0] for col in cols] + [edit_cost] * (na + nd), dtype=float)
128
+ opts = {'mip_rel_gap': 0, 'presolve': True}
129
+ if time_limit:
130
+ opts['time_limit'] = time_limit
131
+ res = milp(c=c, constraints=LinearConstraint(A, lb, ub),
132
+ integrality=np.ones(nvar), bounds=Bounds(0, 1), options=opts)
133
+ if res.x is None:
134
+ raise RuntimeError(f'ILP failed: {res.message}')
135
+ x = res.x > 0.5
136
+ chosen = sorted(cls[cols[j]] for j in range(nx) if x[j])
137
+ F_mask = 0
138
+ for j in range(nx):
139
+ if x[j]:
140
+ F_mask |= cols[j]
141
+ added = domain.values(target & ~F_mask)
142
+ removed = domain.values(F_mask & ~target)
143
+ if not chosen:
144
+ raise ValueError('every point became an exception; raise edit_cost')
145
+ f = _union(('r', m, rr) for m, rr in chosen)
146
+ # sanity: the edits reproduce the target exactly
147
+ assert (F_mask | domain.mask(added)) & ~domain.mask(removed) == target
148
+ assert len(added) + len(removed) <= max_edits
149
+ bound = getattr(res, 'mip_dual_bound', None)
150
+ proven = res.status == 0 and (bound is None or res.fun - bound < 1e-3)
151
+ return TolerantResult(f, added, removed, edit_cost, proven,
152
+ time.perf_counter() - t0, nx)
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: sievemin
3
+ Version: 0.1.1
4
+ Summary: Exact (provably minimal) Xenakis sieve formulas for integer sets
5
+ Author: Duncan Bentley
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/DuncanBentley/sievemin
8
+ Project-URL: Repository, https://github.com/DuncanBentley/sievemin
9
+ Project-URL: Issues, https://github.com/DuncanBentley/sievemin/issues
10
+ Project-URL: Changelog, https://github.com/DuncanBentley/sievemin/blob/main/CHANGELOG.md
11
+ Project-URL: DOI, https://doi.org/10.5281/zenodo.23816540
12
+ Keywords: Xenakis,sieve theory,music theory,set cover,integer linear programming,music21,computational musicology
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Artistic Software
25
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
26
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
27
+ Requires-Python: >=3.10
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: numpy
31
+ Requires-Dist: scipy>=1.9
32
+ Provides-Extra: music21
33
+ Requires-Dist: music21; extra == "music21"
34
+ Provides-Extra: bench
35
+ Requires-Dist: music21; extra == "bench"
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest; extra == "test"
38
+ Requires-Dist: music21; extra == "test"
39
+ Dynamic: license-file
40
+
41
+ # sievemin
42
+
43
+ `sievemin` finds the shortest Xenakis sieve that produces a given set of integers, and proves nothing shorter exists in the formula shape you asked for. Against music21's greedy compressor it is shorter on 928 of 1,000 random segments, and it shortens several published Xenakis sieves.
44
+
45
+ [![CI](https://github.com/DuncanBentley/sievemin/actions/workflows/ci.yml/badge.svg)](https://github.com/DuncanBentley/sievemin/actions) [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](https://github.com/DuncanBentley/sievemin/blob/main/LICENSE) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://github.com/DuncanBentley/sievemin/blob/main/pyproject.toml) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23816540.svg)](https://doi.org/10.5281/zenodo.23816540)
46
+
47
+ A sieve is a formula built from residue classes `m@r` (every n with n mod m = r), joined with union `|`, intersection `&` and complement `-`. The output uses music21's sieve syntax, so you can paste it into `music21.sieve.Sieve`.
48
+
49
+ I make metal and produce records. Sieves are a tidy way to write down a scale or a rhythm that doesn't repeat every octave or every bar. I wanted to know whether the formulas people publish, and the ones music21 generates, are actually the short ones. Mostly they're close. Sometimes they aren't.
50
+
51
+ ## Key findings
52
+
53
+ | finding | numbers | where |
54
+ |---|---|---|
55
+ | music21's greedy is already optimal on its own small examples | 12 of 12 music21 test segments match the exact class count | [REPORT.md](https://github.com/DuncanBentley/sievemin/blob/main/REPORT.md) section 3 |
56
+ | It loses on bigger sets (z = 0..99, music21's modulus limit) | Psappha 20 classes down to 15, Nomos Alpha 8 to 7, À R. 17 to 14, Nekuïa 11 to 8 | [bench/results.md](https://github.com/DuncanBentley/sievemin/blob/main/bench/results.md), Table 1 |
57
+ | It loses on random segments of 5 to 100 points | 928 of 1,000 shorter, 22.4% fewer classes on average, max 40.5%, never longer | [bench/random_segments.md](https://github.com/DuncanBentley/sievemin/blob/main/bench/random_segments.md) |
58
+ | Smaller period | median period 70 against 21,162,960 for music21, at the price of 34.5 classes on average | [bench/random_segments.md](https://github.com/DuncanBentley/sievemin/blob/main/bench/random_segments.md) |
59
+ | Published Xenakis formulas are not all minimal, even with the composer's own moduli | Nomos Alpha size 23 to 21, Psappha (Flint) 59 to 36, À R. 99 to 88, Jonchaies already minimal | [bench/results.md](https://github.com/DuncanBentley/sievemin/blob/main/bench/results.md), Table 2 |
60
+ | Complement makes formulas shorter | the major scale goes from 18 to 10 (any modulus); on 251 small random segments, `&` and `-` beat the best union on 180 | [REPORT.md](https://github.com/DuncanBentley/sievemin/blob/main/REPORT.md) section 3 |
61
+ | The gain is smaller on structured material | 100 random Xenakis-style sieves: 37 shorter, 11.4% on average, median 0% | [bench/random_study.csv](https://github.com/DuncanBentley/sievemin/blob/main/bench/random_study.csv) |
62
+ | Error-tolerant mode (formula plus k wrong notes) | recovers a planted sieve with 1, 2, 3 flipped notes in 100%, 96%, 95% of 150 trials each (cost 1) | [bench/tolerant_study.md](https://github.com/DuncanBentley/sievemin/blob/main/bench/tolerant_study.md) |
63
+
64
+ Random point sets are where the greedy does worst. Real material is more structured, and there the gain is smaller.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install sievemin
70
+ ```
71
+
72
+ or with uv:
73
+
74
+ ```bash
75
+ uv add sievemin
76
+ ```
77
+
78
+ `pip install "sievemin[music21]"` also installs music21, so you can check the output with `music21.sieve.Sieve`. sievemin itself only needs numpy and scipy.
79
+
80
+ ## Quick start
81
+
82
+ ```bash
83
+ sievemin 0 2 4 5 7 9 11 --period 12
84
+ # (-(12@1|12@3|12@6|12@8|12@10))
85
+ # size 10 (leaves 5, operators 5); optimal: True; ...
86
+ ```
87
+
88
+ That takes a few seconds, mostly Python start-up. The solve itself is tens of milliseconds.
89
+
90
+ ```python
91
+ from sievemin import Domain, parse, evaluate, minimize_dnf, to_music21
92
+
93
+ nomos = '(-(13@3|13@5|13@7|13@9)&11@2)|(-(11@4|11@8)&13@9)|(13@0|13@1|13@6)'
94
+ dom = Domain.periodic(143)
95
+ r = minimize_dnf(evaluate(parse(nomos), dom), dom, max_modulus=13, max_neg=4)
96
+ r.text, r.size, r.optimal
97
+ # ('11@2&-(13@3|13@5|13@7)|13@0|13@1|13@6|13@9&-(11@4|11@8)', 21, True)
98
+ to_music21(r.formula) # bracketed, see the music21 note below
99
+ ```
100
+
101
+ `Domain.window(z)` matches exactly on the points of `z`, which is how music21 works. Pass `max_modulus=len(z) - 1` to stay inside the moduli music21 itself would try. `Domain.periodic(P)` matches on every integer.
102
+
103
+ For unions you can choose what "shortest" means: `minimize_union(..., objective='leaves' | 'chars' | 'period')`.
104
+
105
+ `minimize_union_tolerant(points, dom, max_edits=2, edit_cost=1)` returns a formula plus the notes to add and the notes to remove.
106
+
107
+ One music21 note: music21 10.5.2 crashes on an unbracketed intersection with a complemented class, for example `Sieve('3@1&-4@2')`. `to_music21()` puts brackets around the formula to avoid it.
108
+
109
+ ## Reproduce everything
110
+
111
+ ```bash
112
+ git clone https://github.com/DuncanBentley/sievemin
113
+ cd sievemin
114
+ pip install -e ".[test]" # adds pytest and music21
115
+ python -m pytest # 127 tests
116
+ python bench/reproduce.py # benchmark and tolerant study, a few minutes
117
+ python bench/reproduce.py --all # adds the 1,000 random segments, 1 to 2 hours
118
+ ```
119
+
120
+ `bench/reproduce.py` writes `bench/results.md` and `results.csv`, plus `bench/tolerant_study.md` and `tolerant_study.csv`. With `--all` it also writes `bench/random_segments.md` and `random_segments.csv`. The 100 random Xenakis-style sieves are in `bench/random_study.csv`. Seeds are fixed (2026), so the optimal values reproduce exactly. Runtimes depend on the machine. Mine is a Ryzen 9 8940HX laptop, where the 1,000-segment run took 1 hour 54 minutes.
121
+
122
+ ## What it guarantees
123
+
124
+ | function | what it searches | what is proven |
125
+ |---|---|---|
126
+ | `minimize_union` | unions of residue classes, the form music21's `CompressionSegment` outputs | the fewest classes, the shortest string, or the smallest period, by exact ILP over every candidate class |
127
+ | `minimize_dnf` | unions of terms `P & -(N1\|...\|Nk)`, the shape of Xenakis's published sieves | the smallest formula of that shape, for a given cap on k and, optionally, on the moduli |
128
+ | `minimize_general` | formulas of any shape | the smallest formula there is, by exhaustive search, for domains of up to 14 points |
129
+ | `minimize_union_tolerant` | a union of classes plus up to k notes added or removed | the smallest formula size + λ × exceptions |
130
+
131
+ Size means leaves plus operators, so a union of n classes has size 2n - 1. Every result says whether the solver proved it optimal. I also ran every published result in this repo back through music21's own `Sieve` and checked it gives the same set.
132
+
133
+ ## Method
134
+
135
+ Unions are an exact set cover, solved as an ILP with HiGHS through scipy. The candidate list is every residue class that stays inside the target, so the minimum is global. Terms with complement are enumerated per positive class, then another set cover picks the cheapest union. The any-shape search is dynamic programming over all subsets of up to 14 points. [REPORT.md](https://github.com/DuncanBentley/sievemin/blob/main/REPORT.md) has the details, the benchmarks and the prior-work notes.
136
+
137
+ ## Limits / what this does not show
138
+
139
+ `minimize_dnf` is only optimal within its own shape. A formula nested differently can be shorter. With moduli up to 4, the major scale has a size 16 formula, and the best union of terms is 17. Only `minimize_general` covers every shape, and it runs out of room past 14 points.
140
+
141
+ A result on a window only holds on that window, as with music21. If the formula has to hold on every integer, use `Domain.periodic(P)`. A window formula can use moduli no composer would pick, like `41@24`.
142
+
143
+ Exact set cover is NP-hard. Most sets solve in milliseconds, but some 100-point windows take tens of seconds, and the slowest random segment took 248 s.
144
+
145
+ The error-tolerant mode only works with unions, and its answer depends on the exception cost λ, which you choose. That it finds structure in Xenakis's sets is not evidence that he edited notes by hand.
146
+
147
+ On music21's own 12 test segments the greedy is already optimal. The 1,000 random segments have no structure, so 22.4% is not what to expect on a real scale or rhythm.
148
+
149
+ I haven't read *Formalized Music* or Xenakis 1990 directly. The published formulas come from music21's tests and the hmt library, and the value lists I generate match hmt's for Nomos Alpha, Psappha and À R.
150
+
151
+ Whether a shorter formula is better music is not something this measures.
152
+
153
+ ## Upstream and sources
154
+
155
+ - music21: <https://github.com/cuthbertLab/music21>. I studied version 10.5.2 ([release](https://github.com/cuthbertLab/music21/releases/tag/v10.5.2)), specifically [`music21/sieve.py`](https://github.com/cuthbertLab/music21/blob/v10.5.2/music21/sieve.py). Docs: <https://www.music21.org/music21docs/moduleReference/moduleSieve.html>.
156
+ - hmt (Rohan Drape, Haskell), a second source for the published sieves: <https://github.com/rd--/hmt>.
157
+ - Ariza, C. 2005. "The Xenakis Sieve as Object: A New Model and a Complete Implementation." *Computer Music Journal* 29(2). DOI 10.1162/0148926054094396. This is the model behind music21's sieve.
158
+ - Xenakis, I. 1990. "Sieves." *Perspectives of New Music* 28(1): <https://doi.org/10.2307/833343>.
159
+ - Bliznets, Nederlof and Szilágyi, "Parameterized Algorithms for Covering by Arithmetic Progressions": <https://arxiv.org/abs/2312.06393>.
160
+
161
+ No external data is downloaded. The published sieve definitions are re-typed from music21's tests and hmt, and the random sets come from fixed seeds.
162
+
163
+ ## Status of upstream reports
164
+
165
+ | Report | Status |
166
+ |---|---|
167
+ | music21: crash on `Sieve('3@1&-4@2')` (unbracketed intersection with a complemented class), one-line fix plus test | issue filed: [cuthbertLab/music21#2131](https://github.com/cuthbertLab/music21/issues/2131), fixed and merged in [#2135](https://github.com/cuthbertLab/music21/pull/2135) |
168
+ | music21: opt-in exact compression, `CompressionSegment(..., method='exact')` | to be filed |
169
+
170
+ ## Citation
171
+
172
+ See [CITATION.cff](https://github.com/DuncanBentley/sievemin/blob/main/CITATION.cff).
173
+
174
+ ```bibtex
175
+ @software{bentley2026_sievemin,
176
+ author = {Bentley, Duncan},
177
+ title = {sievemin: exact minimisation of Xenakis sieves},
178
+ year = {2026},
179
+ url = {https://github.com/DuncanBentley/sievemin},
180
+ doi = {10.5281/zenodo.23816540}
181
+ }
182
+ ```
183
+
184
+ ## License
185
+
186
+ BSD-3-Clause, the same as music21. See [LICENSE](https://github.com/DuncanBentley/sievemin/blob/main/LICENSE). The repo contains no third-party data.
187
+
188
+ ## Acknowledgements
189
+
190
+ Thanks to Michael Scott Cuthbert and the music21 contributors, whose `sieve.py` I tested against, to Christopher Ariza for the sieve model it implements, and to Rohan Drape for hmt.
191
+
@@ -0,0 +1,11 @@
1
+ sievemin/__init__.py,sha256=h8YJ3N8Neah9ss1C77xzU0CQW2rselpajgVKPBZyfSE,362
2
+ sievemin/cli.py,sha256=fqLZo_8edFkZw04hcyZFBXzHyrmrjYIPUHKypujnUq0,2511
3
+ sievemin/formula.py,sha256=WaBNNcO1u_JiToJGArxuQBVeXOybLh3htqloSGxPYms,7333
4
+ sievemin/solve.py,sha256=SFv_b_px21Mbk0bDJejhXI69Ik54QyEBwCwinP20LEs,20706
5
+ sievemin/tolerant.py,sha256=yQB_IMaBSkCIVRdj91kHEngTeJNjkWA8arlKFxJaKCk,5954
6
+ sievemin-0.1.1.dist-info/licenses/LICENSE,sha256=G-5vR3CvoLN8sM4rgj1B8v26McndLRwUG2GbFIRFCYo,1499
7
+ sievemin-0.1.1.dist-info/METADATA,sha256=tDDX2TQr_vuV3a-Rgc0--AtS_8x6YNiHgzkUxtI7XAU,12660
8
+ sievemin-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ sievemin-0.1.1.dist-info/entry_points.txt,sha256=ECZBHq9nejqAx8hl3qexITtzFCtmGN60uhOeXBemq58,47
10
+ sievemin-0.1.1.dist-info/top_level.txt,sha256=JvmAuaQeWLQ0VMeEY9nfub_ZihHaJUgY0jfDtdygMmY,9
11
+ sievemin-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sievemin = sievemin.cli:main
@@ -0,0 +1,26 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Duncan Bentley
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+ 3. Neither the name of the copyright holder nor the names of its
14
+ contributors may be used to endorse or promote products derived from
15
+ this software without specific prior written permission.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ sievemin