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 +7 -0
- sievemin/cli.py +55 -0
- sievemin/formula.py +254 -0
- sievemin/solve.py +587 -0
- sievemin/tolerant.py +152 -0
- sievemin-0.1.1.dist-info/METADATA +191 -0
- sievemin-0.1.1.dist-info/RECORD +11 -0
- sievemin-0.1.1.dist-info/WHEEL +5 -0
- sievemin-0.1.1.dist-info/entry_points.txt +2 -0
- sievemin-0.1.1.dist-info/licenses/LICENSE +26 -0
- sievemin-0.1.1.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/DuncanBentley/sievemin/actions) [](https://github.com/DuncanBentley/sievemin/blob/main/LICENSE) [](https://github.com/DuncanBentley/sievemin/blob/main/pyproject.toml) [](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,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
|