ursa-ai 0.5.0__py3-none-any.whl → 0.6.0__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.

@@ -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