pypebbling 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pypebbling-0.1.0/LICENSE +21 -0
- pypebbling-0.1.0/PKG-INFO +63 -0
- pypebbling-0.1.0/README.md +50 -0
- pypebbling-0.1.0/naivecheck/__init__.py +1 -0
- pypebbling-0.1.0/naivecheck/flowmodel.py +26 -0
- pypebbling-0.1.0/naivecheck/naive.py +200 -0
- pypebbling-0.1.0/pebbling/__init__.py +1 -0
- pypebbling-0.1.0/pebbling/certify.py +12 -0
- pypebbling-0.1.0/pebbling/graphs.py +164 -0
- pypebbling-0.1.0/pebbling/ipsolver.py +165 -0
- pypebbling-0.1.0/pebbling/numbers.py +63 -0
- pypebbling-0.1.0/pebbling/path.py +55 -0
- pypebbling-0.1.0/pebbling/portfolio.py +79 -0
- pypebbling-0.1.0/pebbling/solver.py +269 -0
- pypebbling-0.1.0/pypebbling.egg-info/PKG-INFO +63 -0
- pypebbling-0.1.0/pypebbling.egg-info/SOURCES.txt +19 -0
- pypebbling-0.1.0/pypebbling.egg-info/dependency_links.txt +1 -0
- pypebbling-0.1.0/pypebbling.egg-info/requires.txt +4 -0
- pypebbling-0.1.0/pypebbling.egg-info/top_level.txt +2 -0
- pypebbling-0.1.0/pyproject.toml +18 -0
- pypebbling-0.1.0/setup.cfg +4 -0
pypebbling-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Lee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypebbling
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Exact pebbling reachability and solvability on graphs, with replayable move certificates and an independent checker
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: ortools>=9.15
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.4; extra == "test"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pypebbling
|
|
15
|
+
|
|
16
|
+
Exact tools for graph pebbling: reachability, solvability and minimality of pebble distributions, with
|
|
17
|
+
move sequences anyone can replay.
|
|
18
|
+
|
|
19
|
+
A pebbling move removes two pebbles from a vertex and puts one pebble on a neighbouring vertex. A
|
|
20
|
+
distribution is solvable if every vertex can receive a pebble by some sequence of moves. The optimal pebbling
|
|
21
|
+
number of a graph is the smallest number of pebbles in a solvable distribution.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pypebbling
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package is imported as `pebbling`. It needs Python 3.12 or later and installs OR-Tools.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pebbling.graphs import grid_graph
|
|
35
|
+
from pebbling.solver import solvability
|
|
36
|
+
from pebbling.certify import replay
|
|
37
|
+
|
|
38
|
+
G = grid_graph(5, 5)
|
|
39
|
+
P = [0] * 25
|
|
40
|
+
for r, c in [(1, 1), (1, 3), (3, 1), (3, 3)]:
|
|
41
|
+
P[r * 5 + c] = 4
|
|
42
|
+
|
|
43
|
+
solvable, certs, _ = solvability(G, tuple(P))
|
|
44
|
+
print("solvable:", solvable)
|
|
45
|
+
|
|
46
|
+
moves = certs[0] # vertex (0, 0); vertex v is (v // 5, v % 5)
|
|
47
|
+
print("moves to (0, 0):", [(divmod(u, 5), divmod(w, 5)) for u, w in moves])
|
|
48
|
+
print("replay ok:", replay(G.adj, P, moves, 0))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What is inside
|
|
52
|
+
|
|
53
|
+
- `pebbling.solver`: exact backward search over demands, with exact integer potentials for pruning.
|
|
54
|
+
- `pebbling.ipsolver`: a second exact engine, an integer flow model solved with CP-SAT.
|
|
55
|
+
- `pebbling.certify`: replays move sequences.
|
|
56
|
+
- `naivecheck`: an independent naive checker, written separately from the solver.
|
|
57
|
+
|
|
58
|
+
Source code, proofs of correctness, the validation suite and a counterexample to a conjecture of Petr,
|
|
59
|
+
Portier and Stolarczyk are in the repository linked on this page.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# pypebbling
|
|
2
|
+
|
|
3
|
+
Exact tools for graph pebbling: reachability, solvability and minimality of pebble distributions, with
|
|
4
|
+
move sequences anyone can replay.
|
|
5
|
+
|
|
6
|
+
A pebbling move removes two pebbles from a vertex and puts one pebble on a neighbouring vertex. A
|
|
7
|
+
distribution is solvable if every vertex can receive a pebble by some sequence of moves. The optimal pebbling
|
|
8
|
+
number of a graph is the smallest number of pebbles in a solvable distribution.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pypebbling
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package is imported as `pebbling`. It needs Python 3.12 or later and installs OR-Tools.
|
|
17
|
+
|
|
18
|
+
## Example
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pebbling.graphs import grid_graph
|
|
22
|
+
from pebbling.solver import solvability
|
|
23
|
+
from pebbling.certify import replay
|
|
24
|
+
|
|
25
|
+
G = grid_graph(5, 5)
|
|
26
|
+
P = [0] * 25
|
|
27
|
+
for r, c in [(1, 1), (1, 3), (3, 1), (3, 3)]:
|
|
28
|
+
P[r * 5 + c] = 4
|
|
29
|
+
|
|
30
|
+
solvable, certs, _ = solvability(G, tuple(P))
|
|
31
|
+
print("solvable:", solvable)
|
|
32
|
+
|
|
33
|
+
moves = certs[0] # vertex (0, 0); vertex v is (v // 5, v % 5)
|
|
34
|
+
print("moves to (0, 0):", [(divmod(u, 5), divmod(w, 5)) for u, w in moves])
|
|
35
|
+
print("replay ok:", replay(G.adj, P, moves, 0))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What is inside
|
|
39
|
+
|
|
40
|
+
- `pebbling.solver`: exact backward search over demands, with exact integer potentials for pruning.
|
|
41
|
+
- `pebbling.ipsolver`: a second exact engine, an integer flow model solved with CP-SAT.
|
|
42
|
+
- `pebbling.certify`: replays move sequences.
|
|
43
|
+
- `naivecheck`: an independent naive checker, written separately from the solver.
|
|
44
|
+
|
|
45
|
+
Source code, proofs of correctness, the validation suite and a counterexample to a conjecture of Petr,
|
|
46
|
+
Portier and Stolarczyk are in the repository linked on this page.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Independent naive checker."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Independent flow-model check of unreachability, for instances too large for naive search."""
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def target_feasible(adj, P, X, time_limit=None):
|
|
6
|
+
n = len(adj)
|
|
7
|
+
total = sum(P)
|
|
8
|
+
model = cp_model.CpModel()
|
|
9
|
+
F = {}
|
|
10
|
+
for u in range(n):
|
|
11
|
+
for w in adj[u]:
|
|
12
|
+
F[(u, w)] = model.NewIntVar(0, total, f"F{u}_{w}")
|
|
13
|
+
for v in range(n):
|
|
14
|
+
incoming = [F[(u, v)] for u in adj[v]]
|
|
15
|
+
outgoing = [F[(v, w)] for w in adj[v]]
|
|
16
|
+
model.Add(P[v] + sum(incoming) - 2 * sum(outgoing) >= (1 if v == X else 0))
|
|
17
|
+
solver = cp_model.CpSolver()
|
|
18
|
+
solver.parameters.num_workers = 1
|
|
19
|
+
if time_limit is not None:
|
|
20
|
+
solver.parameters.max_time_in_seconds = time_limit
|
|
21
|
+
status = solver.Solve(model)
|
|
22
|
+
if status == cp_model.INFEASIBLE:
|
|
23
|
+
return False
|
|
24
|
+
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|
25
|
+
return True
|
|
26
|
+
return None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Deliberately naive pebbling checker, written independently of the `pebbling` package."""
|
|
2
|
+
from fractions import Fraction
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_adjacency(spec):
|
|
6
|
+
kind, _, rest = spec.partition(":")
|
|
7
|
+
if kind == "grid":
|
|
8
|
+
rows, cols = (int(t) for t in rest.split("x"))
|
|
9
|
+
adj = [[] for _ in range(rows * cols)]
|
|
10
|
+
for r in range(rows):
|
|
11
|
+
for c in range(cols):
|
|
12
|
+
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
13
|
+
r2, c2 = r + dr, c + dc
|
|
14
|
+
if 0 <= r2 < rows and 0 <= c2 < cols:
|
|
15
|
+
adj[r * cols + c].append(r2 * cols + c2)
|
|
16
|
+
return adj
|
|
17
|
+
if kind == "path":
|
|
18
|
+
n = int(rest)
|
|
19
|
+
return [[x for x in (i - 1, i + 1) if 0 <= x < n] for i in range(n)]
|
|
20
|
+
if kind == "cycle":
|
|
21
|
+
n = int(rest)
|
|
22
|
+
return [sorted({(i - 1) % n, (i + 1) % n}) for i in range(n)]
|
|
23
|
+
if kind == "complete":
|
|
24
|
+
n = int(rest)
|
|
25
|
+
return [[j for j in range(n) if j != i] for i in range(n)]
|
|
26
|
+
if kind == "petersen":
|
|
27
|
+
adj = [[] for _ in range(10)]
|
|
28
|
+
pairs = []
|
|
29
|
+
for i in range(5):
|
|
30
|
+
pairs.append((i, (i + 1) % 5))
|
|
31
|
+
pairs.append((i, i + 5))
|
|
32
|
+
pairs.append((5 + i, 5 + (i + 2) % 5))
|
|
33
|
+
for a, b in pairs:
|
|
34
|
+
adj[a].append(b)
|
|
35
|
+
adj[b].append(a)
|
|
36
|
+
return adj
|
|
37
|
+
if kind == "edges":
|
|
38
|
+
n_str, _, e_str = rest.partition(":")
|
|
39
|
+
adj = [[] for _ in range(int(n_str))]
|
|
40
|
+
for e in e_str.split(";"):
|
|
41
|
+
if e:
|
|
42
|
+
a, b = (int(t) for t in e.split("-"))
|
|
43
|
+
adj[a].append(b)
|
|
44
|
+
adj[b].append(a)
|
|
45
|
+
return adj
|
|
46
|
+
raise ValueError(spec)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def distances_from(adj, source):
|
|
50
|
+
dist = {source: 0}
|
|
51
|
+
frontier = [source]
|
|
52
|
+
while frontier:
|
|
53
|
+
nxt = []
|
|
54
|
+
for x in frontier:
|
|
55
|
+
for y in adj[x]:
|
|
56
|
+
if y not in dist:
|
|
57
|
+
dist[y] = dist[x] + 1
|
|
58
|
+
nxt.append(y)
|
|
59
|
+
frontier = nxt
|
|
60
|
+
return [dist[v] for v in range(len(adj))]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def value(adj, P, X):
|
|
64
|
+
d = distances_from(adj, X)
|
|
65
|
+
total = Fraction(0)
|
|
66
|
+
for y in range(len(adj)):
|
|
67
|
+
total += Fraction(P[y], 2 ** d[y])
|
|
68
|
+
return total
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def replay_moves(adj, P, moves, X):
|
|
72
|
+
state = list(P)
|
|
73
|
+
for u, w in moves:
|
|
74
|
+
if w not in adj[u]:
|
|
75
|
+
return False
|
|
76
|
+
if state[u] < 2:
|
|
77
|
+
return False
|
|
78
|
+
state[u] -= 2
|
|
79
|
+
state[w] += 1
|
|
80
|
+
return state[X] >= 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StateLimit(Exception):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_reachable(adj, P, X, prune_by_value=False, max_states=None, greedy_only=False):
|
|
88
|
+
"""Forward depth-first search over pebbling states."""
|
|
89
|
+
start = tuple(P)
|
|
90
|
+
if start[X] > 0:
|
|
91
|
+
return True
|
|
92
|
+
d = distances_from(adj, X)
|
|
93
|
+
half_powers = [Fraction(1, 2 ** k) for k in range(max(d) + 2)]
|
|
94
|
+
start_val = sum((Fraction(P[y], 2 ** d[y]) for y in range(len(adj))), Fraction(0))
|
|
95
|
+
if prune_by_value and start_val < 1:
|
|
96
|
+
return False
|
|
97
|
+
seen = {start}
|
|
98
|
+
stack = [(start, start_val)]
|
|
99
|
+
n = len(adj)
|
|
100
|
+
while stack:
|
|
101
|
+
state, val = stack.pop()
|
|
102
|
+
candidates = []
|
|
103
|
+
for u in range(n):
|
|
104
|
+
if state[u] >= 2:
|
|
105
|
+
for w in adj[u]:
|
|
106
|
+
if greedy_only and d[w] >= d[u]:
|
|
107
|
+
continue
|
|
108
|
+
candidates.append((u, w))
|
|
109
|
+
candidates.sort(key=lambda m: d[m[1]] - d[m[0]], reverse=True)
|
|
110
|
+
for u, w in candidates:
|
|
111
|
+
nxt = list(state)
|
|
112
|
+
nxt[u] -= 2
|
|
113
|
+
nxt[w] += 1
|
|
114
|
+
if w == X:
|
|
115
|
+
return True
|
|
116
|
+
nxt = tuple(nxt)
|
|
117
|
+
if nxt in seen:
|
|
118
|
+
continue
|
|
119
|
+
nval = val - 2 * half_powers[d[u]] + half_powers[d[w]]
|
|
120
|
+
if prune_by_value and nval < 1:
|
|
121
|
+
continue
|
|
122
|
+
seen.add(nxt)
|
|
123
|
+
if max_states is not None and len(seen) > max_states:
|
|
124
|
+
raise StateLimit(len(seen))
|
|
125
|
+
stack.append((nxt, nval))
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def reachable_vertices(adj, P, max_states=None):
|
|
130
|
+
"""Vertices that hold a pebble in some state reachable from P."""
|
|
131
|
+
start = tuple(P)
|
|
132
|
+
n = len(adj)
|
|
133
|
+
covered = {v for v in range(n) if start[v] > 0}
|
|
134
|
+
seen = {start}
|
|
135
|
+
stack = [start]
|
|
136
|
+
while stack:
|
|
137
|
+
state = stack.pop()
|
|
138
|
+
for u in range(n):
|
|
139
|
+
if state[u] >= 2:
|
|
140
|
+
for w in adj[u]:
|
|
141
|
+
nxt = list(state)
|
|
142
|
+
nxt[u] -= 2
|
|
143
|
+
nxt[w] += 1
|
|
144
|
+
nxt = tuple(nxt)
|
|
145
|
+
covered.add(w)
|
|
146
|
+
if nxt not in seen:
|
|
147
|
+
seen.add(nxt)
|
|
148
|
+
if max_states is not None and len(seen) > max_states:
|
|
149
|
+
raise StateLimit(len(seen))
|
|
150
|
+
stack.append(nxt)
|
|
151
|
+
return covered
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_solvable(adj, P, max_states=None):
|
|
155
|
+
return len(reachable_vertices(adj, P, max_states)) == len(adj)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_minimal_distribution(adj, P, witness_hints=None, prune_by_value=False,
|
|
159
|
+
max_states=None, certificates=None, hint_only=False):
|
|
160
|
+
"""Naive minimality verification of a claimed minimal distribution."""
|
|
161
|
+
n = len(adj)
|
|
162
|
+
report = {"reach_method": {}, "deletions": {}, "ok": True}
|
|
163
|
+
for X in range(n):
|
|
164
|
+
try:
|
|
165
|
+
ok = is_reachable(adj, P, X, prune_by_value=prune_by_value, max_states=max_states)
|
|
166
|
+
report["reach_method"][X] = "search"
|
|
167
|
+
except StateLimit:
|
|
168
|
+
cert = (certificates or {}).get(X)
|
|
169
|
+
if cert is None:
|
|
170
|
+
report["reach_method"][X] = "undecided"
|
|
171
|
+
report["ok"] = False
|
|
172
|
+
continue
|
|
173
|
+
ok = replay_moves(adj, P, cert, X)
|
|
174
|
+
report["reach_method"][X] = "certificate_replay"
|
|
175
|
+
if not ok:
|
|
176
|
+
report["ok"] = False
|
|
177
|
+
report["unreachable_in_P"] = X
|
|
178
|
+
return report
|
|
179
|
+
for v in range(n):
|
|
180
|
+
if P[v] == 0:
|
|
181
|
+
continue
|
|
182
|
+
Q = list(P)
|
|
183
|
+
Q[v] -= 1
|
|
184
|
+
order = list(range(n))
|
|
185
|
+
if witness_hints and v in witness_hints:
|
|
186
|
+
h = witness_hints[v]
|
|
187
|
+
order = [h] if hint_only else [h] + [x for x in order if x != h]
|
|
188
|
+
found = None
|
|
189
|
+
undecided = []
|
|
190
|
+
for X in order:
|
|
191
|
+
try:
|
|
192
|
+
if not is_reachable(adj, Q, X, prune_by_value=prune_by_value, max_states=max_states):
|
|
193
|
+
found = X
|
|
194
|
+
break
|
|
195
|
+
except StateLimit:
|
|
196
|
+
undecided.append(X)
|
|
197
|
+
report["deletions"][v] = {"unreachable_target": found, "undecided_targets": undecided}
|
|
198
|
+
if found is None:
|
|
199
|
+
report["ok"] = False
|
|
200
|
+
return report
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pebbling toolkit: exact reachability, solvability, minimality and potentials."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Replay a pebbling move sequence."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def replay(adj, P, moves, target):
|
|
5
|
+
"""True iff every move is legal from P in order and the result has a pebble on target."""
|
|
6
|
+
Q = list(P)
|
|
7
|
+
for u, w in moves:
|
|
8
|
+
if w not in adj[u] or Q[u] < 2:
|
|
9
|
+
return False
|
|
10
|
+
Q[u] -= 2
|
|
11
|
+
Q[w] += 1
|
|
12
|
+
return Q[target] >= 1
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Graphs for pebbling: constructors, all-pairs distances, grid symmetries."""
|
|
2
|
+
from collections import deque
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Graph:
|
|
7
|
+
def __init__(self, name, num_vertices, edges, kind="other", coords=None, grid_shape=None):
|
|
8
|
+
n = num_vertices
|
|
9
|
+
nbrs = [set() for _ in range(n)]
|
|
10
|
+
for a, b in edges:
|
|
11
|
+
if not (0 <= a < n and 0 <= b < n) or a == b:
|
|
12
|
+
raise ValueError(f"bad edge {(a, b)} for {name}")
|
|
13
|
+
nbrs[a].add(b)
|
|
14
|
+
nbrs[b].add(a)
|
|
15
|
+
self.name = name
|
|
16
|
+
self.kind = kind
|
|
17
|
+
self.n = n
|
|
18
|
+
self.adj = tuple(tuple(sorted(s)) for s in nbrs)
|
|
19
|
+
self.edges = tuple(sorted({(min(a, b), max(a, b)) for a, b in edges}))
|
|
20
|
+
self.coords = coords
|
|
21
|
+
self.grid_shape = grid_shape
|
|
22
|
+
self.dist = tuple(tuple(self._bfs(s)) for s in range(n))
|
|
23
|
+
for row in self.dist:
|
|
24
|
+
if min(row) < 0:
|
|
25
|
+
raise ValueError(f"{name} is disconnected")
|
|
26
|
+
self.diam = max(max(row) for row in self.dist)
|
|
27
|
+
# potentials are exact integers: v_P(X) = sum_y P[y] * weight[X][y] / scale
|
|
28
|
+
self.scale = 1 << self.diam
|
|
29
|
+
self.weight = tuple(tuple(1 << (self.diam - d) for d in row) for row in self.dist)
|
|
30
|
+
self.weight2 = tuple(tuple(2 * w for w in row) for row in self.weight)
|
|
31
|
+
|
|
32
|
+
def _bfs(self, s):
|
|
33
|
+
dist = [-1] * self.n
|
|
34
|
+
dist[s] = 0
|
|
35
|
+
q = deque([s])
|
|
36
|
+
while q:
|
|
37
|
+
x = q.popleft()
|
|
38
|
+
for y in self.adj[x]:
|
|
39
|
+
if dist[y] < 0:
|
|
40
|
+
dist[y] = dist[x] + 1
|
|
41
|
+
q.append(y)
|
|
42
|
+
return dist
|
|
43
|
+
|
|
44
|
+
def spec(self):
|
|
45
|
+
"""String from which make_graph() rebuilds this graph (for worker processes)."""
|
|
46
|
+
if self.kind == "grid":
|
|
47
|
+
return f"grid:{self.grid_shape[0]}x{self.grid_shape[1]}"
|
|
48
|
+
if self.kind in ("path", "cycle", "complete"):
|
|
49
|
+
return f"{self.kind}:{self.n}"
|
|
50
|
+
if self.kind == "petersen":
|
|
51
|
+
return "petersen"
|
|
52
|
+
return "edges:" + str(self.n) + ":" + ";".join(f"{a}-{b}" for a, b in self.edges)
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return f"Graph({self.name}, n={self.n}, |E|={len(self.edges)})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def complete_graph(n):
|
|
59
|
+
return Graph(f"K{n}", n, [(a, b) for a in range(n) for b in range(a + 1, n)], kind="complete")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def path_graph(n):
|
|
63
|
+
return Graph(f"P{n}", n, [(i, i + 1) for i in range(n - 1)], kind="path")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cycle_graph(n):
|
|
67
|
+
if n < 3:
|
|
68
|
+
raise ValueError("cycle needs n >= 3")
|
|
69
|
+
return Graph(f"C{n}", n, [(i, (i + 1) % n) for i in range(n)], kind="cycle")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def grid_graph(m, n):
|
|
73
|
+
edges = []
|
|
74
|
+
for i in range(m):
|
|
75
|
+
for j in range(n):
|
|
76
|
+
v = i * n + j
|
|
77
|
+
if i + 1 < m:
|
|
78
|
+
edges.append((v, v + n))
|
|
79
|
+
if j + 1 < n:
|
|
80
|
+
edges.append((v, v + 1))
|
|
81
|
+
coords = tuple((i, j) for i in range(m) for j in range(n))
|
|
82
|
+
return Graph(f"grid{m}x{n}", m * n, edges, kind="grid", coords=coords, grid_shape=(m, n))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def petersen_graph():
|
|
86
|
+
outer = [(i, (i + 1) % 5) for i in range(5)]
|
|
87
|
+
spokes = [(i, i + 5) for i in range(5)]
|
|
88
|
+
inner = [(5 + i, 5 + (i + 2) % 5) for i in range(5)]
|
|
89
|
+
return Graph("Petersen", 10, outer + spokes + inner, kind="petersen")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def random_connected_graph(n, p, rng):
|
|
93
|
+
"""G(n, p) conditioned on connectivity (rejection sampling), for solver cross-checks."""
|
|
94
|
+
while True:
|
|
95
|
+
edges = [(a, b) for a in range(n) for b in range(a + 1, n) if rng.random() < p]
|
|
96
|
+
try:
|
|
97
|
+
g = Graph(f"rand{n}", n, edges, kind="other")
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
return g
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def make_graph(spec):
|
|
104
|
+
kind, _, rest = spec.partition(":")
|
|
105
|
+
if kind == "grid":
|
|
106
|
+
m, n = rest.split("x")
|
|
107
|
+
return grid_graph(int(m), int(n))
|
|
108
|
+
if kind == "path":
|
|
109
|
+
return path_graph(int(rest))
|
|
110
|
+
if kind == "cycle":
|
|
111
|
+
return cycle_graph(int(rest))
|
|
112
|
+
if kind == "complete":
|
|
113
|
+
return complete_graph(int(rest))
|
|
114
|
+
if kind == "petersen":
|
|
115
|
+
return petersen_graph()
|
|
116
|
+
if kind == "edges":
|
|
117
|
+
n_str, _, e_str = rest.partition(":")
|
|
118
|
+
edges = [tuple(int(x) for x in e.split("-")) for e in e_str.split(";") if e]
|
|
119
|
+
return Graph(f"rand{n_str}", int(n_str), edges, kind="other")
|
|
120
|
+
raise ValueError(spec)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def grid_symmetries(m, n):
|
|
124
|
+
"""The 8 dihedral maps of an m-by-n grid."""
|
|
125
|
+
maps = [
|
|
126
|
+
("id", (m, n), lambda i, j: (i, j)),
|
|
127
|
+
("flip_rows", (m, n), lambda i, j: (m - 1 - i, j)),
|
|
128
|
+
("flip_cols", (m, n), lambda i, j: (i, n - 1 - j)),
|
|
129
|
+
("rot180", (m, n), lambda i, j: (m - 1 - i, n - 1 - j)),
|
|
130
|
+
("transpose", (n, m), lambda i, j: (j, i)),
|
|
131
|
+
("anti_transpose", (n, m), lambda i, j: (n - 1 - j, m - 1 - i)),
|
|
132
|
+
("rot90", (n, m), lambda i, j: (j, m - 1 - i)),
|
|
133
|
+
("rot270", (n, m), lambda i, j: (n - 1 - j, i)),
|
|
134
|
+
]
|
|
135
|
+
out = []
|
|
136
|
+
for name, (m2, n2), f in maps:
|
|
137
|
+
perm = []
|
|
138
|
+
for i in range(m):
|
|
139
|
+
for j in range(n):
|
|
140
|
+
i2, j2 = f(i, j)
|
|
141
|
+
if not (0 <= i2 < m2 and 0 <= j2 < n2):
|
|
142
|
+
raise AssertionError(f"symmetry {name} leaves the grid")
|
|
143
|
+
perm.append(i2 * n2 + j2)
|
|
144
|
+
out.append((name, (m2, n2), tuple(perm)))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def apply_perm(P, perm):
|
|
149
|
+
"""Image of distribution P under a vertex map perm (perm must be a bijection)."""
|
|
150
|
+
Q = [0] * len(P)
|
|
151
|
+
for v, c in enumerate(P):
|
|
152
|
+
Q[perm[v]] = c
|
|
153
|
+
return tuple(Q)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def boundary_distance(G, v):
|
|
157
|
+
"""Distance from v to the nearest boundary vertex."""
|
|
158
|
+
if G.kind == "grid":
|
|
159
|
+
m, n = G.grid_shape
|
|
160
|
+
i, j = G.coords[v]
|
|
161
|
+
return min(i, j, m - 1 - i, n - 1 - j)
|
|
162
|
+
if G.kind == "path":
|
|
163
|
+
return min(v, G.n - 1 - v)
|
|
164
|
+
raise ValueError(f"boundary distance undefined for kind {G.kind}")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Second exact reachability engine: integer feasibility solved with OR-Tools CP-SAT."""
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from .certify import replay
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IPUndecided(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def firing_bounds(G, P):
|
|
12
|
+
W = G.weight
|
|
13
|
+
scale = G.scale
|
|
14
|
+
n = G.n
|
|
15
|
+
support = [y for y in range(n) if P[y]]
|
|
16
|
+
out = []
|
|
17
|
+
for u in range(n):
|
|
18
|
+
phi = sum(P[y] * W[u][y] for y in support)
|
|
19
|
+
num = 2 * phi - scale
|
|
20
|
+
out.append(num // (3 * scale) if num >= 3 * scale else 0)
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ip_cover(G, P, D, restrict=True, time_limit=None, cuts=(), params=None):
|
|
25
|
+
"""Move sequence covering demand D (dict), None if not coverable."""
|
|
26
|
+
n = G.n
|
|
27
|
+
if all(P[v] >= d for v, d in D.items()):
|
|
28
|
+
return ()
|
|
29
|
+
total = sum(P)
|
|
30
|
+
if restrict:
|
|
31
|
+
bound = firing_bounds(G, P)
|
|
32
|
+
else:
|
|
33
|
+
bound = [total // 2] * n
|
|
34
|
+
model = cp_model.CpModel()
|
|
35
|
+
f = {}
|
|
36
|
+
for u in range(n):
|
|
37
|
+
if bound[u] == 0:
|
|
38
|
+
continue
|
|
39
|
+
for w in G.adj[u]:
|
|
40
|
+
if restrict and D.get(w, 0) == 0 and bound[w] == 0:
|
|
41
|
+
continue
|
|
42
|
+
f[(u, w)] = model.NewIntVar(0, bound[u], f"f_{u}_{w}")
|
|
43
|
+
inflow = [[] for _ in range(n)]
|
|
44
|
+
outflow = [[] for _ in range(n)]
|
|
45
|
+
for (u, w), var in f.items():
|
|
46
|
+
outflow[u].append(var)
|
|
47
|
+
inflow[w].append(var)
|
|
48
|
+
for v in range(n):
|
|
49
|
+
need = D.get(v, 0)
|
|
50
|
+
if not inflow[v] and not outflow[v]:
|
|
51
|
+
if P[v] < need:
|
|
52
|
+
return None
|
|
53
|
+
continue
|
|
54
|
+
model.Add(P[v] + sum(inflow[v]) - 2 * sum(outflow[v]) >= need)
|
|
55
|
+
if f and "moves" in cuts:
|
|
56
|
+
model.Add(sum(f.values()) <= total - sum(D.values()))
|
|
57
|
+
if f and "potential" in cuts and len(D) == 1:
|
|
58
|
+
((X, need),) = D.items()
|
|
59
|
+
wX = G.weight[X]
|
|
60
|
+
phiX = sum(P[y] * wX[y] for y in range(n) if P[y])
|
|
61
|
+
model.Add(sum(var * (2 * wX[u] - wX[w]) for (u, w), var in f.items()) <= phiX - need * wX[X])
|
|
62
|
+
if "two_cycle" in cuts:
|
|
63
|
+
for (u, w), var in f.items():
|
|
64
|
+
if u < w and (w, u) in f:
|
|
65
|
+
b = model.NewBoolVar(f"dir_{u}_{w}")
|
|
66
|
+
model.Add(var == 0).OnlyEnforceIf(b)
|
|
67
|
+
model.Add(f[(w, u)] == 0).OnlyEnforceIf(b.Not())
|
|
68
|
+
solver = cp_model.CpSolver()
|
|
69
|
+
solver.parameters.num_workers = 1
|
|
70
|
+
if time_limit is not None:
|
|
71
|
+
solver.parameters.max_time_in_seconds = time_limit
|
|
72
|
+
if params:
|
|
73
|
+
for name, val in params.items():
|
|
74
|
+
setattr(solver.parameters, name, val)
|
|
75
|
+
status = solver.Solve(model)
|
|
76
|
+
if status == cp_model.INFEASIBLE:
|
|
77
|
+
return None
|
|
78
|
+
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|
79
|
+
raise IPUndecided(solver.StatusName(status))
|
|
80
|
+
F = {a: solver.Value(var) for a, var in f.items() if solver.Value(var) > 0}
|
|
81
|
+
moves = flow_to_moves(n, F)
|
|
82
|
+
Q = list(P)
|
|
83
|
+
for u, w in moves:
|
|
84
|
+
if Q[u] < 2 or w not in G.adj[u]:
|
|
85
|
+
raise AssertionError("IP certificate failed to replay")
|
|
86
|
+
Q[u] -= 2
|
|
87
|
+
Q[w] += 1
|
|
88
|
+
if any(Q[v] < d for v, d in D.items()):
|
|
89
|
+
raise AssertionError("IP certificate does not cover the demand")
|
|
90
|
+
return tuple(moves)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ip_reach(G, P, X, restrict=True, time_limit=None, cuts=(), params=None):
|
|
94
|
+
moves = ip_cover(G, P, {X: 1}, restrict=restrict, time_limit=time_limit, cuts=cuts, params=params)
|
|
95
|
+
if moves is not None and not replay(G.adj, P, moves, X):
|
|
96
|
+
raise AssertionError("IP certificate failed to replay")
|
|
97
|
+
return moves
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def flow_to_moves(n, F):
|
|
101
|
+
"""Cancel directed cycles in a balanced multiset, then list moves in topological order."""
|
|
102
|
+
F = dict(F)
|
|
103
|
+
while True:
|
|
104
|
+
cycle = _find_cycle(n, F)
|
|
105
|
+
if cycle is None:
|
|
106
|
+
break
|
|
107
|
+
c = min(F[a] for a in cycle)
|
|
108
|
+
for a in cycle:
|
|
109
|
+
F[a] -= c
|
|
110
|
+
if F[a] == 0:
|
|
111
|
+
del F[a]
|
|
112
|
+
indeg = [0] * n
|
|
113
|
+
succ = [[] for _ in range(n)]
|
|
114
|
+
for (u, w) in F:
|
|
115
|
+
succ[u].append(w)
|
|
116
|
+
indeg[w] += 1
|
|
117
|
+
order = [v for v in range(n) if indeg[v] == 0]
|
|
118
|
+
i = 0
|
|
119
|
+
while i < len(order):
|
|
120
|
+
v = order[i]
|
|
121
|
+
i += 1
|
|
122
|
+
for w in succ[v]:
|
|
123
|
+
indeg[w] -= 1
|
|
124
|
+
if indeg[w] == 0:
|
|
125
|
+
order.append(w)
|
|
126
|
+
if len(order) != n:
|
|
127
|
+
raise AssertionError("cycle cancellation left a cycle")
|
|
128
|
+
moves = []
|
|
129
|
+
for v in order:
|
|
130
|
+
for w in sorted(succ[v]):
|
|
131
|
+
moves.extend([(v, w)] * F[(v, w)])
|
|
132
|
+
return moves
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _find_cycle(n, F):
|
|
136
|
+
succ = [[] for _ in range(n)]
|
|
137
|
+
for (u, w) in F:
|
|
138
|
+
succ[u].append(w)
|
|
139
|
+
color = [0] * n # 0 new, 1 on stack, 2 done
|
|
140
|
+
parent = [-1] * n
|
|
141
|
+
for s in range(n):
|
|
142
|
+
if color[s]:
|
|
143
|
+
continue
|
|
144
|
+
stack = [(s, 0)]
|
|
145
|
+
color[s] = 1
|
|
146
|
+
while stack:
|
|
147
|
+
v, i = stack[-1]
|
|
148
|
+
if i < len(succ[v]):
|
|
149
|
+
stack[-1] = (v, i + 1)
|
|
150
|
+
w = succ[v][i]
|
|
151
|
+
if color[w] == 0:
|
|
152
|
+
color[w] = 1
|
|
153
|
+
parent[w] = v
|
|
154
|
+
stack.append((w, 0))
|
|
155
|
+
elif color[w] == 1:
|
|
156
|
+
cyc = [(v, w)]
|
|
157
|
+
x = v
|
|
158
|
+
while x != w:
|
|
159
|
+
cyc.append((parent[x], x))
|
|
160
|
+
x = parent[x]
|
|
161
|
+
return cyc
|
|
162
|
+
else:
|
|
163
|
+
color[v] = 2
|
|
164
|
+
stack.pop()
|
|
165
|
+
return None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""pi(G) and pi_opt(G) by exhaustive enumeration of distributions."""
|
|
2
|
+
from itertools import combinations
|
|
3
|
+
from math import comb
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def distributions(n, k):
|
|
7
|
+
"""All distributions of k pebbles on n vertices (stars and bars), each exactly once."""
|
|
8
|
+
if n == 1:
|
|
9
|
+
yield (k,)
|
|
10
|
+
return
|
|
11
|
+
last = k + n - 2
|
|
12
|
+
for bars in combinations(range(k + n - 1), n - 1):
|
|
13
|
+
out = []
|
|
14
|
+
prev = -1
|
|
15
|
+
for b in bars:
|
|
16
|
+
out.append(b - prev - 1)
|
|
17
|
+
prev = b
|
|
18
|
+
out.append(last - prev)
|
|
19
|
+
yield tuple(out)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def count_distributions(n, k):
|
|
23
|
+
return comb(k + n - 1, n - 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pebbling_number(n, is_solvable, k_max=10**9):
|
|
27
|
+
"""Pebbling number by scanning k upward; returns a dict with the value and witnesses."""
|
|
28
|
+
witness_below = tuple([0] * n) # the empty distribution is unsolvable
|
|
29
|
+
per_k = []
|
|
30
|
+
for k in range(1, k_max + 1):
|
|
31
|
+
checked = 0
|
|
32
|
+
found = None
|
|
33
|
+
for P in distributions(n, k):
|
|
34
|
+
checked += 1
|
|
35
|
+
if not is_solvable(P):
|
|
36
|
+
found = P
|
|
37
|
+
break
|
|
38
|
+
per_k.append({"k": k, "checked": checked, "unsolvable_example": found})
|
|
39
|
+
if found is None:
|
|
40
|
+
return {"value": k, "witness_below": witness_below, "checked_at_value": checked,
|
|
41
|
+
"total_at_value": count_distributions(n, k), "per_k": per_k}
|
|
42
|
+
witness_below = found
|
|
43
|
+
return {"value": None, "per_k": per_k}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def optimal_pebbling_number(n, is_solvable, k_max=10**9):
|
|
47
|
+
"""Optimal pebbling number by scanning k upward; returns a dict with the value and witnesses."""
|
|
48
|
+
per_k = []
|
|
49
|
+
for k in range(1, k_max + 1):
|
|
50
|
+
checked = 0
|
|
51
|
+
found = None
|
|
52
|
+
for P in distributions(n, k):
|
|
53
|
+
checked += 1
|
|
54
|
+
if is_solvable(P):
|
|
55
|
+
found = P
|
|
56
|
+
break
|
|
57
|
+
per_k.append({"k": k, "checked": checked, "solvable_example": found})
|
|
58
|
+
if found is not None:
|
|
59
|
+
below = per_k[-2]["checked"] if len(per_k) > 1 else 0
|
|
60
|
+
return {"value": k, "witness": found, "checked_all_unsolvable_at_k_minus_1": below,
|
|
61
|
+
"total_at_k_minus_1": count_distributions(n, k - 1) if k > 1 else 1,
|
|
62
|
+
"per_k": per_k}
|
|
63
|
+
return {"value": None, "per_k": per_k}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Path-specific reachability by carries."""
|
|
2
|
+
from fractions import Fraction
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def left_arrivals(P):
|
|
6
|
+
n = len(P)
|
|
7
|
+
A = [0] * n
|
|
8
|
+
carry = 0
|
|
9
|
+
for i in range(1, n):
|
|
10
|
+
carry = (P[i - 1] + carry) // 2
|
|
11
|
+
A[i] = carry
|
|
12
|
+
return A
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def right_arrivals(P):
|
|
16
|
+
n = len(P)
|
|
17
|
+
B = [0] * n
|
|
18
|
+
carry = 0
|
|
19
|
+
for i in range(n - 2, -1, -1):
|
|
20
|
+
carry = (P[i + 1] + carry) // 2
|
|
21
|
+
B[i] = carry
|
|
22
|
+
return B
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reachable(P):
|
|
26
|
+
A = left_arrivals(P)
|
|
27
|
+
B = right_arrivals(P)
|
|
28
|
+
return [P[i] + A[i] + B[i] >= 1 for i in range(len(P))]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def solvable(P):
|
|
32
|
+
return all(reachable(P))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def minimality_status(P):
|
|
36
|
+
"""'unsolvable', 'not_minimal' or 'minimal'; for 'minimal' also v -> an unreachable vertex of P - e_v."""
|
|
37
|
+
if not solvable(P):
|
|
38
|
+
return "unsolvable", None
|
|
39
|
+
witnesses = {}
|
|
40
|
+
Q = list(P)
|
|
41
|
+
for v, c in enumerate(P):
|
|
42
|
+
if c == 0:
|
|
43
|
+
continue
|
|
44
|
+
Q[v] -= 1
|
|
45
|
+
r = reachable(Q)
|
|
46
|
+
Q[v] += 1
|
|
47
|
+
bad = [i for i, ok in enumerate(r) if not ok]
|
|
48
|
+
if not bad:
|
|
49
|
+
return "not_minimal", v
|
|
50
|
+
witnesses[v] = bad[0]
|
|
51
|
+
return "minimal", witnesses
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def value(P, X):
|
|
55
|
+
return sum((Fraction(c, 2 ** abs(X - y)) for y, c in enumerate(P) if c), Fraction(0))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Decision procedure for large instances: backward search under a node budget, then CP-SAT."""
|
|
2
|
+
from .certify import replay
|
|
3
|
+
from .ipsolver import ip_reach
|
|
4
|
+
from .solver import Solver, Undecided
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Oracle:
|
|
8
|
+
def __init__(self, G, P, node_limit=20000, inherited_fail=(), ip_time_limit=None):
|
|
9
|
+
self.G = G
|
|
10
|
+
self.P = tuple(P)
|
|
11
|
+
self.bs = Solver(G, self.P, inherited_fail=inherited_fail, node_limit=node_limit)
|
|
12
|
+
self.phiP = self.bs.phiP
|
|
13
|
+
self.ip_time_limit = ip_time_limit
|
|
14
|
+
self.engine_counts = {"bs": 0, "ip": 0}
|
|
15
|
+
|
|
16
|
+
def reach(self, X):
|
|
17
|
+
"""(moves or None, engine)."""
|
|
18
|
+
try:
|
|
19
|
+
moves = self.bs.reach(X)
|
|
20
|
+
engine = "bs"
|
|
21
|
+
except Undecided:
|
|
22
|
+
moves = ip_reach(self.G, self.P, X, time_limit=self.ip_time_limit)
|
|
23
|
+
engine = "ip"
|
|
24
|
+
self.engine_counts[engine] += 1
|
|
25
|
+
if moves is not None and not replay(self.G.adj, self.P, moves, X):
|
|
26
|
+
raise AssertionError(f"certificate replay failed ({engine})")
|
|
27
|
+
return moves, engine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def solvability(G, P, parent_certs=None, inherited_fail=(), node_limit=20000, priority=None,
|
|
31
|
+
collect=None, ip_time_limit=None):
|
|
32
|
+
"""(True, certs, oracle) or (False, (X, engine), oracle)."""
|
|
33
|
+
o = Oracle(G, P, node_limit=node_limit, inherited_fail=inherited_fail, ip_time_limit=ip_time_limit)
|
|
34
|
+
certs = {}
|
|
35
|
+
pending = []
|
|
36
|
+
for X in range(G.n):
|
|
37
|
+
c = parent_certs.get(X) if parent_certs else None
|
|
38
|
+
if c is not None and replay(G.adj, o.P, c, X):
|
|
39
|
+
certs[X] = c
|
|
40
|
+
else:
|
|
41
|
+
pending.append(X)
|
|
42
|
+
if collect is not None:
|
|
43
|
+
collect.update(certs)
|
|
44
|
+
if priority is None:
|
|
45
|
+
pending.sort(key=lambda X: o.phiP[X])
|
|
46
|
+
else:
|
|
47
|
+
pending.sort(key=lambda X: (priority(X), o.phiP[X]))
|
|
48
|
+
for X in pending:
|
|
49
|
+
moves, engine = o.reach(X)
|
|
50
|
+
if moves is None:
|
|
51
|
+
return False, (X, engine), o
|
|
52
|
+
certs[X] = moves
|
|
53
|
+
if collect is not None:
|
|
54
|
+
collect[X] = moves
|
|
55
|
+
return True, certs, o
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def minimality(G, P, node_limit=20000, certs=None, fail_layers=(), ip_time_limit=None):
|
|
59
|
+
"""Same contract as solver.minimality; witnesses map v -> (unreachable X, engine)."""
|
|
60
|
+
ok, info, o = solvability(G, P, parent_certs=certs, inherited_fail=fail_layers,
|
|
61
|
+
node_limit=node_limit, ip_time_limit=ip_time_limit)
|
|
62
|
+
if not ok:
|
|
63
|
+
return {"status": "unsolvable", "unreachable": info[0], "engine": info[1]}
|
|
64
|
+
certs = info
|
|
65
|
+
layers = (o.bs.fail,) + tuple(fail_layers)
|
|
66
|
+
witnesses = {}
|
|
67
|
+
for v in range(G.n):
|
|
68
|
+
if P[v] == 0:
|
|
69
|
+
continue
|
|
70
|
+
P2 = list(P)
|
|
71
|
+
P2[v] -= 1
|
|
72
|
+
dv = G.dist[v]
|
|
73
|
+
ok2, info2, _ = solvability(G, P2, parent_certs=certs, inherited_fail=layers,
|
|
74
|
+
node_limit=node_limit, priority=lambda X: dv[X],
|
|
75
|
+
ip_time_limit=ip_time_limit)
|
|
76
|
+
if ok2:
|
|
77
|
+
return {"status": "not_minimal", "removable": v, "certs": certs}
|
|
78
|
+
witnesses[v] = info2
|
|
79
|
+
return {"status": "minimal", "certs": certs, "witnesses": witnesses}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Exact pebbling reachability, solvability and minimality; proofs in docs/solver_notes.md."""
|
|
2
|
+
import sys
|
|
3
|
+
from array import array
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
from operator import gt
|
|
6
|
+
|
|
7
|
+
from .certify import replay
|
|
8
|
+
|
|
9
|
+
sys.setrecursionlimit(max(sys.getrecursionlimit(), 50000))
|
|
10
|
+
|
|
11
|
+
FAIL_CACHE_CAP = 600_000 # clearing a memo only costs time; it never changes an answer
|
|
12
|
+
SUCC_CACHE_CAP = 100_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Undecided(Exception):
|
|
16
|
+
"""The node budget ran out; the answer is unknown."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def value(G, P, X):
|
|
20
|
+
"""v_P(X) as an exact Fraction, summed term by term from the definition."""
|
|
21
|
+
total = Fraction(0)
|
|
22
|
+
dX = G.dist[X]
|
|
23
|
+
for y, c in enumerate(P):
|
|
24
|
+
if c:
|
|
25
|
+
total += Fraction(c, 1 << dX[y])
|
|
26
|
+
return total
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def potential_scaled(G, P, X):
|
|
30
|
+
"""v_P(X) * 2**diam as an exact integer."""
|
|
31
|
+
w = G.weight[X]
|
|
32
|
+
return sum(c * w[y] for y, c in enumerate(P) if c)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _packed_tables(G, F):
|
|
36
|
+
cache = G.__dict__.setdefault("_packed_cache", {})
|
|
37
|
+
t = cache.get(F)
|
|
38
|
+
if t is None:
|
|
39
|
+
n = G.n
|
|
40
|
+
W = G.weight
|
|
41
|
+
packW = tuple(sum(W[v][y] << (F * y) for y in range(n)) for v in range(n))
|
|
42
|
+
packW2 = tuple(2 * p for p in packW)
|
|
43
|
+
H = sum(1 << (F * y + F - 1) for y in range(n))
|
|
44
|
+
t = (packW, packW2, H, (1 << F) - 1)
|
|
45
|
+
cache[F] = t
|
|
46
|
+
return t
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Solver:
|
|
50
|
+
"""Reachability oracle for one fixed distribution P (memo shared across targets)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, G, P, potential_prune=True, inherited_fail=(), node_limit=None,
|
|
53
|
+
potential_impl="packed"):
|
|
54
|
+
P = tuple(P)
|
|
55
|
+
if len(P) != G.n:
|
|
56
|
+
raise ValueError("distribution length does not match graph")
|
|
57
|
+
for c in P:
|
|
58
|
+
if not isinstance(c, int) or c < 0:
|
|
59
|
+
raise ValueError(f"bad pebble count {c!r}")
|
|
60
|
+
if potential_impl not in ("packed", "list"):
|
|
61
|
+
raise ValueError(potential_impl)
|
|
62
|
+
self.G = G
|
|
63
|
+
self.P = P
|
|
64
|
+
self.total = sum(P)
|
|
65
|
+
self.potential_prune = potential_prune
|
|
66
|
+
self.inherited_fail = tuple(inherited_fail)
|
|
67
|
+
self.node_limit = node_limit
|
|
68
|
+
self.fail = set()
|
|
69
|
+
self.succ = {}
|
|
70
|
+
self.nodes = 0
|
|
71
|
+
self.total_nodes = 0
|
|
72
|
+
self._adj = G.adj
|
|
73
|
+
self._W = G.weight
|
|
74
|
+
self._W2 = G.weight2
|
|
75
|
+
W = G.weight
|
|
76
|
+
support = [y for y, c in enumerate(P) if c]
|
|
77
|
+
self.phiP = [sum(P[y] * W[x][y] for y in support) for x in range(G.n)]
|
|
78
|
+
self._packed = potential_prune and potential_impl == "packed"
|
|
79
|
+
if self._packed:
|
|
80
|
+
F = ((self.total + 2) * G.scale).bit_length() + 2
|
|
81
|
+
packW, packW2, H, mask = _packed_tables(G, F)
|
|
82
|
+
self._F = F
|
|
83
|
+
self._mask = mask
|
|
84
|
+
self._pW = packW
|
|
85
|
+
self._pW2 = packW2
|
|
86
|
+
self._H = H
|
|
87
|
+
self._PH = sum(P[y] * packW[y] for y in support) + H
|
|
88
|
+
|
|
89
|
+
def reach(self, X):
|
|
90
|
+
"""A move sequence ((from, to), ...) that puts a pebble on X, or None if X is unreachable."""
|
|
91
|
+
self.nodes = 0
|
|
92
|
+
if self.P[X] >= 1:
|
|
93
|
+
return ()
|
|
94
|
+
if self._packed:
|
|
95
|
+
phiD = self._pW[X]
|
|
96
|
+
if (self._PH - phiD) & self._H != self._H:
|
|
97
|
+
return None
|
|
98
|
+
elif self.potential_prune:
|
|
99
|
+
phiD = list(self._W[X])
|
|
100
|
+
if any(map(gt, phiD, self.phiP)):
|
|
101
|
+
return None
|
|
102
|
+
else:
|
|
103
|
+
phiD = None
|
|
104
|
+
try:
|
|
105
|
+
return self._cover({X: 1}, 1, phiD, array("I", (X, 1)).tobytes())
|
|
106
|
+
finally:
|
|
107
|
+
self.total_nodes += self.nodes
|
|
108
|
+
|
|
109
|
+
def _add_fail(self, key):
|
|
110
|
+
if len(self.fail) >= FAIL_CACHE_CAP:
|
|
111
|
+
self.fail.clear()
|
|
112
|
+
self.fail.add(key)
|
|
113
|
+
|
|
114
|
+
def _cover(self, D, size, phiD, key):
|
|
115
|
+
P = self.P
|
|
116
|
+
deficits = []
|
|
117
|
+
deficit_total = 0
|
|
118
|
+
for v, d in D.items():
|
|
119
|
+
e = d - P[v]
|
|
120
|
+
if e > 0:
|
|
121
|
+
deficits.append(v)
|
|
122
|
+
deficit_total += e
|
|
123
|
+
if not deficits:
|
|
124
|
+
return ()
|
|
125
|
+
if size + deficit_total > self.total:
|
|
126
|
+
return None
|
|
127
|
+
cached = self.succ.get(key)
|
|
128
|
+
if cached is not None:
|
|
129
|
+
return cached
|
|
130
|
+
if key in self.fail:
|
|
131
|
+
return None
|
|
132
|
+
for layer in self.inherited_fail:
|
|
133
|
+
if key in layer:
|
|
134
|
+
return None
|
|
135
|
+
self.nodes += 1
|
|
136
|
+
if self.node_limit is not None and self.nodes > self.node_limit:
|
|
137
|
+
raise Undecided(f"node limit {self.node_limit} exceeded")
|
|
138
|
+
|
|
139
|
+
total = self.total
|
|
140
|
+
adj = self._adj
|
|
141
|
+
prune = self.potential_prune
|
|
142
|
+
packed = self._packed
|
|
143
|
+
phiP = self.phiP
|
|
144
|
+
if packed:
|
|
145
|
+
PH = self._PH
|
|
146
|
+
H = self._H
|
|
147
|
+
pW = self._pW
|
|
148
|
+
pW2 = self._pW2
|
|
149
|
+
else:
|
|
150
|
+
W = self._W
|
|
151
|
+
W2 = self._W2
|
|
152
|
+
|
|
153
|
+
best_v = -1
|
|
154
|
+
best_kids = None
|
|
155
|
+
for v in deficits:
|
|
156
|
+
kids = []
|
|
157
|
+
if packed:
|
|
158
|
+
pWv = pW[v]
|
|
159
|
+
elif prune:
|
|
160
|
+
Wv = W[v]
|
|
161
|
+
for u in adj[v]:
|
|
162
|
+
du = D.get(u, 0)
|
|
163
|
+
pu = P[u]
|
|
164
|
+
grow = (du + 2 - pu if du + 2 > pu else 0) - (du - pu if du > pu else 0)
|
|
165
|
+
# child has size+1 and deficit (deficit_total - 1 + grow)
|
|
166
|
+
if size + deficit_total + grow > total:
|
|
167
|
+
continue
|
|
168
|
+
if packed:
|
|
169
|
+
phi2 = phiD + pW2[u] - pWv
|
|
170
|
+
if (PH - phi2) & H != H:
|
|
171
|
+
continue
|
|
172
|
+
elif prune:
|
|
173
|
+
phi2 = [a + b - c for a, b, c in zip(phiD, W2[u], Wv)]
|
|
174
|
+
if any(map(gt, phi2, phiP)):
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
phi2 = None
|
|
178
|
+
kids.append((u, phi2))
|
|
179
|
+
if not kids:
|
|
180
|
+
self._add_fail(key)
|
|
181
|
+
return None
|
|
182
|
+
if best_kids is None or len(kids) < len(best_kids):
|
|
183
|
+
best_v, best_kids = v, kids
|
|
184
|
+
|
|
185
|
+
v = best_v
|
|
186
|
+
kids = best_kids
|
|
187
|
+
if len(kids) > 1:
|
|
188
|
+
if packed:
|
|
189
|
+
F = self._F
|
|
190
|
+
mask = self._mask
|
|
191
|
+
kids.sort(key=lambda t: (D.get(t[0], 0) - P[t[0]], ((t[1] >> (F * t[0])) & mask) - phiP[t[0]]))
|
|
192
|
+
elif prune:
|
|
193
|
+
kids.sort(key=lambda t: (D.get(t[0], 0) - P[t[0]], t[1][t[0]] - phiP[t[0]]))
|
|
194
|
+
else:
|
|
195
|
+
kids.sort(key=lambda t: (D.get(t[0], 0) - P[t[0]], -phiP[t[0]]))
|
|
196
|
+
dv = D[v]
|
|
197
|
+
for u, phi2 in kids:
|
|
198
|
+
D2 = dict(D)
|
|
199
|
+
if dv == 1:
|
|
200
|
+
del D2[v]
|
|
201
|
+
else:
|
|
202
|
+
D2[v] = dv - 1
|
|
203
|
+
D2[u] = D2.get(u, 0) + 2
|
|
204
|
+
key2 = array("I", [x for item in sorted(D2.items()) for x in item]).tobytes()
|
|
205
|
+
res = self._cover(D2, size + 1, phi2, key2)
|
|
206
|
+
if res is not None:
|
|
207
|
+
res = res + ((u, v),)
|
|
208
|
+
if len(self.succ) >= SUCC_CACHE_CAP:
|
|
209
|
+
self.succ.clear()
|
|
210
|
+
self.succ[key] = res
|
|
211
|
+
return res
|
|
212
|
+
self._add_fail(key)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def solvability(G, P, parent_certs=None, inherited_fail=(), potential_prune=True, node_limit=None,
|
|
217
|
+
potential_impl="packed"):
|
|
218
|
+
"""Decide whether every vertex is reachable from P."""
|
|
219
|
+
s = Solver(G, P, potential_prune=potential_prune, inherited_fail=inherited_fail,
|
|
220
|
+
node_limit=node_limit, potential_impl=potential_impl)
|
|
221
|
+
certs = {}
|
|
222
|
+
pending = []
|
|
223
|
+
for X in range(G.n):
|
|
224
|
+
c = parent_certs.get(X) if parent_certs else None
|
|
225
|
+
if c is not None and replay(G.adj, s.P, c, X):
|
|
226
|
+
certs[X] = c
|
|
227
|
+
else:
|
|
228
|
+
pending.append(X)
|
|
229
|
+
pending.sort(key=lambda X: s.phiP[X])
|
|
230
|
+
for X in pending:
|
|
231
|
+
c = s.reach(X)
|
|
232
|
+
if c is None:
|
|
233
|
+
return False, X, s
|
|
234
|
+
certs[X] = c
|
|
235
|
+
return True, certs, s
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def is_solvable(G, P, **kw):
|
|
239
|
+
return solvability(G, P, **kw)[0]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def reachable_set(G, P, **kw):
|
|
243
|
+
s = Solver(G, P, **kw)
|
|
244
|
+
return frozenset(X for X in range(G.n) if s.reach(X) is not None)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def minimality(G, P, potential_prune=True, node_limit=None, certs=None, fail_layers=(),
|
|
248
|
+
potential_impl="packed"):
|
|
249
|
+
"""Classify P as 'unsolvable', 'not_minimal' or 'minimal'."""
|
|
250
|
+
ok, info, s = solvability(G, P, parent_certs=certs, inherited_fail=fail_layers,
|
|
251
|
+
potential_prune=potential_prune, node_limit=node_limit,
|
|
252
|
+
potential_impl=potential_impl)
|
|
253
|
+
if not ok:
|
|
254
|
+
return {"status": "unsolvable", "unreachable": info}
|
|
255
|
+
certs = info
|
|
256
|
+
layers = (s.fail,) + tuple(fail_layers)
|
|
257
|
+
witnesses = {}
|
|
258
|
+
for v in range(G.n):
|
|
259
|
+
if P[v] == 0:
|
|
260
|
+
continue
|
|
261
|
+
P2 = list(P)
|
|
262
|
+
P2[v] -= 1
|
|
263
|
+
ok2, info2, _ = solvability(G, P2, parent_certs=certs, inherited_fail=layers,
|
|
264
|
+
potential_prune=potential_prune, node_limit=node_limit,
|
|
265
|
+
potential_impl=potential_impl)
|
|
266
|
+
if ok2:
|
|
267
|
+
return {"status": "not_minimal", "removable": v, "certs": certs}
|
|
268
|
+
witnesses[v] = info2
|
|
269
|
+
return {"status": "minimal", "certs": certs, "witnesses": witnesses}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypebbling
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Exact pebbling reachability and solvability on graphs, with replayable move certificates and an independent checker
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: ortools>=9.15
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.4; extra == "test"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pypebbling
|
|
15
|
+
|
|
16
|
+
Exact tools for graph pebbling: reachability, solvability and minimality of pebble distributions, with
|
|
17
|
+
move sequences anyone can replay.
|
|
18
|
+
|
|
19
|
+
A pebbling move removes two pebbles from a vertex and puts one pebble on a neighbouring vertex. A
|
|
20
|
+
distribution is solvable if every vertex can receive a pebble by some sequence of moves. The optimal pebbling
|
|
21
|
+
number of a graph is the smallest number of pebbles in a solvable distribution.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pypebbling
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package is imported as `pebbling`. It needs Python 3.12 or later and installs OR-Tools.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pebbling.graphs import grid_graph
|
|
35
|
+
from pebbling.solver import solvability
|
|
36
|
+
from pebbling.certify import replay
|
|
37
|
+
|
|
38
|
+
G = grid_graph(5, 5)
|
|
39
|
+
P = [0] * 25
|
|
40
|
+
for r, c in [(1, 1), (1, 3), (3, 1), (3, 3)]:
|
|
41
|
+
P[r * 5 + c] = 4
|
|
42
|
+
|
|
43
|
+
solvable, certs, _ = solvability(G, tuple(P))
|
|
44
|
+
print("solvable:", solvable)
|
|
45
|
+
|
|
46
|
+
moves = certs[0] # vertex (0, 0); vertex v is (v // 5, v % 5)
|
|
47
|
+
print("moves to (0, 0):", [(divmod(u, 5), divmod(w, 5)) for u, w in moves])
|
|
48
|
+
print("replay ok:", replay(G.adj, P, moves, 0))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What is inside
|
|
52
|
+
|
|
53
|
+
- `pebbling.solver`: exact backward search over demands, with exact integer potentials for pruning.
|
|
54
|
+
- `pebbling.ipsolver`: a second exact engine, an integer flow model solved with CP-SAT.
|
|
55
|
+
- `pebbling.certify`: replays move sequences.
|
|
56
|
+
- `naivecheck`: an independent naive checker, written separately from the solver.
|
|
57
|
+
|
|
58
|
+
Source code, proofs of correctness, the validation suite and a counterexample to a conjecture of Petr,
|
|
59
|
+
Portier and Stolarczyk are in the repository linked on this page.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
naivecheck/__init__.py
|
|
5
|
+
naivecheck/flowmodel.py
|
|
6
|
+
naivecheck/naive.py
|
|
7
|
+
pebbling/__init__.py
|
|
8
|
+
pebbling/certify.py
|
|
9
|
+
pebbling/graphs.py
|
|
10
|
+
pebbling/ipsolver.py
|
|
11
|
+
pebbling/numbers.py
|
|
12
|
+
pebbling/path.py
|
|
13
|
+
pebbling/portfolio.py
|
|
14
|
+
pebbling/solver.py
|
|
15
|
+
pypebbling.egg-info/PKG-INFO
|
|
16
|
+
pypebbling.egg-info/SOURCES.txt
|
|
17
|
+
pypebbling.egg-info/dependency_links.txt
|
|
18
|
+
pypebbling.egg-info/requires.txt
|
|
19
|
+
pypebbling.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pypebbling"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Exact pebbling reachability and solvability on graphs, with replayable move certificates and an independent checker"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = ["ortools>=9.15"]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=8.4"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
packages = ["pebbling", "naivecheck"]
|