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.
@@ -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,4 @@
1
+ ortools>=9.15
2
+
3
+ [test]
4
+ pytest>=8.4
@@ -0,0 +1,2 @@
1
+ naivecheck
2
+ pebbling
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+