ursa-ai 0.5.0__py3-none-any.whl → 0.6.0rc2__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.
Potentially problematic release.
This version of ursa-ai might be problematic. Click here for more details.
- ursa/agents/arxiv_agent.py +77 -47
- ursa/agents/base.py +369 -2
- ursa/agents/execution_agent.py +92 -48
- ursa/agents/hypothesizer_agent.py +39 -42
- ursa/agents/lammps_agent.py +51 -29
- ursa/agents/mp_agent.py +45 -20
- ursa/agents/optimization_agent.py +403 -0
- ursa/agents/planning_agent.py +63 -28
- ursa/agents/rag_agent.py +75 -44
- ursa/agents/recall_agent.py +35 -5
- ursa/agents/websearch_agent.py +44 -54
- ursa/cli/__init__.py +127 -0
- ursa/cli/hitl.py +426 -0
- ursa/observability/pricing.py +319 -0
- ursa/observability/timing.py +1441 -0
- ursa/prompt_library/execution_prompts.py +7 -0
- ursa/prompt_library/optimization_prompts.py +131 -0
- ursa/tools/feasibility_checker.py +114 -0
- ursa/tools/feasibility_tools.py +1075 -0
- ursa/util/helperFunctions.py +142 -0
- ursa/util/optimization_schema.py +78 -0
- {ursa_ai-0.5.0.dist-info → ursa_ai-0.6.0rc2.dist-info}/METADATA +123 -4
- ursa_ai-0.6.0rc2.dist-info/RECORD +39 -0
- ursa_ai-0.6.0rc2.dist-info/entry_points.txt +2 -0
- ursa_ai-0.5.0.dist-info/RECORD +0 -28
- {ursa_ai-0.5.0.dist-info → ursa_ai-0.6.0rc2.dist-info}/WHEEL +0 -0
- {ursa_ai-0.5.0.dist-info → ursa_ai-0.6.0rc2.dist-info}/licenses/LICENSE +0 -0
- {ursa_ai-0.5.0.dist-info → ursa_ai-0.6.0rc2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified feasibility checker with heuristic pre-check and exact auto-routing.
|
|
3
|
+
|
|
4
|
+
Backends (imported lazily and used only if available):
|
|
5
|
+
- PySMT (cvc5/msat/yices/z3) for SMT-style logic, disjunctions, and nonlinear constructs.
|
|
6
|
+
- OR-Tools CP-SAT for strictly linear integer/boolean instances with integer coefficients.
|
|
7
|
+
- OR-Tools CBC (pywraplp) for linear MILP/LP (mixed real + integer, or pure LP).
|
|
8
|
+
- SciPy HiGHS (linprog) for pure continuous LP feasibility.
|
|
9
|
+
|
|
10
|
+
Install any subset you need:
|
|
11
|
+
pip install pysmt && pysmt-install --cvc5 # or --z3/--msat/--yices
|
|
12
|
+
pip install ortools
|
|
13
|
+
pip install scipy
|
|
14
|
+
pip install numpy
|
|
15
|
+
|
|
16
|
+
This file exposes a single LangChain tool: `feasibility_check_auto`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import random
|
|
21
|
+
from typing import Annotated, Any, Dict, List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import sympy as sp
|
|
24
|
+
from langchain_core.tools import tool
|
|
25
|
+
from sympy.core.relational import Relational
|
|
26
|
+
from sympy.parsing.sympy_parser import parse_expr, standard_transformations
|
|
27
|
+
|
|
28
|
+
# Optional deps — handled gracefully if not installed
|
|
29
|
+
try:
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
_HAS_NUMPY = True
|
|
33
|
+
except Exception:
|
|
34
|
+
_HAS_NUMPY = False
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from pysmt.shortcuts import GE as PS_GE
|
|
38
|
+
from pysmt.shortcuts import GT as PS_GT
|
|
39
|
+
from pysmt.shortcuts import LE as PS_LE
|
|
40
|
+
from pysmt.shortcuts import LT as PS_LT
|
|
41
|
+
from pysmt.shortcuts import And as PS_And
|
|
42
|
+
from pysmt.shortcuts import Bool as PS_Bool
|
|
43
|
+
from pysmt.shortcuts import Equals as PS_Eq
|
|
44
|
+
from pysmt.shortcuts import Int as PS_Int
|
|
45
|
+
from pysmt.shortcuts import Not as PS_Not
|
|
46
|
+
from pysmt.shortcuts import Or as PS_Or
|
|
47
|
+
from pysmt.shortcuts import Plus as PS_Plus
|
|
48
|
+
from pysmt.shortcuts import Real as PS_Real
|
|
49
|
+
from pysmt.shortcuts import Solver as PS_Solver
|
|
50
|
+
from pysmt.shortcuts import Symbol as PS_Symbol
|
|
51
|
+
from pysmt.shortcuts import Times as PS_Times
|
|
52
|
+
from pysmt.typing import BOOL as PS_BOOL
|
|
53
|
+
from pysmt.typing import INT as PS_INT
|
|
54
|
+
from pysmt.typing import REAL as PS_REAL
|
|
55
|
+
|
|
56
|
+
_HAS_PYSMT = True
|
|
57
|
+
except Exception:
|
|
58
|
+
_HAS_PYSMT = False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from ortools.sat.python import cp_model as _cpsat
|
|
62
|
+
|
|
63
|
+
_HAS_CPSAT = True
|
|
64
|
+
except Exception:
|
|
65
|
+
_HAS_CPSAT = False
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from ortools.linear_solver import pywraplp as _lp
|
|
69
|
+
|
|
70
|
+
_HAS_LP = True
|
|
71
|
+
except Exception:
|
|
72
|
+
_HAS_LP = False
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from scipy.optimize import linprog as _linprog
|
|
76
|
+
|
|
77
|
+
_HAS_SCIPY = True
|
|
78
|
+
except Exception:
|
|
79
|
+
_HAS_SCIPY = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# =========================
|
|
83
|
+
# Parsing & classification
|
|
84
|
+
# =========================
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_constraints(
|
|
88
|
+
constraints: List[str], variable_name: List[str]
|
|
89
|
+
) -> Tuple[List[sp.Symbol], List[sp.Expr]]:
|
|
90
|
+
"""Parse user constraint strings into SymPy expressions.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
constraints: Constraint strings (e.g., "x + 2*y <= 5").
|
|
94
|
+
variable_name: Names of variables referenced in constraints.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A tuple (symbols, sympy_constraints) where `symbols` are the SymPy symbols for
|
|
98
|
+
`variable_name` and `sympy_constraints` are parsed SymPy expressions.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
Exception: If SymPy fails to parse any constraint string.
|
|
102
|
+
"""
|
|
103
|
+
syms = sp.symbols(variable_name)
|
|
104
|
+
local_dict = {n: s for n, s in zip(variable_name, syms)}
|
|
105
|
+
sympy_cons = [
|
|
106
|
+
parse_expr(
|
|
107
|
+
c,
|
|
108
|
+
local_dict=local_dict,
|
|
109
|
+
transformations=standard_transformations,
|
|
110
|
+
evaluate=False,
|
|
111
|
+
)
|
|
112
|
+
for c in constraints
|
|
113
|
+
]
|
|
114
|
+
return syms, sympy_cons
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _flatten_conjunction(expr: sp.Expr) -> Tuple[List[sp.Expr], bool]:
|
|
118
|
+
"""Flatten a conjunction into a list of conjuncts.
|
|
119
|
+
|
|
120
|
+
If the expression is a chain of ANDs, returns all atomic conjuncts and `False`
|
|
121
|
+
for the non-conjunctive flag. Otherwise, returns [expr] and `True` if a non-AND
|
|
122
|
+
logical structure (e.g., OR/NOT) is detected.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
expr: A SymPy boolean/relational expression.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A tuple (conjuncts, nonconj) where `conjuncts` is a list of SymPy expressions
|
|
129
|
+
and `nonconj` is True if expr contains non-conjunctive logic (e.g., Or/Not).
|
|
130
|
+
"""
|
|
131
|
+
from sympy.logic.boolalg import And, Not, Or
|
|
132
|
+
|
|
133
|
+
if isinstance(expr, And):
|
|
134
|
+
out, stack = [], list(expr.args)
|
|
135
|
+
while stack:
|
|
136
|
+
a = stack.pop()
|
|
137
|
+
if isinstance(a, And):
|
|
138
|
+
stack.extend(a.args)
|
|
139
|
+
else:
|
|
140
|
+
out.append(a)
|
|
141
|
+
return out, False
|
|
142
|
+
|
|
143
|
+
is_rel = isinstance(expr, Relational)
|
|
144
|
+
if is_rel or expr in (sp.true, sp.false):
|
|
145
|
+
return [expr], False
|
|
146
|
+
if isinstance(expr, (Or, Not)):
|
|
147
|
+
return [expr], True
|
|
148
|
+
return [expr], True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _linear_relational(
|
|
152
|
+
expr: sp.Expr, symbols: List[sp.Symbol]
|
|
153
|
+
) -> Optional[bool]:
|
|
154
|
+
"""Check whether a relational constraint is linear in the given symbols.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
expr: SymPy expression (ideally a relational like <=, >=, ==, <, >).
|
|
158
|
+
symbols: The variables with respect to which linearity is checked.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if linear, False if nonlinear, or None if `expr` is not a relational.
|
|
162
|
+
|
|
163
|
+
Notes:
|
|
164
|
+
Any presence of non-polynomial functions (e.g., sin, abs) returns False.
|
|
165
|
+
"""
|
|
166
|
+
if not isinstance(expr, Relational):
|
|
167
|
+
return None
|
|
168
|
+
diff = sp.simplify(expr.lhs - expr.rhs)
|
|
169
|
+
try:
|
|
170
|
+
poly = sp.Poly(diff, *symbols, domain="QQ")
|
|
171
|
+
return poly.total_degree() <= 1
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _has_boolean_logic(expr: sp.Expr) -> bool:
|
|
177
|
+
"""Return True if the expression is a boolean combinator (And/Or/Not).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
expr: SymPy expression.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if expr is an instance of And/Or/Not; False otherwise.
|
|
184
|
+
"""
|
|
185
|
+
from sympy.logic.boolalg import And, Not, Or
|
|
186
|
+
|
|
187
|
+
return isinstance(expr, (And, Or, Not))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _classify(
|
|
191
|
+
sympy_cons: List[sp.Expr], symbols: List[sp.Symbol], vtypes: List[str]
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
"""Classify the problem structure for routing.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
sympy_cons: List of SymPy constraints.
|
|
197
|
+
symbols: Variable symbols.
|
|
198
|
+
vtypes: Variable types aligned with `symbols` (e.g., "real", "integer", "boolean").
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A dictionary with keys:
|
|
202
|
+
- requires_smt: True if non-conjunctive or boolean logic present.
|
|
203
|
+
- all_linear: True if all relational atoms are linear.
|
|
204
|
+
- has_int, has_bool, has_real: Presence flags for variable domains.
|
|
205
|
+
- only_conjunction: True if the top-level structure is pure conjunction.
|
|
206
|
+
"""
|
|
207
|
+
requires_smt, only_conj, linear_ok = False, True, True
|
|
208
|
+
for c in sympy_cons:
|
|
209
|
+
conjuncts, nonconj = _flatten_conjunction(c)
|
|
210
|
+
if nonconj:
|
|
211
|
+
requires_smt, only_conj = True, False
|
|
212
|
+
for a in conjuncts:
|
|
213
|
+
if _has_boolean_logic(a):
|
|
214
|
+
requires_smt = True
|
|
215
|
+
is_lin = _linear_relational(a, symbols)
|
|
216
|
+
if is_lin is False:
|
|
217
|
+
linear_ok = False
|
|
218
|
+
if is_lin is None and a not in (sp.true, sp.false):
|
|
219
|
+
requires_smt = True
|
|
220
|
+
|
|
221
|
+
vtypes_l = [t.lower() for t in vtypes]
|
|
222
|
+
return {
|
|
223
|
+
"requires_smt": requires_smt,
|
|
224
|
+
"all_linear": linear_ok,
|
|
225
|
+
"has_int": any(t in ("int", "integer") for t in vtypes_l),
|
|
226
|
+
"has_bool": any(t in ("bool", "boolean", "logical") for t in vtypes_l),
|
|
227
|
+
"has_real": any(
|
|
228
|
+
t in ("real", "float", "double", "continuous") for t in vtypes_l
|
|
229
|
+
),
|
|
230
|
+
"only_conjunction": only_conj,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _is_int_like(x: Optional[float], tol: float = 1e-9) -> bool:
|
|
235
|
+
"""Check if a value is (approximately) an integer.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
x: Value to test, possibly None.
|
|
239
|
+
tol: Absolute tolerance.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if x is within tol of an integer; False otherwise.
|
|
243
|
+
"""
|
|
244
|
+
return x is not None and abs(x - round(x)) <= tol
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _coeffs_linear(
|
|
248
|
+
expr: sp.Expr, symbols: List[sp.Symbol]
|
|
249
|
+
) -> Tuple[Dict[str, float], float]:
|
|
250
|
+
"""Extract linear coefficients and constant term of an expression.
|
|
251
|
+
|
|
252
|
+
The expression is interpreted as:
|
|
253
|
+
expr == sum_i coeff_i * x_i + const
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
expr: SymPy expression assumed linear in `symbols`.
|
|
257
|
+
symbols: Variable symbols.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
A tuple (coeffs, const), where `coeffs[name]` is the coefficient of the
|
|
261
|
+
variable `name`, and `const` is the constant term.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: If non-linearity is detected via second derivatives.
|
|
265
|
+
"""
|
|
266
|
+
coeffs: Dict[str, float] = {}
|
|
267
|
+
for s in symbols:
|
|
268
|
+
if sp.simplify(sp.diff(expr, s, 2)) != 0:
|
|
269
|
+
raise ValueError("Non-linear term detected.")
|
|
270
|
+
c = float(sp.N(sp.diff(expr, s)))
|
|
271
|
+
if abs(c) > 0.0:
|
|
272
|
+
coeffs[str(s)] = c
|
|
273
|
+
const = float(sp.N(expr.subs({s: 0 for s in symbols})))
|
|
274
|
+
return coeffs, const
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _all_int_coeffs(
|
|
278
|
+
coeffs: Dict[str, float], const: float, tol: float = 1e-9
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Return True if all coefficients and the constant are integer-like.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
coeffs: Mapping from variable name to coefficient.
|
|
284
|
+
const: Constant term.
|
|
285
|
+
tol: Integer-likeness tolerance.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if every coefficient and `const` is within `tol` of an integer.
|
|
289
|
+
"""
|
|
290
|
+
return all(_is_int_like(v, tol) for v in coeffs.values()) and _is_int_like(
|
|
291
|
+
const, tol
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# =========================
|
|
296
|
+
# Heuristic feasibility
|
|
297
|
+
# =========================
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _rand_unif(lo: Optional[float], hi: Optional[float], R: float) -> float:
|
|
301
|
+
"""Sample a uniform random real value within [lo, hi], with fallback radius.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
lo: Lower bound or None for unbounded.
|
|
305
|
+
hi: Upper bound or None for unbounded.
|
|
306
|
+
R: Fallback radius if a side is unbounded.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
A float sampled uniformly within the determined interval.
|
|
310
|
+
"""
|
|
311
|
+
lo = -R if lo is None or math.isinf(lo) else float(lo)
|
|
312
|
+
hi = R if hi is None or math.isinf(hi) else float(hi)
|
|
313
|
+
if lo > hi:
|
|
314
|
+
lo, hi = hi, lo
|
|
315
|
+
return random.uniform(lo, hi)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _rand_int(lo: Optional[float], hi: Optional[float], R: int) -> int:
|
|
319
|
+
"""Sample a uniform random integer within [lo, hi], with fallback radius.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
lo: Lower bound or None for unbounded.
|
|
323
|
+
hi: Upper bound or None for unbounded.
|
|
324
|
+
R: Fallback radius if a side is unbounded.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
An integer sampled uniformly within the determined interval.
|
|
328
|
+
"""
|
|
329
|
+
lo = -R if lo is None or math.isinf(lo) else int(math.floor(lo))
|
|
330
|
+
hi = R if hi is None or math.isinf(hi) else int(math.ceil(hi))
|
|
331
|
+
if lo > hi:
|
|
332
|
+
lo, hi = hi, lo
|
|
333
|
+
return random.randint(lo, hi)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _eval_relational(
|
|
337
|
+
lhs_num: float, rhs_num: float, rel_op: str, tol: float
|
|
338
|
+
) -> bool:
|
|
339
|
+
"""Evaluate a relational comparison with tolerance.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
lhs_num: Left-hand numeric value.
|
|
343
|
+
rhs_num: Right-hand numeric value.
|
|
344
|
+
rel_op: The relational operator string (one of '==','<=','<','>=','>').
|
|
345
|
+
tol: Numeric tolerance for equality/inequality strictness.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
True if relation holds under tolerance; False otherwise.
|
|
349
|
+
"""
|
|
350
|
+
d = lhs_num - rhs_num
|
|
351
|
+
if rel_op == "==":
|
|
352
|
+
return abs(d) <= tol
|
|
353
|
+
if rel_op == "<=":
|
|
354
|
+
return d <= tol
|
|
355
|
+
if rel_op == "<":
|
|
356
|
+
return d < -tol
|
|
357
|
+
if rel_op == ">=":
|
|
358
|
+
return d >= -tol
|
|
359
|
+
if rel_op == ">":
|
|
360
|
+
return d > tol
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _eval_bool_expr(e: sp.Expr, env: Dict[sp.Symbol, Any], tol: float) -> bool:
|
|
365
|
+
"""Evaluate a boolean/relational SymPy expression under an assignment.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
e: SymPy boolean/relational expression.
|
|
369
|
+
env: Mapping from symbol to Python numeric/bool value.
|
|
370
|
+
tol: Numeric tolerance for evaluating relational operators.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True if the expression is satisfied; False otherwise.
|
|
374
|
+
"""
|
|
375
|
+
# Relational
|
|
376
|
+
if isinstance(e, Relational):
|
|
377
|
+
lhs = float(sp.N(e.lhs.subs(env)))
|
|
378
|
+
rhs = float(sp.N(e.rhs.subs(env)))
|
|
379
|
+
return _eval_relational(lhs, rhs, e.rel_op, tol)
|
|
380
|
+
|
|
381
|
+
# Boolean logic
|
|
382
|
+
from sympy.logic.boolalg import And, Not, Or
|
|
383
|
+
|
|
384
|
+
if isinstance(e, And):
|
|
385
|
+
return all(_eval_bool_expr(a, env, tol) for a in e.args)
|
|
386
|
+
if isinstance(e, Or):
|
|
387
|
+
return any(_eval_bool_expr(a, env, tol) for a in e.args)
|
|
388
|
+
if isinstance(e, Not):
|
|
389
|
+
return not _eval_bool_expr(e.args[0], env, tol)
|
|
390
|
+
|
|
391
|
+
# Literals
|
|
392
|
+
if e is sp.true:
|
|
393
|
+
return True
|
|
394
|
+
if e is sp.false:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
# Fallback: cast numeric to bool (non-zero -> True). Not generally recommended.
|
|
398
|
+
try:
|
|
399
|
+
return bool(sp.N(e.subs(env)))
|
|
400
|
+
except Exception:
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _heuristic_feasible(
|
|
405
|
+
sympy_cons: List[sp.Expr],
|
|
406
|
+
symbols: List[sp.Symbol],
|
|
407
|
+
variable_name: List[str],
|
|
408
|
+
variable_type: List[str],
|
|
409
|
+
variable_bounds: List[List[Optional[float]]],
|
|
410
|
+
samples: int = 2000,
|
|
411
|
+
seed: Optional[int] = None,
|
|
412
|
+
tol: float = 1e-8,
|
|
413
|
+
unbounded_radius_real: float = 1e3,
|
|
414
|
+
unbounded_radius_int: int = 10**6,
|
|
415
|
+
) -> Optional[Dict[str, Any]]:
|
|
416
|
+
"""Try to find a satisfying assignment via randomized sampling.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
sympy_cons: Parsed SymPy constraints.
|
|
420
|
+
symbols: SymPy symbols aligned with `variable_name`.
|
|
421
|
+
variable_name: Variable names.
|
|
422
|
+
variable_type: Variable types aligned with `variable_name`.
|
|
423
|
+
variable_bounds: Per-variable [low, high] bounds (None means unbounded side).
|
|
424
|
+
samples: Number of random samples to try.
|
|
425
|
+
seed: Random seed for reproducibility.
|
|
426
|
+
tol: Tolerance for evaluating relational constraints.
|
|
427
|
+
unbounded_radius_real: Sampling radius for unbounded real variables.
|
|
428
|
+
unbounded_radius_int: Sampling radius for unbounded integer variables.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
A dict mapping variable names to sampled values if a witness is found; otherwise None.
|
|
432
|
+
|
|
433
|
+
Notes:
|
|
434
|
+
This does not prove infeasibility; it only returns a witness if one is found.
|
|
435
|
+
"""
|
|
436
|
+
if seed is not None:
|
|
437
|
+
random.seed(seed)
|
|
438
|
+
if _HAS_NUMPY:
|
|
439
|
+
np.random.seed(seed)
|
|
440
|
+
|
|
441
|
+
sym_by_name = {str(s): s for s in symbols}
|
|
442
|
+
|
|
443
|
+
for _ in range(samples):
|
|
444
|
+
env: Dict[sp.Symbol, Any] = {}
|
|
445
|
+
|
|
446
|
+
# Sample a point
|
|
447
|
+
for n, t, (lo, hi) in zip(
|
|
448
|
+
variable_name, variable_type, variable_bounds
|
|
449
|
+
):
|
|
450
|
+
t_l = t.lower()
|
|
451
|
+
if t_l in ("boolean", "bool", "logical"):
|
|
452
|
+
val = bool(random.getrandbits(1))
|
|
453
|
+
elif t_l in ("integer", "int"):
|
|
454
|
+
val = _rand_int(lo, hi, unbounded_radius_int)
|
|
455
|
+
else:
|
|
456
|
+
val = _rand_unif(lo, hi, unbounded_radius_real)
|
|
457
|
+
env[sym_by_name[n]] = val
|
|
458
|
+
|
|
459
|
+
# Check all constraints
|
|
460
|
+
ok = True
|
|
461
|
+
for c in sympy_cons:
|
|
462
|
+
if not _eval_bool_expr(c, env, tol):
|
|
463
|
+
ok = False
|
|
464
|
+
break
|
|
465
|
+
if ok:
|
|
466
|
+
return {n: env[sym_by_name[n]] for n in variable_name}
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# =========================
|
|
471
|
+
# Exact backends
|
|
472
|
+
# =========================
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _solve_with_pysmt(
|
|
476
|
+
sympy_cons: List[sp.Expr],
|
|
477
|
+
symbols: List[sp.Symbol],
|
|
478
|
+
variable_name: List[str],
|
|
479
|
+
variable_type: List[str],
|
|
480
|
+
variable_bounds: List[List[Optional[float]]],
|
|
481
|
+
solver_name: str = "cvc5",
|
|
482
|
+
) -> str:
|
|
483
|
+
"""Solve via PySMT (SMT backends like cvc5/msat/yices/z3).
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
sympy_cons: Parsed SymPy constraints (may include boolean logic).
|
|
487
|
+
symbols: SymPy symbols aligned with `variable_name`.
|
|
488
|
+
variable_name: Variable names.
|
|
489
|
+
variable_type: Variable types ("real", "integer", "boolean").
|
|
490
|
+
variable_bounds: Per-variable [low, high] bounds (None for unbounded).
|
|
491
|
+
solver_name: PySMT backend name ("cvc5", "msat", "yices", "z3").
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
A formatted string with status and (if SAT) an example model.
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
ValueError: If an unknown variable type is encountered.
|
|
498
|
+
"""
|
|
499
|
+
if not _HAS_PYSMT:
|
|
500
|
+
return "PySMT not installed. `pip install pysmt` and run `pysmt-install --cvc5` (or other backend)."
|
|
501
|
+
|
|
502
|
+
# Build PySMT symbols
|
|
503
|
+
ps_vars: Dict[str, Any] = {}
|
|
504
|
+
for n, t in zip(variable_name, variable_type):
|
|
505
|
+
t_l = t.lower()
|
|
506
|
+
if t_l in ("integer", "int"):
|
|
507
|
+
ps_vars[n] = PS_Symbol(n, PS_INT)
|
|
508
|
+
elif t_l in ("real", "float", "double", "continuous"):
|
|
509
|
+
ps_vars[n] = PS_Symbol(n, PS_REAL)
|
|
510
|
+
elif t_l in ("boolean", "bool", "logical"):
|
|
511
|
+
ps_vars[n] = PS_Symbol(n, PS_BOOL)
|
|
512
|
+
else:
|
|
513
|
+
raise ValueError(f"Unknown type: {t}")
|
|
514
|
+
|
|
515
|
+
sym2ps = {s: ps_vars[n] for n, s in zip(variable_name, symbols)}
|
|
516
|
+
|
|
517
|
+
def conv(e: sp.Expr):
|
|
518
|
+
"""Convert a SymPy expression to a PySMT node."""
|
|
519
|
+
if isinstance(e, sp.Symbol):
|
|
520
|
+
return sym2ps[e]
|
|
521
|
+
if isinstance(e, sp.Integer):
|
|
522
|
+
return PS_Int(int(e))
|
|
523
|
+
if isinstance(e, (sp.Rational, sp.Float)):
|
|
524
|
+
return PS_Real(float(e))
|
|
525
|
+
if isinstance(e, sp.Eq):
|
|
526
|
+
return PS_Eq(conv(e.lhs), conv(e.rhs))
|
|
527
|
+
if isinstance(e, sp.Le):
|
|
528
|
+
return PS_LE(conv(e.lhs), conv(e.rhs))
|
|
529
|
+
if isinstance(e, sp.Lt):
|
|
530
|
+
return PS_LT(conv(e.lhs), conv(e.rhs))
|
|
531
|
+
if isinstance(e, sp.Ge):
|
|
532
|
+
return PS_GE(conv(e.lhs), conv(e.rhs))
|
|
533
|
+
if isinstance(e, sp.Gt):
|
|
534
|
+
return PS_GT(conv(e.lhs), conv(e.rhs))
|
|
535
|
+
from sympy.logic.boolalg import And, Not, Or
|
|
536
|
+
|
|
537
|
+
if isinstance(e, And):
|
|
538
|
+
return PS_And(*[conv(a) for a in e.args])
|
|
539
|
+
if isinstance(e, Or):
|
|
540
|
+
return PS_Or(*[conv(a) for a in e.args])
|
|
541
|
+
if isinstance(e, Not):
|
|
542
|
+
return PS_Not(conv(e.args[0]))
|
|
543
|
+
if e is sp.true:
|
|
544
|
+
return PS_Bool(True)
|
|
545
|
+
if e is sp.false:
|
|
546
|
+
return PS_Bool(False)
|
|
547
|
+
if isinstance(e, sp.Add):
|
|
548
|
+
terms = [conv(a) for a in e.args]
|
|
549
|
+
out = terms[0]
|
|
550
|
+
for t in terms[1:]:
|
|
551
|
+
out = PS_Plus(out, t)
|
|
552
|
+
return out
|
|
553
|
+
if isinstance(e, sp.Mul):
|
|
554
|
+
terms = [conv(a) for a in e.args]
|
|
555
|
+
out = terms[0]
|
|
556
|
+
for t in terms[1:]:
|
|
557
|
+
out = PS_Times(out, t)
|
|
558
|
+
return out
|
|
559
|
+
raise ValueError(f"Unsupported function for PySMT conversion: {e}")
|
|
560
|
+
|
|
561
|
+
# Append bounds as assertions
|
|
562
|
+
ps_all = []
|
|
563
|
+
for (n, t), (lo, hi) in zip(
|
|
564
|
+
zip(variable_name, variable_type), variable_bounds
|
|
565
|
+
):
|
|
566
|
+
v = ps_vars[n]
|
|
567
|
+
t_l = t.lower()
|
|
568
|
+
if t_l in ("boolean", "bool", "logical"):
|
|
569
|
+
continue
|
|
570
|
+
if lo is not None:
|
|
571
|
+
ps_all.append(
|
|
572
|
+
PS_LE(
|
|
573
|
+
PS_Real(float(lo)) if t_l != "integer" else PS_Int(int(lo)),
|
|
574
|
+
v,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
if hi is not None:
|
|
578
|
+
ps_all.append(
|
|
579
|
+
PS_LE(
|
|
580
|
+
v,
|
|
581
|
+
PS_Real(float(hi)) if t_l != "integer" else PS_Int(int(hi)),
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
for c in sympy_cons:
|
|
585
|
+
ps_all.append(conv(c))
|
|
586
|
+
|
|
587
|
+
with PS_Solver(name=solver_name) as s:
|
|
588
|
+
if ps_all:
|
|
589
|
+
s.add_assertion(PS_And(ps_all))
|
|
590
|
+
res = s.solve()
|
|
591
|
+
if res:
|
|
592
|
+
model = {n: str(s.get_value(ps_vars[n])) for n in variable_name}
|
|
593
|
+
return f"[backend=pysmt:{solver_name}] Feasible. Example model: {model}"
|
|
594
|
+
return f"[backend=pysmt:{solver_name}] Infeasible."
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _solve_with_cpsat_integer_boolean(
|
|
598
|
+
conjuncts: List[sp.Expr],
|
|
599
|
+
symbols: List[sp.Symbol],
|
|
600
|
+
variable_name: List[str],
|
|
601
|
+
variable_type: List[str],
|
|
602
|
+
variable_bounds: List[List[Optional[float]]],
|
|
603
|
+
) -> str:
|
|
604
|
+
"""Solve linear integer/boolean feasibility via OR-Tools CP-SAT.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
conjuncts: A list of atomic conjuncts (linear relational constraints).
|
|
608
|
+
symbols: Variable symbols.
|
|
609
|
+
variable_name: Variable names.
|
|
610
|
+
variable_type: Variable types; only integer/boolean are supported here.
|
|
611
|
+
variable_bounds: Per-variable [low, high] bounds; None means unbounded side.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
A formatted string with status and example integer/boolean model if feasible.
|
|
615
|
+
|
|
616
|
+
Notes:
|
|
617
|
+
If non-integer coefficients/constants are detected, the function returns a
|
|
618
|
+
message requesting routing to MILP/LP instead (CBC branch).
|
|
619
|
+
"""
|
|
620
|
+
if not _HAS_CPSAT:
|
|
621
|
+
return "OR-Tools CP-SAT not installed. `pip install ortools`."
|
|
622
|
+
|
|
623
|
+
m = _cpsat.CpModel()
|
|
624
|
+
name_to_var: Dict[str, Any] = {}
|
|
625
|
+
|
|
626
|
+
# Create int/bool vars
|
|
627
|
+
for n, t, (lo, hi) in zip(variable_name, variable_type, variable_bounds):
|
|
628
|
+
t_l = t.lower()
|
|
629
|
+
if t_l in ("boolean", "bool", "logical"):
|
|
630
|
+
v = m.NewBoolVar(n)
|
|
631
|
+
else:
|
|
632
|
+
lo_i = int(lo) if lo is not None else -(10**9)
|
|
633
|
+
hi_i = int(hi) if hi is not None else 10**9
|
|
634
|
+
v = m.NewIntVar(lo_i, hi_i, n)
|
|
635
|
+
name_to_var[n] = v
|
|
636
|
+
|
|
637
|
+
# Add constraints
|
|
638
|
+
for c in conjuncts:
|
|
639
|
+
if isinstance(c, (sp.Eq, sp.Le, sp.Lt, sp.Ge, sp.Gt)):
|
|
640
|
+
diff = sp.simplify(c.lhs - c.rhs)
|
|
641
|
+
coeffs, const = _coeffs_linear(diff, symbols)
|
|
642
|
+
if not _all_int_coeffs(coeffs, const):
|
|
643
|
+
return "Detected non-integer coefficients/constant; routing to MILP/LP."
|
|
644
|
+
expr = sum(
|
|
645
|
+
int(round(coeffs.get(n, 0))) * name_to_var[n]
|
|
646
|
+
for n in variable_name
|
|
647
|
+
) + int(round(const))
|
|
648
|
+
if isinstance(c, sp.Eq):
|
|
649
|
+
m.Add(expr == 0)
|
|
650
|
+
elif isinstance(c, sp.Le):
|
|
651
|
+
m.Add(expr <= 0)
|
|
652
|
+
elif isinstance(c, sp.Ge):
|
|
653
|
+
m.Add(expr >= 0)
|
|
654
|
+
elif isinstance(c, sp.Lt):
|
|
655
|
+
m.Add(expr <= -1) # strict for integers
|
|
656
|
+
elif isinstance(c, sp.Gt):
|
|
657
|
+
m.Add(expr >= 1)
|
|
658
|
+
elif c is sp.true:
|
|
659
|
+
pass
|
|
660
|
+
elif c is sp.false:
|
|
661
|
+
m.Add(0 == 1)
|
|
662
|
+
else:
|
|
663
|
+
return "Non-relational/non-linear constraint; CP-SAT handles linear conjunctions only."
|
|
664
|
+
|
|
665
|
+
solver = _cpsat.CpSolver()
|
|
666
|
+
status = solver.Solve(m)
|
|
667
|
+
if status in (_cpsat.OPTIMAL, _cpsat.FEASIBLE):
|
|
668
|
+
model = {n: int(solver.Value(name_to_var[n])) for n in variable_name}
|
|
669
|
+
return f"[backend=cp-sat] Feasible. Example solution: {model}"
|
|
670
|
+
return "[backend=cp-sat] Infeasible."
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _solve_with_cbc_milp(
|
|
674
|
+
conjuncts: List[sp.Expr],
|
|
675
|
+
symbols: List[sp.Symbol],
|
|
676
|
+
variable_name: List[str],
|
|
677
|
+
variable_type: List[str],
|
|
678
|
+
variable_bounds: List[List[Optional[float]]],
|
|
679
|
+
) -> str:
|
|
680
|
+
"""Solve linear MILP/LP feasibility via OR-Tools CBC (pywraplp).
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
conjuncts: A list of atomic conjuncts (linear relational constraints).
|
|
684
|
+
symbols: Variable symbols.
|
|
685
|
+
variable_name: Variable names.
|
|
686
|
+
variable_type: Variable types; booleans will be modeled as {0,1} integers.
|
|
687
|
+
variable_bounds: Per-variable [low, high] bounds; None means unbounded side.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
A formatted string with status and example model if feasible, or UNSAT.
|
|
691
|
+
|
|
692
|
+
Raises:
|
|
693
|
+
RuntimeError: If the CBC solver cannot be created (bad OR-Tools install).
|
|
694
|
+
"""
|
|
695
|
+
if not _HAS_LP:
|
|
696
|
+
return (
|
|
697
|
+
"OR-Tools linear solver (CBC) not installed. `pip install ortools`."
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
solver = _lp.Solver.CreateSolver("CBC_MIXED_INTEGER_PROGRAMMING")
|
|
701
|
+
if solver is None:
|
|
702
|
+
return "Failed to create CBC solver. Ensure OR-Tools is properly installed."
|
|
703
|
+
|
|
704
|
+
var_objs: Dict[str, Any] = {}
|
|
705
|
+
for n, t, (lo, hi) in zip(variable_name, variable_type, variable_bounds):
|
|
706
|
+
t_l = t.lower()
|
|
707
|
+
lo_v = -_lp.Solver.infinity() if lo is None else float(lo)
|
|
708
|
+
hi_v = _lp.Solver.infinity() if hi is None else float(hi)
|
|
709
|
+
if t_l in ("boolean", "bool", "logical"):
|
|
710
|
+
var = solver.IntVar(0, 1, n)
|
|
711
|
+
elif t_l in ("integer", "int"):
|
|
712
|
+
var = solver.IntVar(math.floor(lo_v), math.ceil(hi_v), n)
|
|
713
|
+
else:
|
|
714
|
+
var = solver.NumVar(lo_v, hi_v, n)
|
|
715
|
+
var_objs[n] = var
|
|
716
|
+
|
|
717
|
+
eps = 1e-9
|
|
718
|
+
for c in conjuncts:
|
|
719
|
+
if isinstance(c, (sp.Eq, sp.Le, sp.Lt, sp.Ge, sp.Gt)):
|
|
720
|
+
diff = sp.simplify(c.lhs - c.rhs)
|
|
721
|
+
coeffs, const = _coeffs_linear(diff, symbols)
|
|
722
|
+
|
|
723
|
+
# Build bounds L <= sum(coeff*var) <= U, shifting by -const.
|
|
724
|
+
if isinstance(c, sp.Eq):
|
|
725
|
+
L = U = -const
|
|
726
|
+
elif isinstance(c, sp.Le):
|
|
727
|
+
L, U = -_lp.Solver.infinity(), -const
|
|
728
|
+
elif isinstance(c, sp.Ge):
|
|
729
|
+
L, U = -const, _lp.Solver.infinity()
|
|
730
|
+
elif isinstance(c, sp.Lt):
|
|
731
|
+
L, U = -_lp.Solver.infinity(), -const - eps
|
|
732
|
+
elif isinstance(c, sp.Gt):
|
|
733
|
+
L, U = -const + eps, _lp.Solver.infinity()
|
|
734
|
+
|
|
735
|
+
ct = solver.RowConstraint(L, U, "")
|
|
736
|
+
for n, v in var_objs.items():
|
|
737
|
+
ct.SetCoefficient(v, coeffs.get(n, 0.0))
|
|
738
|
+
|
|
739
|
+
elif c is sp.true:
|
|
740
|
+
pass
|
|
741
|
+
elif c is sp.false:
|
|
742
|
+
ct = solver.RowConstraint(1, _lp.Solver.infinity(), "")
|
|
743
|
+
else:
|
|
744
|
+
return "Non-relational or non-linear constraint encountered; CBC supports linear conjunctions only."
|
|
745
|
+
|
|
746
|
+
solver.Minimize(0)
|
|
747
|
+
status = solver.Solve()
|
|
748
|
+
if status in (_lp.Solver.OPTIMAL, _lp.Solver.FEASIBLE):
|
|
749
|
+
model: Dict[str, Any] = {}
|
|
750
|
+
int_like = {
|
|
751
|
+
n
|
|
752
|
+
for n, t in zip(variable_name, variable_type)
|
|
753
|
+
if t.lower() in ("integer", "int", "boolean", "bool", "logical")
|
|
754
|
+
}
|
|
755
|
+
for n, var in var_objs.items():
|
|
756
|
+
val = var.solution_value()
|
|
757
|
+
model[n] = int(round(val)) if n in int_like else float(val)
|
|
758
|
+
return f"[backend=cbc] Feasible. Example solution: {model}"
|
|
759
|
+
if status == _lp.Solver.INFEASIBLE:
|
|
760
|
+
return "[backend=cbc] Infeasible."
|
|
761
|
+
return f"[backend=cbc] Solver status: {status}"
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _solve_with_highs_lp(
|
|
765
|
+
conjuncts: List[sp.Expr],
|
|
766
|
+
symbols: List[sp.Symbol],
|
|
767
|
+
variable_name: List[str],
|
|
768
|
+
variable_bounds: List[List[Optional[float]]],
|
|
769
|
+
) -> str:
|
|
770
|
+
"""Solve pure continuous LP feasibility via SciPy HiGHS.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
conjuncts: A list of atomic conjuncts (linear relational constraints).
|
|
774
|
+
symbols: Variable symbols.
|
|
775
|
+
variable_name: Variable names (continuous).
|
|
776
|
+
variable_bounds: Per-variable [low, high] bounds; None means unbounded side.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
A formatted string with status and example model if feasible, or a failure message.
|
|
780
|
+
|
|
781
|
+
Notes:
|
|
782
|
+
This route supports only continuous variables and linear relational constraints.
|
|
783
|
+
"""
|
|
784
|
+
if not _HAS_SCIPY:
|
|
785
|
+
return "SciPy not installed. `pip install scipy`."
|
|
786
|
+
|
|
787
|
+
var_index = {n: i for i, n in enumerate(variable_name)}
|
|
788
|
+
A_ub, b_ub, A_eq, b_eq = [], [], [], []
|
|
789
|
+
eps = 1e-9
|
|
790
|
+
|
|
791
|
+
for c in conjuncts:
|
|
792
|
+
if not isinstance(c, (sp.Eq, sp.Le, sp.Lt, sp.Ge, sp.Gt)):
|
|
793
|
+
if c is sp.true:
|
|
794
|
+
continue
|
|
795
|
+
if c is sp.false:
|
|
796
|
+
return "[backend=highs] Infeasible."
|
|
797
|
+
return "Only linear relational constraints supported by LP route."
|
|
798
|
+
diff = sp.simplify(c.lhs - c.rhs)
|
|
799
|
+
coeffs, const = _coeffs_linear(diff, symbols)
|
|
800
|
+
row = [0.0] * len(variable_name)
|
|
801
|
+
for n, v in coeffs.items():
|
|
802
|
+
row[var_index[n]] = v
|
|
803
|
+
if isinstance(c, sp.Eq):
|
|
804
|
+
A_eq.append(row)
|
|
805
|
+
b_eq.append(-const)
|
|
806
|
+
elif isinstance(c, sp.Le):
|
|
807
|
+
A_ub.append(row)
|
|
808
|
+
b_ub.append(-const)
|
|
809
|
+
elif isinstance(c, sp.Ge):
|
|
810
|
+
A_ub.append([-v for v in row])
|
|
811
|
+
b_ub.append(const)
|
|
812
|
+
elif isinstance(c, sp.Lt):
|
|
813
|
+
A_ub.append(row)
|
|
814
|
+
b_ub.append(-const - eps)
|
|
815
|
+
elif isinstance(c, sp.Gt):
|
|
816
|
+
A_ub.append([-v for v in row])
|
|
817
|
+
b_ub.append(const - eps)
|
|
818
|
+
|
|
819
|
+
bounds = []
|
|
820
|
+
for lo, hi in variable_bounds:
|
|
821
|
+
lo_v = -math.inf if lo is None else float(lo)
|
|
822
|
+
hi_v = math.inf if hi is None else float(hi)
|
|
823
|
+
bounds.append((lo_v, hi_v))
|
|
824
|
+
|
|
825
|
+
import numpy as np
|
|
826
|
+
|
|
827
|
+
c = np.zeros(len(variable_name))
|
|
828
|
+
res = _linprog(
|
|
829
|
+
c,
|
|
830
|
+
A_ub=np.array(A_ub) if A_ub else None,
|
|
831
|
+
b_ub=np.array(b_ub) if b_ub else None,
|
|
832
|
+
A_eq=np.array(A_eq) if A_eq else None,
|
|
833
|
+
b_eq=np.array(b_eq) if b_eq else None,
|
|
834
|
+
bounds=bounds,
|
|
835
|
+
method="highs",
|
|
836
|
+
)
|
|
837
|
+
if res.success:
|
|
838
|
+
model = {n: float(res.x[i]) for i, n in enumerate(variable_name)}
|
|
839
|
+
return f"[backend=highs] Feasible. Example solution: {model}"
|
|
840
|
+
return f"[backend=highs] Infeasible or solver failed: {res.message}"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
# =========================
|
|
844
|
+
# Router tool (with heuristic)
|
|
845
|
+
# =========================
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@tool(parse_docstring=True)
|
|
849
|
+
def feasibility_check_auto(
|
|
850
|
+
constraints: Annotated[
|
|
851
|
+
List[str],
|
|
852
|
+
"Constraint strings like 'x0 + 2*x1 <= 5' or '(x0<=3) | (x1>=2)'",
|
|
853
|
+
],
|
|
854
|
+
variable_name: Annotated[List[str], "['x0','x1',...]"],
|
|
855
|
+
variable_type: Annotated[List[str], "['real'|'integer'|'boolean', ...]"],
|
|
856
|
+
variable_bounds: Annotated[
|
|
857
|
+
List[List[Optional[float]]],
|
|
858
|
+
"[(low, high), ...] (use None for unbounded)",
|
|
859
|
+
],
|
|
860
|
+
prefer_smt_solver: Annotated[
|
|
861
|
+
str, "SMT backend if needed: 'cvc5'|'msat'|'yices'|'z3'"
|
|
862
|
+
] = "cvc5",
|
|
863
|
+
heuristic_enabled: Annotated[
|
|
864
|
+
bool, "Run a fast randomized search first?"
|
|
865
|
+
] = True,
|
|
866
|
+
heuristic_first: Annotated[
|
|
867
|
+
bool, "Try heuristic before exact routing"
|
|
868
|
+
] = True,
|
|
869
|
+
heuristic_samples: Annotated[int, "Samples for heuristic search"] = 2000,
|
|
870
|
+
heuristic_seed: Annotated[Optional[int], "Seed for reproducibility"] = None,
|
|
871
|
+
heuristic_unbounded_radius_real: Annotated[
|
|
872
|
+
float, "Sampling range for unbounded real vars"
|
|
873
|
+
] = 1e3,
|
|
874
|
+
heuristic_unbounded_radius_int: Annotated[
|
|
875
|
+
int, "Sampling range for unbounded integer vars"
|
|
876
|
+
] = 10**6,
|
|
877
|
+
numeric_tolerance: Annotated[
|
|
878
|
+
float, "Tolerance for relational checks (Eq/Lt/Le/etc.)"
|
|
879
|
+
] = 1e-8,
|
|
880
|
+
) -> str:
|
|
881
|
+
"""Unified feasibility checker with heuristic pre-check and exact auto-routing.
|
|
882
|
+
|
|
883
|
+
Performs an optional randomized feasibility search. If no witness is found (or the
|
|
884
|
+
heuristic is disabled), the function auto-routes to an exact backend based on the
|
|
885
|
+
detected problem structure (PySMT for SMT/logic/nonlinear, OR-Tools CP-SAT for
|
|
886
|
+
linear integer/boolean, OR-Tools CBC for MILP/LP, or SciPy HiGHS for pure LP).
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
constraints: Constraint strings such as "x0 + 2*x1 <= 5" or "(x0<=3) | (x1>=2)".
|
|
890
|
+
variable_name: Variable names, e.g., ["x0", "x1"].
|
|
891
|
+
variable_type: Variable types aligned with `variable_name`. Each must be one of
|
|
892
|
+
"real", "integer", or "boolean".
|
|
893
|
+
variable_bounds: Per-variable [low, high] bounds aligned with `variable_name`.
|
|
894
|
+
Use None to denote an unbounded side.
|
|
895
|
+
prefer_smt_solver: SMT backend name used by PySMT ("cvc5", "msat", "yices", or "z3").
|
|
896
|
+
heuristic_enabled: Whether to run the heuristic sampler.
|
|
897
|
+
heuristic_first: If True, run the heuristic before exact routing; if False, run it after.
|
|
898
|
+
heuristic_samples: Number of heuristic samples.
|
|
899
|
+
heuristic_seed: Random seed for reproducibility.
|
|
900
|
+
heuristic_unbounded_radius_real: Sampling radius for unbounded real variables.
|
|
901
|
+
heuristic_unbounded_radius_int: Sampling radius for unbounded integer variables.
|
|
902
|
+
numeric_tolerance: Tolerance used in relational checks (e.g., Eq, Lt, Le).
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
A message indicating the chosen backend and the feasibility result. On success,
|
|
906
|
+
includes an example model (assignment). On infeasibility, includes a short
|
|
907
|
+
diagnostic or solver status.
|
|
908
|
+
|
|
909
|
+
Raises:
|
|
910
|
+
ValueError: If constraints cannot be parsed or an unsupported variable type is provided.
|
|
911
|
+
"""
|
|
912
|
+
# 1) Parse
|
|
913
|
+
try:
|
|
914
|
+
symbols, sympy_cons = _parse_constraints(constraints, variable_name)
|
|
915
|
+
except Exception as e:
|
|
916
|
+
return f"Parse error: {e}"
|
|
917
|
+
|
|
918
|
+
# 2) Heuristic (optional)
|
|
919
|
+
if heuristic_enabled and heuristic_first:
|
|
920
|
+
try:
|
|
921
|
+
h_model = _heuristic_feasible(
|
|
922
|
+
sympy_cons,
|
|
923
|
+
symbols,
|
|
924
|
+
variable_name,
|
|
925
|
+
variable_type,
|
|
926
|
+
variable_bounds,
|
|
927
|
+
samples=heuristic_samples,
|
|
928
|
+
seed=heuristic_seed,
|
|
929
|
+
tol=numeric_tolerance,
|
|
930
|
+
unbounded_radius_real=heuristic_unbounded_radius_real,
|
|
931
|
+
unbounded_radius_int=heuristic_unbounded_radius_int,
|
|
932
|
+
)
|
|
933
|
+
if h_model is not None:
|
|
934
|
+
return f"[backend=heuristic] Feasible (sampled witness). Example solution: {h_model}"
|
|
935
|
+
except Exception:
|
|
936
|
+
# Ignore heuristic issues and continue to exact route
|
|
937
|
+
pass
|
|
938
|
+
|
|
939
|
+
# 3) Classify & route
|
|
940
|
+
info = _classify(sympy_cons, symbols, variable_type)
|
|
941
|
+
|
|
942
|
+
# SMT needed or nonlinear / non-conj
|
|
943
|
+
if info["requires_smt"] or not info["all_linear"]:
|
|
944
|
+
res = _solve_with_pysmt(
|
|
945
|
+
sympy_cons,
|
|
946
|
+
symbols,
|
|
947
|
+
variable_name,
|
|
948
|
+
variable_type,
|
|
949
|
+
variable_bounds,
|
|
950
|
+
solver_name=prefer_smt_solver,
|
|
951
|
+
)
|
|
952
|
+
# Optional heuristic after exact if requested
|
|
953
|
+
if (
|
|
954
|
+
heuristic_enabled
|
|
955
|
+
and not heuristic_first
|
|
956
|
+
and any(
|
|
957
|
+
kw in res.lower()
|
|
958
|
+
for kw in ("unknown", "not installed", "unsupported", "failed")
|
|
959
|
+
)
|
|
960
|
+
):
|
|
961
|
+
h_model = _heuristic_feasible(
|
|
962
|
+
sympy_cons,
|
|
963
|
+
symbols,
|
|
964
|
+
variable_name,
|
|
965
|
+
variable_type,
|
|
966
|
+
variable_bounds,
|
|
967
|
+
samples=heuristic_samples,
|
|
968
|
+
seed=heuristic_seed,
|
|
969
|
+
tol=numeric_tolerance,
|
|
970
|
+
unbounded_radius_real=heuristic_unbounded_radius_real,
|
|
971
|
+
unbounded_radius_int=heuristic_unbounded_radius_int,
|
|
972
|
+
)
|
|
973
|
+
if h_model is not None:
|
|
974
|
+
return f"[backend=heuristic] Feasible (sampled witness). Example solution: {h_model}"
|
|
975
|
+
return res
|
|
976
|
+
|
|
977
|
+
# Linear-only path: collect atomic conjuncts
|
|
978
|
+
conjuncts: List[sp.Expr] = []
|
|
979
|
+
for c in sympy_cons:
|
|
980
|
+
atoms, _ = _flatten_conjunction(c)
|
|
981
|
+
conjuncts.extend(atoms)
|
|
982
|
+
|
|
983
|
+
has_int, has_bool, has_real = (
|
|
984
|
+
info["has_int"],
|
|
985
|
+
info["has_bool"],
|
|
986
|
+
info["has_real"],
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# Pure LP (continuous only)
|
|
990
|
+
if not has_int and not has_bool and has_real:
|
|
991
|
+
res = _solve_with_highs_lp(
|
|
992
|
+
conjuncts, symbols, variable_name, variable_bounds
|
|
993
|
+
)
|
|
994
|
+
if "not installed" in res.lower():
|
|
995
|
+
res = _solve_with_cbc_milp(
|
|
996
|
+
conjuncts,
|
|
997
|
+
symbols,
|
|
998
|
+
variable_name,
|
|
999
|
+
variable_type,
|
|
1000
|
+
variable_bounds,
|
|
1001
|
+
)
|
|
1002
|
+
if (
|
|
1003
|
+
heuristic_enabled
|
|
1004
|
+
and not heuristic_first
|
|
1005
|
+
and any(kw in res.lower() for kw in ("failed", "unknown"))
|
|
1006
|
+
):
|
|
1007
|
+
h_model = _heuristic_feasible(
|
|
1008
|
+
sympy_cons,
|
|
1009
|
+
symbols,
|
|
1010
|
+
variable_name,
|
|
1011
|
+
variable_type,
|
|
1012
|
+
variable_bounds,
|
|
1013
|
+
samples=heuristic_samples,
|
|
1014
|
+
seed=heuristic_seed,
|
|
1015
|
+
tol=numeric_tolerance,
|
|
1016
|
+
unbounded_radius_real=heuristic_unbounded_radius_real,
|
|
1017
|
+
unbounded_radius_int=heuristic_unbounded_radius_int,
|
|
1018
|
+
)
|
|
1019
|
+
if h_model is not None:
|
|
1020
|
+
return f"[backend=heuristic] Feasible (sampled witness). Example solution: {h_model}"
|
|
1021
|
+
return res
|
|
1022
|
+
|
|
1023
|
+
# All integer/boolean → CP-SAT first (if integer coefficients), else CBC MILP
|
|
1024
|
+
if (has_int or has_bool) and not has_real:
|
|
1025
|
+
res = _solve_with_cpsat_integer_boolean(
|
|
1026
|
+
conjuncts, symbols, variable_name, variable_type, variable_bounds
|
|
1027
|
+
)
|
|
1028
|
+
if (
|
|
1029
|
+
any(
|
|
1030
|
+
kw in res
|
|
1031
|
+
for kw in (
|
|
1032
|
+
"routing to MILP/LP",
|
|
1033
|
+
"handles linear conjunctions only",
|
|
1034
|
+
)
|
|
1035
|
+
)
|
|
1036
|
+
or "not installed" in res.lower()
|
|
1037
|
+
):
|
|
1038
|
+
res = _solve_with_cbc_milp(
|
|
1039
|
+
conjuncts,
|
|
1040
|
+
symbols,
|
|
1041
|
+
variable_name,
|
|
1042
|
+
variable_type,
|
|
1043
|
+
variable_bounds,
|
|
1044
|
+
)
|
|
1045
|
+
return res
|
|
1046
|
+
|
|
1047
|
+
# Mixed reals + integers → CBC MILP
|
|
1048
|
+
res = _solve_with_cbc_milp(
|
|
1049
|
+
conjuncts, symbols, variable_name, variable_type, variable_bounds
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# Optional heuristic after exact (if backend missing/failing)
|
|
1053
|
+
if (
|
|
1054
|
+
heuristic_enabled
|
|
1055
|
+
and not heuristic_first
|
|
1056
|
+
and any(
|
|
1057
|
+
kw in res.lower() for kw in ("not installed", "failed", "status:")
|
|
1058
|
+
)
|
|
1059
|
+
):
|
|
1060
|
+
h_model = _heuristic_feasible(
|
|
1061
|
+
sympy_cons,
|
|
1062
|
+
symbols,
|
|
1063
|
+
variable_name,
|
|
1064
|
+
variable_type,
|
|
1065
|
+
variable_bounds,
|
|
1066
|
+
samples=heuristic_samples,
|
|
1067
|
+
seed=heuristic_seed,
|
|
1068
|
+
tol=numeric_tolerance,
|
|
1069
|
+
unbounded_radius_real=heuristic_unbounded_radius_real,
|
|
1070
|
+
unbounded_radius_int=heuristic_unbounded_radius_int,
|
|
1071
|
+
)
|
|
1072
|
+
if h_model is not None:
|
|
1073
|
+
return f"[backend=heuristic] Feasible (sampled witness). Example solution: {h_model}"
|
|
1074
|
+
|
|
1075
|
+
return res
|