solvor 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solvor/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Solvor - Pure Python Optimization Solvers."""
2
+
3
+ from solvor.types import Status, Result
4
+ from solvor.simplex import solve_lp
5
+ from solvor.tabu import tabu_search, solve_tsp
6
+ from solvor.anneal import anneal
7
+ from solvor.milp import solve_milp
8
+ from solvor.sat import solve_sat
9
+ from solvor.cp import Model
10
+ from solvor.bayesian import bayesian_opt
11
+ from solvor.genetic import evolve
12
+ from solvor.flow import max_flow, min_cost_flow, solve_assignment
13
+ from solvor.gradient import gradient_descent, momentum, adam
14
+
15
+ __all__ = [
16
+ "solve_lp", "solve_milp", "tabu_search", "solve_tsp", "anneal",
17
+ "solve_sat", "Model", "bayesian_opt", "evolve",
18
+ "max_flow", "min_cost_flow", "solve_assignment",
19
+ "gradient_descent", "momentum", "adam",
20
+ "Status", "Result",
21
+ ]
solvor/anneal.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Simulated Annealing - Probabilistic Metaheuristic for Global Optimization
3
+
4
+ Escapes local optima by accepting worse solutions with probability exp(-delta/T).
5
+ Temperature T decreases over time following a cooling schedule, gradually shifting
6
+ from exploration (high T) to exploitation (low T).
7
+
8
+ Usage:
9
+ from solvor.anneal import anneal, Status
10
+ result = anneal(initial, objective_fn, neighbors)
11
+ result = anneal(initial, objective_fn, neighbors, minimize=False)
12
+
13
+ Parameters:
14
+ initial : Starting solution
15
+ objective_fn : solution -> float (value to minimize)
16
+ neighbors : solution -> new_solution (random neighbor generator)
17
+ minimize : True for min, False for max (default: True)
18
+ temperature : Starting temperature (default: 1000.0)
19
+ cooling : Multiplicative cooling factor per iteration (default: 0.9995)
20
+ min_temp : Stop when temperature falls below this (default: 1e-8)
21
+ max_iter : Maximum iterations (default: 100,000)
22
+
23
+ Returns Result(solution, objective, iterations, evaluations, status)
24
+
25
+ Parameter impact:
26
+ temperature too low -> trapped in local_optimum early
27
+ temperature too high -> random walk, slow convergence
28
+ cooling too fast -> insufficient exploration
29
+ cooling too slow -> wastes iterations at high temperature
30
+ """
31
+
32
+ from collections.abc import Callable
33
+ from math import exp
34
+ from random import random
35
+ from solvor.types import Status, Result
36
+
37
+ __all__ = ["anneal", "Status", "Result"]
38
+
39
+ def anneal[T](
40
+ initial: T,
41
+ objective_fn: Callable[[T], float],
42
+ neighbors: Callable[[T], T],
43
+ *,
44
+ minimize: bool = True,
45
+ temperature: float = 1000.0,
46
+ cooling: float = 0.9995,
47
+ min_temp: float = 1e-8,
48
+ max_iter: int = 100_000,
49
+ ) -> Result:
50
+ """(initial, objective_fn, neighbors, opts) -> Result with best_solution found."""
51
+ sign = 1 if minimize else -1
52
+ evals = 0
53
+
54
+ def evaluate(sol):
55
+ nonlocal evals
56
+ evals += 1
57
+ return sign * objective_fn(sol)
58
+
59
+ solution, obj = initial, evaluate(initial)
60
+ best_solution, best_obj = solution, obj
61
+
62
+ for iteration in range(1, max_iter + 1):
63
+ if temperature < min_temp:
64
+ break
65
+
66
+ neighbor = neighbors(solution)
67
+ neighbor_obj = evaluate(neighbor)
68
+ delta = neighbor_obj - obj
69
+
70
+ if delta < 0 or random() < exp(-delta / temperature):
71
+ solution, obj = neighbor, neighbor_obj
72
+
73
+ if obj < best_obj:
74
+ best_solution, best_obj = solution, obj
75
+
76
+ temperature *= cooling
77
+
78
+ final_obj = best_obj * sign
79
+ status = Status.MAX_ITER if iteration == max_iter else Status.FEASIBLE
80
+ return Result(best_solution, final_obj, iteration, evals, status)
solvor/bayesian.py ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ Bayesian Optimization - Gaussian Process-based Global Optimization
3
+
4
+ Optimizes expensive black-box functions using a Gaussian Process surrogate
5
+ model and Expected Improvement acquisition function. Ideal for hyperparameter
6
+ tuning where function evaluations are costly.
7
+
8
+ Usage:
9
+ from solvor.bayesian import bayesian_opt, Status
10
+ result = bayesian_opt(objective_fn, bounds=[(0, 1), (0, 1)], max_iter=50)
11
+ result = bayesian_opt(f, bounds, minimize=False) # maximize
12
+
13
+ Parameters:
14
+ objective_fn : x -> float (function to optimize, x is list of floats)
15
+ bounds : List of (low, high) tuples for each dimension
16
+ minimize : True for min, False for max (default: True)
17
+ max_iter : Number of iterations (default: 50)
18
+ n_initial : Random initial samples before GP (default: 5)
19
+ seed : Random seed (default: None)
20
+
21
+ Returns Result(solution, objective, iterations, evaluations, status)
22
+ solution = best input found (list of floats)
23
+ objective = best objective value
24
+
25
+ How it works:
26
+ 1. Sample n_initial random points
27
+ 2. Fit Gaussian Process to observations
28
+ 3. Find next point by maximizing Expected Improvement
29
+ 4. Evaluate objective at new point, repeat from step 2
30
+ """
31
+
32
+ from collections.abc import Callable, Sequence
33
+ from math import sqrt, exp, pi, erf
34
+ from random import Random
35
+ from solvor.types import Status, Result
36
+
37
+ __all__ = ["bayesian_opt", "Status", "Result"]
38
+
39
+ def bayesian_opt(
40
+ objective_fn: Callable[[Sequence[float]], float],
41
+ bounds: Sequence[tuple[float, float]],
42
+ *,
43
+ minimize: bool = True,
44
+ max_iter: int = 50,
45
+ n_initial: int = 5,
46
+ seed: int | None = None,
47
+ ) -> Result:
48
+ """(objective_fn, bounds, opts) -> Result with best solution found."""
49
+ rng = Random(seed)
50
+ sign = 1 if minimize else -1
51
+ n_dims = len(bounds)
52
+ evals = 0
53
+
54
+ def evaluate(x):
55
+ nonlocal evals
56
+ evals += 1
57
+ return sign * objective_fn(x)
58
+
59
+ def random_point():
60
+ return [rng.uniform(lo, hi) for lo, hi in bounds]
61
+
62
+ xs = [random_point() for _ in range(n_initial)]
63
+ ys = [evaluate(x) for x in xs]
64
+
65
+ best_idx = min(range(len(ys)), key=lambda i: ys[i])
66
+ best_x, best_y = xs[best_idx], ys[best_idx]
67
+
68
+ length_scales = [(hi - lo) / 2 for lo, hi in bounds]
69
+
70
+ def kernel(x1, x2):
71
+ sq_dist = sum(((a - b) / ls) ** 2 for a, b, ls in zip(x1, x2, length_scales))
72
+ return exp(-0.5 * sq_dist)
73
+
74
+ def gp_predict(x_new, X, Y, noise=1e-6):
75
+ n = len(X)
76
+ if n == 0:
77
+ return 0.0, 1.0
78
+
79
+ K = [[kernel(X[i], X[j]) + (noise if i == j else 0) for j in range(n)] for i in range(n)]
80
+ k_star = [kernel(x_new, X[i]) for i in range(n)]
81
+
82
+ K_inv_y = _solve_linear(K, Y)
83
+ K_inv_k = _solve_linear(K, k_star)
84
+
85
+ mu = sum(k_star[i] * K_inv_y[i] for i in range(n))
86
+ var = kernel(x_new, x_new) - sum(k_star[i] * K_inv_k[i] for i in range(n))
87
+ var = max(var, 1e-10)
88
+
89
+ return mu, sqrt(var)
90
+
91
+ def expected_improvement(x, X, Y, best_y, xi=0.01):
92
+ mu, sigma = gp_predict(x, X, Y)
93
+ if sigma < 1e-10:
94
+ return 0.0
95
+
96
+ z = (best_y - mu - xi) / sigma
97
+ ei = (best_y - mu - xi) * _norm_cdf(z) + sigma * _norm_pdf(z)
98
+ return ei
99
+
100
+ for iteration in range(n_initial, max_iter):
101
+ best_ei, best_candidate = -float('inf'), None
102
+
103
+ for _ in range(100 * n_dims):
104
+ candidate = random_point()
105
+ ei = expected_improvement(candidate, xs, ys, best_y)
106
+ if ei > best_ei:
107
+ best_ei, best_candidate = ei, candidate
108
+
109
+ if best_candidate is None:
110
+ best_candidate = random_point()
111
+
112
+ y_new = evaluate(best_candidate)
113
+ xs.append(best_candidate)
114
+ ys.append(y_new)
115
+
116
+ if y_new < best_y:
117
+ best_x, best_y = best_candidate, y_new
118
+
119
+ final_obj = best_y * sign
120
+ return Result(best_x, final_obj, max_iter, evals, Status.FEASIBLE)
121
+
122
+ def _solve_linear(A, b):
123
+ n = len(A)
124
+ aug = [row[:] + [b[i]] for i, row in enumerate(A)]
125
+
126
+ for col in range(n):
127
+ max_row = max(range(col, n), key=lambda r: abs(aug[r][col]))
128
+ aug[col], aug[max_row] = aug[max_row], aug[col]
129
+
130
+ pivot = aug[col][col]
131
+ if abs(pivot) < 1e-12:
132
+ continue
133
+
134
+ for j in range(col, n + 1):
135
+ aug[col][j] /= pivot
136
+
137
+ for row in range(n):
138
+ if row != col:
139
+ factor = aug[row][col]
140
+ for j in range(col, n + 1):
141
+ aug[row][j] -= factor * aug[col][j]
142
+
143
+ return [aug[i][n] for i in range(n)]
144
+
145
+ def _norm_pdf(x):
146
+ return exp(-0.5 * x * x) / sqrt(2 * pi)
147
+
148
+ def _norm_cdf(x):
149
+ return 0.5 * (1 + erf(x / sqrt(2)))
solvor/cp.py ADDED
@@ -0,0 +1,289 @@
1
+ """
2
+ CP-SAT Solver - Constraint Programming via SAT Encoding
3
+
4
+ Solves constraint satisfaction problems by encoding integer variables and
5
+ constraints into SAT clauses, then solving with the SAT backend.
6
+
7
+ Usage:
8
+ from solvor.cp import Model, Status
9
+ m = Model()
10
+ x = m.int_var(0, 9, 'x')
11
+ y = m.int_var(0, 9, 'y')
12
+ m.add(m.all_different([x, y]))
13
+ m.add(x + y == 10)
14
+ result = m.solve()
15
+ print(result.solution) # {'x': 3, 'y': 7} or similar
16
+
17
+ Supported constraints:
18
+ all_different(vars) - All variables take distinct values
19
+ sum(vars) == k - Sum equals constant
20
+ sum(vars) <= k - Sum at most constant
21
+ sum(vars) >= k - Sum at least constant
22
+ var == k - Variable equals constant
23
+ var != k - Variable not equal constant
24
+ var1 == var2 - Variables equal
25
+ var1 != var2 - Variables different
26
+
27
+ Returns Result(solution, objective, iterations, evaluations, status)
28
+ solution = dict mapping variable names to values
29
+ objective = 0 (satisfaction only, no optimization)
30
+ """
31
+
32
+ from itertools import combinations
33
+ from solvor.sat import solve_sat, Status as SATStatus
34
+ from solvor.types import Status, Result
35
+
36
+ __all__ = ["Model", "Status", "Result"]
37
+
38
+ class IntVar:
39
+ def __init__(self, model, lb, ub, name):
40
+ self.model = model
41
+ self.lb = lb
42
+ self.ub = ub
43
+ self.name = name
44
+ self.bool_vars = {}
45
+ for v in range(lb, ub + 1):
46
+ self.bool_vars[v] = model._new_bool_var()
47
+
48
+ def __eq__(self, other):
49
+ if isinstance(other, int):
50
+ return ('eq_const', self, other)
51
+ if isinstance(other, IntVar):
52
+ return ('eq_var', self, other)
53
+ return NotImplemented
54
+
55
+ def __ne__(self, other):
56
+ if isinstance(other, int):
57
+ return ('ne_const', self, other)
58
+ if isinstance(other, IntVar):
59
+ return ('ne_var', self, other)
60
+ return NotImplemented
61
+
62
+ def __add__(self, other):
63
+ return ('add', self, other)
64
+
65
+ def __radd__(self, other):
66
+ return ('add', other, self)
67
+
68
+ class Model:
69
+ def __init__(self):
70
+ self._next_bool = 1
71
+ self._vars = {}
72
+ self._constraints = []
73
+ self._clauses = []
74
+
75
+ def _new_bool_var(self):
76
+ v = self._next_bool
77
+ self._next_bool += 1
78
+ return v
79
+
80
+ def int_var(self, lb, ub, name=None):
81
+ """(lb, ub, name) -> IntVar with domain [lb, ub]."""
82
+ if name is None:
83
+ name = f"_v{len(self._vars)}"
84
+ var = IntVar(self, lb, ub, name)
85
+ self._vars[name] = var
86
+ return var
87
+
88
+ def all_different(self, variables):
89
+ """(vars) -> constraint that all variables take different values."""
90
+ return ('all_different', tuple(variables))
91
+
92
+ def add(self, constraint):
93
+ """(constraint) -> add constraint to model."""
94
+ self._constraints.append(constraint)
95
+
96
+ def _encode_exactly_one(self, lits):
97
+ if not lits:
98
+ return
99
+ self._clauses.append(lits)
100
+ for a, b in combinations(lits, 2):
101
+ self._clauses.append([-a, -b])
102
+
103
+ def _encode_at_most_one(self, lits):
104
+ for a, b in combinations(lits, 2):
105
+ self._clauses.append([-a, -b])
106
+
107
+ def _encode_vars(self):
108
+ for var in self._vars.values():
109
+ lits = [var.bool_vars[v] for v in range(var.lb, var.ub + 1)]
110
+ self._encode_exactly_one(lits)
111
+
112
+ def _encode_all_different(self, variables):
113
+ all_vals = set()
114
+ for var in variables:
115
+ all_vals.update(range(var.lb, var.ub + 1))
116
+
117
+ for val in all_vals:
118
+ lits = []
119
+ for var in variables:
120
+ if val in var.bool_vars:
121
+ lits.append(var.bool_vars[val])
122
+ if len(lits) > 1:
123
+ self._encode_at_most_one(lits)
124
+
125
+ def _encode_eq_const(self, var, val):
126
+ if val in var.bool_vars:
127
+ self._clauses.append([var.bool_vars[val]])
128
+ else:
129
+ self._clauses.append([])
130
+
131
+ def _encode_ne_const(self, var, val):
132
+ if val in var.bool_vars:
133
+ self._clauses.append([-var.bool_vars[val]])
134
+
135
+ def _encode_eq_var(self, var1, var2):
136
+ common = set(var1.bool_vars.keys()) & set(var2.bool_vars.keys())
137
+ for val in common:
138
+ self._clauses.append([-var1.bool_vars[val], var2.bool_vars[val]])
139
+ self._clauses.append([var1.bool_vars[val], -var2.bool_vars[val]])
140
+
141
+ for val in set(var1.bool_vars.keys()) - common:
142
+ self._clauses.append([-var1.bool_vars[val]])
143
+ for val in set(var2.bool_vars.keys()) - common:
144
+ self._clauses.append([-var2.bool_vars[val]])
145
+
146
+ def _encode_ne_var(self, var1, var2):
147
+ common = set(var1.bool_vars.keys()) & set(var2.bool_vars.keys())
148
+ for val in common:
149
+ self._clauses.append([-var1.bool_vars[val], -var2.bool_vars[val]])
150
+
151
+ def _flatten_sum(self, expr):
152
+ terms = []
153
+ const = 0
154
+
155
+ def flatten(e):
156
+ nonlocal const
157
+ if isinstance(e, IntVar):
158
+ terms.append(e)
159
+ elif isinstance(e, int):
160
+ const += e
161
+ elif isinstance(e, tuple) and e[0] == 'add':
162
+ flatten(e[1])
163
+ flatten(e[2])
164
+
165
+ flatten(expr)
166
+ return terms, const
167
+
168
+ def _encode_sum_eq(self, variables, target):
169
+ n = len(variables)
170
+ if n == 0:
171
+ if target != 0:
172
+ self._clauses.append([])
173
+ return
174
+
175
+ min_sum = sum(v.lb for v in variables)
176
+ max_sum = sum(v.ub for v in variables)
177
+
178
+ if target < min_sum or target > max_sum:
179
+ self._clauses.append([])
180
+ return
181
+
182
+ if n == 1:
183
+ self._encode_eq_const(variables[0], target)
184
+ return
185
+
186
+ if n == 2:
187
+ v1, v2 = variables
188
+ for val1 in range(v1.lb, v1.ub + 1):
189
+ val2 = target - val1
190
+ if val2 < v2.lb or val2 > v2.ub:
191
+ self._clauses.append([-v1.bool_vars[val1]])
192
+ else:
193
+ self._clauses.append([-v1.bool_vars[val1], v2.bool_vars[val2]])
194
+ return
195
+
196
+ aux = self.int_var(variables[0].lb + variables[1].lb,
197
+ variables[0].ub + variables[1].ub)
198
+
199
+ for v1 in range(variables[0].lb, variables[0].ub + 1):
200
+ for v2 in range(variables[1].lb, variables[1].ub + 1):
201
+ s = v1 + v2
202
+ if s in aux.bool_vars:
203
+ self._clauses.append([-variables[0].bool_vars[v1],
204
+ -variables[1].bool_vars[v2],
205
+ aux.bool_vars[s]])
206
+
207
+ self._encode_sum_eq([aux] + list(variables[2:]), target)
208
+
209
+ def _encode_constraint(self, constraint):
210
+ if isinstance(constraint, tuple):
211
+ kind = constraint[0]
212
+
213
+ if kind == 'all_different':
214
+ self._encode_all_different(constraint[1])
215
+
216
+ elif kind == 'eq_const':
217
+ self._encode_eq_const(constraint[1], constraint[2])
218
+
219
+ elif kind == 'ne_const':
220
+ self._encode_ne_const(constraint[1], constraint[2])
221
+
222
+ elif kind == 'eq_var':
223
+ self._encode_eq_var(constraint[1], constraint[2])
224
+
225
+ elif kind == 'ne_var':
226
+ self._encode_ne_var(constraint[1], constraint[2])
227
+
228
+ elif kind == 'add':
229
+ terms, const = self._flatten_sum(constraint)
230
+ if len(terms) == 0:
231
+ if const != 0:
232
+ self._clauses.append([])
233
+ else:
234
+ pass
235
+
236
+ elif kind == 'sum_eq':
237
+ self._encode_sum_eq(list(constraint[1]), constraint[2])
238
+
239
+ elif kind == 'sum_le':
240
+ terms, target = constraint[1], constraint[2]
241
+ aux = self.int_var(sum(v.lb for v in terms), min(sum(v.ub for v in terms), target))
242
+ self._encode_sum_eq(list(terms), aux)
243
+
244
+ elif kind == 'sum_ge':
245
+ terms, target = constraint[1], constraint[2]
246
+ aux = self.int_var(max(sum(v.lb for v in terms), target), sum(v.ub for v in terms))
247
+ self._encode_sum_eq(list(terms), aux)
248
+
249
+ def sum_eq(self, variables, target):
250
+ """(vars, k) -> constraint that sum of vars equals k."""
251
+ return ('sum_eq', tuple(variables), target)
252
+
253
+ def sum_le(self, variables, target):
254
+ """(vars, k) -> constraint that sum of vars <= k."""
255
+ return ('sum_le', tuple(variables), target)
256
+
257
+ def sum_ge(self, variables, target):
258
+ """(vars, k) -> constraint that sum of vars >= k."""
259
+ return ('sum_ge', tuple(variables), target)
260
+
261
+ def solve(self, **kwargs):
262
+ """() -> Result with solution or UNSAT."""
263
+ self._clauses = []
264
+ self._encode_vars()
265
+
266
+ for constraint in self._constraints:
267
+ self._encode_constraint(constraint)
268
+
269
+ if any(len(c) == 0 for c in self._clauses):
270
+ return Result(None, 0, 0, 0, Status.INFEASIBLE)
271
+
272
+ sat_result = solve_sat(self._clauses, **kwargs)
273
+
274
+ if sat_result.status == SATStatus.INFEASIBLE:
275
+ return Result(None, 0, sat_result.iterations, sat_result.evaluations, Status.INFEASIBLE)
276
+
277
+ if sat_result.status == SATStatus.MAX_ITER:
278
+ return Result(None, 0, sat_result.iterations, sat_result.evaluations, Status.MAX_ITER)
279
+
280
+ solution = {}
281
+ for name, var in self._vars.items():
282
+ if name.startswith('_'):
283
+ continue
284
+ for val, bool_var in var.bool_vars.items():
285
+ if sat_result.solution.get(bool_var, False):
286
+ solution[name] = val
287
+ break
288
+
289
+ return Result(solution, 0, sat_result.iterations, sat_result.evaluations, Status.OPTIMAL)