solverpilot 0.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.
Files changed (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Iterable, Mapping
4
+ from .ir import *
5
+
6
+ @dataclass(frozen=True, slots=True, eq=False)
7
+ class CPIntVar:
8
+ model:'CPModel'; var_id:int; name:str; domain:tuple[int,...]
9
+ def _e(self): return CPLinearExpr(self.model,{self.var_id:1},0)
10
+ def __add__(self,o): return self._e()+o
11
+ def __radd__(self,o): return self._e()+o
12
+ def __sub__(self,o): return self._e()-o
13
+ def __rsub__(self,o): return (-self._e())+o
14
+ def __mul__(self,o): return self._e()*o
15
+ def __rmul__(self,o): return self._e()*o
16
+ def __neg__(self): return -self._e()
17
+ def __le__(self,o): return self._e()<=o
18
+ def __ge__(self,o): return self._e()>=o
19
+ def __eq__(self,o):
20
+ if isinstance(o,CPIntVar) and self.model is not o.model: return False
21
+ return self._e()==o
22
+ __hash__=object.__hash__
23
+
24
+ @dataclass(frozen=True, slots=True, eq=False)
25
+ class CPBoolVar(CPIntVar): pass
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CPLinearExpr:
29
+ model:'CPModel'; terms:Mapping[int,int]; constant:int=0
30
+ def __post_init__(self): object.__setattr__(self,'terms',{int(k):int(v) for k,v in self.terms.items() if int(v)})
31
+ def _coerce(self,o):
32
+ if isinstance(o,CPIntVar):
33
+ if o.model is not self.model: raise ValueError('cross-model CP expression')
34
+ return o._e()
35
+ if isinstance(o,CPLinearExpr):
36
+ if o.model is not self.model: raise ValueError('cross-model CP expression')
37
+ return o
38
+ if isinstance(o,int): return CPLinearExpr(self.model,{},o)
39
+ raise TypeError(f'unsupported CP expression value {type(o).__name__}')
40
+ def __add__(self,o):
41
+ r=self._coerce(o); d=dict(self.terms)
42
+ for k,v in r.terms.items(): d[k]=d.get(k,0)+v
43
+ return CPLinearExpr(self.model,d,self.constant+r.constant)
44
+ __radd__=__add__
45
+ def __neg__(self): return CPLinearExpr(self.model,{k:-v for k,v in self.terms.items()},-self.constant)
46
+ def __sub__(self,o): return self+(-self._coerce(o))
47
+ def __rsub__(self,o): return self._coerce(o)+(-self)
48
+ def __mul__(self,o):
49
+ if not isinstance(o,int): raise TypeError('P9 CP linear expressions only support integer scaling')
50
+ return CPLinearExpr(self.model,{k:o*v for k,v in self.terms.items()},o*self.constant)
51
+ __rmul__=__mul__
52
+ def __le__(self,o): return CPBoundedLinear(self-self._coerce(o),None,0)
53
+ def __ge__(self,o): return CPBoundedLinear(self-self._coerce(o),0,None)
54
+ def __eq__(self,o): return CPBoundedLinear(self-self._coerce(o),0,0)
55
+ def ir(self): return CPLinearExprIR(tuple(self.terms.items()),self.constant)
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class CPBoundedLinear:
59
+ expr:CPLinearExpr; lower:int|None; upper:int|None
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class CPIntervalVar:
63
+ model:'CPModel'; interval_id:int; name:str; start:CPIntVar; size:int; end:CPIntVar
64
+
65
+ class CPModel:
66
+ def __init__(self,name=None):
67
+ self.name=name; self._vars=[]; self._intervals=[]; self._constraints=[]; self._objective=None; self._sense=CPObjectiveSense.MINIMIZE
68
+ def int_var(self,lower:int,upper:int,name=None):
69
+ if int(lower)>int(upper): raise ValueError('lower > upper')
70
+ return self.int_var_from_values(range(int(lower),int(upper)+1),name)
71
+ def int_var_from_values(self,values:Iterable[int],name=None):
72
+ vals=tuple(sorted(set(int(x) for x in values)))
73
+ if not vals: raise ValueError('domain cannot be empty')
74
+ vid=len(self._vars); v=CPIntVar(self,vid,name or f'x{vid}',vals); self._vars.append((v,None,'int')); return v
75
+ def bool_var(self,name=None):
76
+ vid=len(self._vars); v=CPBoolVar(self,vid,name or f'b{vid}',(0,1)); self._vars.append((v,None,'bool')); return v
77
+ def interval_var(self,start:CPIntVar,size:int,name=None):
78
+ self._check(start); size=int(size)
79
+ if size<0: raise ValueError('interval size must be nonnegative')
80
+ end_dom=tuple(v+size for v in start.domain); vid=len(self._vars); end=CPIntVar(self,vid,(name or f'i{len(self._intervals)}')+'_end',end_dom); self._vars.append((end,(start.var_id,size),'int'))
81
+ iid=len(self._intervals); iv=CPIntervalVar(self,iid,name or f'i{iid}',start,size,end); self._intervals.append(iv); return iv
82
+ def _check(self,*vars):
83
+ for v in vars:
84
+ if not isinstance(v,CPIntVar) or v.model is not self: raise ValueError('CP variable belongs to another model')
85
+ def add(self,bounded:CPBoundedLinear):
86
+ if not isinstance(bounded,CPBoundedLinear) or bounded.expr.model is not self: raise TypeError('CPModel.add expects a bounded CP linear expression')
87
+ self._constraints.append(CPLinearConstraintIR(bounded.expr.ir(),bounded.lower,bounded.upper)); return self
88
+ def add_all_different(self,vars):
89
+ vs=tuple(vars); self._check(*vs); self._constraints.append(CPAllDifferentIR(tuple(v.var_id for v in vs))); return self
90
+ def add_exactly_one(self,literals):
91
+ vs=tuple(literals); self._check(*vs)
92
+ if not vs: raise ValueError('exactly_one requires literals')
93
+ if any(tuple(v.domain)!=(0,1) for v in vs): raise ValueError('exactly_one requires BoolVar domains')
94
+ self._constraints.append(CPExactlyOneIR(tuple(v.var_id for v in vs))); return self
95
+ def add_allowed_assignments(self,vars,tuples_list):
96
+ vs=tuple(vars); self._check(*vs)
97
+ if not vs: raise ValueError('table requires variables')
98
+ rows=tuple(tuple(int(x) for x in row) for row in tuples_list)
99
+ if any(len(r)!=len(vs) for r in rows): raise ValueError('table tuple arity mismatch')
100
+ self._constraints.append(CPTableIR(tuple(v.var_id for v in vs),rows)); return self
101
+ def add_element(self,index:CPIntVar,values,target:CPIntVar):
102
+ self._check(index,target); vals=tuple(int(v) for v in values)
103
+ if not vals: raise ValueError('element values cannot be empty')
104
+ self._constraints.append(CPElementIR(index.var_id,vals,target.var_id)); return self
105
+ def add_circuit(self,arcs):
106
+ out=[]
107
+ for tail,head,lit in arcs:
108
+ self._check(lit)
109
+ if tuple(lit.domain)!=(0,1): raise ValueError('circuit arc literal must be BoolVar')
110
+ out.append((int(tail),int(head),lit.var_id))
111
+ if not out: raise ValueError('circuit requires arcs')
112
+ if any(t<0 or h<0 for t,h,_ in out): raise ValueError('circuit node ids must be nonnegative')
113
+ loops=[(t,h) for t,h,_ in out if t==h]
114
+ if len(loops)!=len(set(loops)): raise ValueError('multiple self-loops for one node are not CP-SAT compatible')
115
+ self._constraints.append(CPCircuitIR(tuple(out))); return self
116
+ def add_no_overlap(self,intervals):
117
+ ivs=tuple(intervals)
118
+ if any(not isinstance(i,CPIntervalVar) or i.model is not self for i in ivs): raise ValueError('interval belongs to another model')
119
+ self._constraints.append(CPNoOverlapIR(tuple(i.interval_id for i in ivs))); return self
120
+ def add_cumulative(self,intervals,demands,capacity):
121
+ ivs=tuple(intervals); ds=tuple(int(x) for x in demands); cap=int(capacity)
122
+ if len(ivs)!=len(ds): raise ValueError('cumulative interval/demand length mismatch')
123
+ if any(d<0 for d in ds): raise ValueError('cumulative demands must be nonnegative')
124
+ if any(not isinstance(i,CPIntervalVar) or i.model is not self for i in ivs): raise ValueError('interval belongs to another model')
125
+ self._constraints.append(CPCumulativeIR(tuple(i.interval_id for i in ivs),ds,cap)); return self
126
+ def minimize(self,expr): self._objective=self._as_expr(expr).ir(); self._sense=CPObjectiveSense.MINIMIZE; return self
127
+ def maximize(self,expr): self._objective=self._as_expr(expr).ir(); self._sense=CPObjectiveSense.MAXIMIZE; return self
128
+ def _as_expr(self,x):
129
+ if isinstance(x,CPIntVar): self._check(x); return x._e()
130
+ if isinstance(x,CPLinearExpr):
131
+ if x.model is not self: raise ValueError('cross-model objective')
132
+ return x
133
+ if isinstance(x,int): return CPLinearExpr(self,{},x)
134
+ raise TypeError('objective must be CP linear expression')
135
+ def compile(self):
136
+ variables=tuple(CPIntVarIR(v.var_id,v.name,v.domain,kind,derived) for v,derived,kind in self._vars)
137
+ intervals=tuple(CPIntervalIR(i.interval_id,i.name,i.start.var_id,i.size,i.end.var_id) for i in self._intervals)
138
+ return CPProblem(variables,intervals,tuple(self._constraints),self._objective,self._sense,self.name,{'compiled_by':'solverpilot-p9-cp-core'})
139
+ def solve(self,backend=None,**kwargs):
140
+ if backend is None:
141
+ from .reference import ReferenceCPBackend
142
+ backend=ReferenceCPBackend()
143
+ return backend.solve(self.compile(),**kwargs)
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import importlib.util
4
+ import importlib.metadata
5
+ import json
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ import secrets
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from collections.abc import Mapping
14
+
15
+ from solverpilot._version import __version__ as SOLVERPILOT_VERSION
16
+ from .ir import *
17
+ from .validate import validate_cp_solution
18
+ from .reference import CPSolveResult
19
+
20
+ VERIFIED_ORTOOLS_VERSION = '9.15.6755'
21
+ CP_WORKER_PROTOCOL_VERSION = 2
22
+ CP_WORKER_REQUEST_SCHEMA = 'solverpilot.cp.ortools_worker_request.v2'
23
+ CP_WORKER_RESULT_SCHEMA = 'solverpilot.cp.ortools_worker_result.v2'
24
+
25
+
26
+ def _isolated_worker_command() -> list[str]:
27
+ """Return an isolated worker command pinned to this SolverPilot tree.
28
+
29
+ ``-I`` removes the current directory, user site, and ``PYTHON*`` environment
30
+ variables from normal import discovery. The bootstrap creates minimal package
31
+ shells rooted at the exact package tree that supplied this backend. This avoids
32
+ eager top-level SolverPilot imports before OR-Tools and prevents CWD/PYTHONPATH
33
+ shadowing of the worker package.
34
+ """
35
+ package_parent = str(Path(__file__).resolve().parents[2])
36
+ bootstrap = (
37
+ "import pathlib,runpy,sys,types; "
38
+ "root=pathlib.Path(sys.argv[1]); "
39
+ "sp=types.ModuleType('solverpilot'); "
40
+ "sp.__path__=[str(root/'solverpilot')]; "
41
+ "sp.__package__='solverpilot'; "
42
+ "sys.modules['solverpilot']=sp; "
43
+ "cp=types.ModuleType('solverpilot.cp'); "
44
+ "cp.__path__=[str(root/'solverpilot'/'cp')]; "
45
+ "cp.__package__='solverpilot.cp'; "
46
+ "sys.modules['solverpilot.cp']=cp; "
47
+ "runpy.run_module('solverpilot.cp.ortools_worker', run_name='__main__')"
48
+ )
49
+ return [sys.executable, '-I', '-c', bootstrap, package_parent]
50
+
51
+
52
+ def _isolated_worker_environment() -> dict[str, str]:
53
+ env = {k: v for k, v in os.environ.items() if not k.upper().startswith('PYTHON')}
54
+ env['PYTHONNOUSERSITE'] = '1'
55
+ env['PYTHONDONTWRITEBYTECODE'] = '1'
56
+ env['PYTHONSAFEPATH'] = '1'
57
+ return env
58
+
59
+
60
+ def _require_json_int(value, *, where: str) -> int:
61
+ if isinstance(value, bool) or not isinstance(value, int):
62
+ raise RuntimeError(f'P9 isolated CP-SAT worker returned non-integer {where}')
63
+ return int(value)
64
+
65
+
66
+ def _require_json_bool(value, *, where: str) -> bool:
67
+ if not isinstance(value, bool):
68
+ raise RuntimeError(f'P9 isolated CP-SAT worker returned non-boolean {where}')
69
+ return value
70
+
71
+
72
+ def _bound_matches_integer(value, objective: int) -> bool:
73
+ # CP-SAT exposes objective/bound as binary64. Above 2**53 adjacent integers are
74
+ # not distinguishable, so proof preservation deliberately fails closed.
75
+ if abs(int(objective)) > 2**53:
76
+ return False
77
+ try:
78
+ numeric = float(value)
79
+ except (TypeError, ValueError):
80
+ return False
81
+ return math.isfinite(numeric) and abs(numeric - float(objective)) < 0.5
82
+
83
+
84
+
85
+ def _translate_to_cp_sat(problem: CPProblem, cp_model):
86
+ """Translate P9 CP IR into an OR-Tools CpModel.
87
+
88
+ Kept separate from binding discovery/solve so the translation contract can
89
+ be unit-tested without pretending the native CP-SAT runtime is present.
90
+ """
91
+ m = cp_model.CpModel()
92
+ vmap = {}
93
+ for v in problem.variables:
94
+ # Use a true BoolVar for the canonical {0,1} Boolean domain because
95
+ # ExactlyOne/Circuit consume literals. Fixed/subset Boolean domains are
96
+ # still represented exactly through an integer domain.
97
+ if v.kind == 'bool' and tuple(v.domain) == (0, 1):
98
+ vv = m.new_bool_var(v.name)
99
+ else:
100
+ dom = cp_model.Domain.from_values(list(v.domain))
101
+ vv = m.new_int_var_from_domain(dom, v.name)
102
+ vmap[v.var_id] = vv
103
+
104
+ for v in problem.variables:
105
+ if v.derived_from is not None:
106
+ start_id, size = v.derived_from
107
+ m.add(vmap[v.var_id] == vmap[start_id] + size)
108
+
109
+ imap = {}
110
+ for iv in problem.intervals:
111
+ imap[iv.interval_id] = m.new_interval_var(
112
+ vmap[iv.start_var], iv.size, vmap[iv.end_var], iv.name
113
+ )
114
+
115
+ def expr(e):
116
+ return e.constant + sum(c * vmap[i] for i, c in e.terms)
117
+
118
+ for c in problem.constraints:
119
+ if isinstance(c, CPLinearConstraintIR):
120
+ ex = expr(c.expr)
121
+ if c.lower is not None and c.upper is not None and c.lower == c.upper:
122
+ m.add(ex == c.lower)
123
+ else:
124
+ if c.lower is not None:
125
+ m.add(ex >= c.lower)
126
+ if c.upper is not None:
127
+ m.add(ex <= c.upper)
128
+ elif isinstance(c, CPAllDifferentIR):
129
+ m.add_all_different([vmap[i] for i in c.var_ids])
130
+ elif isinstance(c, CPExactlyOneIR):
131
+ m.add_exactly_one([vmap[i] for i in c.literal_ids])
132
+ elif isinstance(c, CPTableIR):
133
+ m.add_allowed_assignments([vmap[i] for i in c.var_ids], c.allowed_tuples)
134
+ elif isinstance(c, CPElementIR):
135
+ m.add_element(vmap[c.index_var], list(c.values), vmap[c.target_var])
136
+ elif isinstance(c, CPCircuitIR):
137
+ m.add_circuit([(t, h, vmap[l]) for t, h, l in c.arcs])
138
+ elif isinstance(c, CPNoOverlapIR):
139
+ m.add_no_overlap([imap[i] for i in c.interval_ids])
140
+ elif isinstance(c, CPCumulativeIR):
141
+ m.add_cumulative([imap[i] for i in c.interval_ids], list(c.demands), c.capacity)
142
+ else: # pragma: no cover - defensive against future IR expansion.
143
+ raise TypeError(f'unsupported P9 CP constraint IR {type(c).__name__}')
144
+
145
+ if problem.objective is not None:
146
+ if problem.objective_sense is CPObjectiveSense.MINIMIZE:
147
+ m.minimize(expr(problem.objective))
148
+ else:
149
+ m.maximize(expr(problem.objective))
150
+ return m, vmap, imap
151
+
152
+
153
+ @dataclass(slots=True)
154
+ class ORToolsCPSATBackend:
155
+ max_time_s: float | None = None
156
+ num_workers: int = 1
157
+ controller_timeout_s: float = 3600.0
158
+ name: str = 'ortools-cp-sat'
159
+
160
+ def __post_init__(self):
161
+ if self.max_time_s is not None and self.max_time_s <= 0:
162
+ raise ValueError('max_time_s must be positive')
163
+ if self.controller_timeout_s <= 0:
164
+ raise ValueError('controller_timeout_s must be positive')
165
+ if isinstance(self.num_workers, bool) or not isinstance(self.num_workers, int) or self.num_workers < 1:
166
+ raise ValueError('num_workers must be a positive integer')
167
+
168
+ @property
169
+ def binding_version(self):
170
+ try:
171
+ return importlib.metadata.version("ortools")
172
+ except importlib.metadata.PackageNotFoundError:
173
+ return None
174
+
175
+ def capability_manifest_v2(self):
176
+ from solverpilot.capabilities.v2 import (
177
+ BackendCapabilityManifestV2, CapabilityClaim, CapabilityEvidence, CapabilityKey,
178
+ CapabilityMode, CapabilityStatus, VerificationLevel,
179
+ )
180
+ evidence = CapabilityEvidence(
181
+ evidence_id='p9-ortools-cp-sat-9.15.6755',
182
+ kind='native-integration',
183
+ binding_versions=(VERIFIED_ORTOOLS_VERSION,),
184
+ adapter_versions=(SOLVERPILOT_VERSION,),
185
+ verified_on='2026-09-09',
186
+ notes=('isolated worker; parent primal/objective revalidation',),
187
+ )
188
+ supported = (
189
+ CapabilityKey.PROBLEM_CP, CapabilityKey.CONSTRAINT_LINEAR,
190
+ CapabilityKey.CONSTRAINT_ALL_DIFFERENT, CapabilityKey.CONSTRAINT_EXACTLY_ONE,
191
+ CapabilityKey.CONSTRAINT_TABLE, CapabilityKey.CONSTRAINT_ELEMENT,
192
+ CapabilityKey.CONSTRAINT_CIRCUIT, CapabilityKey.CONSTRAINT_NO_OVERLAP,
193
+ CapabilityKey.CONSTRAINT_CUMULATIVE, CapabilityKey.RESULT_PRIMAL,
194
+ CapabilityKey.RESULT_OBJECTIVE, CapabilityKey.LIFECYCLE_THREAD_CONTROL,
195
+ )
196
+ claims = {
197
+ key: CapabilityClaim(
198
+ status=CapabilityStatus.SUPPORTED, mode=CapabilityMode.NATIVE,
199
+ verification=VerificationLevel.VERIFIED, evidence=(evidence,),
200
+ ) for key in supported
201
+ }
202
+ return BackendCapabilityManifestV2(
203
+ backend=self.name, backend_version=self.binding_version, binding='ortools',
204
+ binding_version=self.binding_version, adapter_version=SOLVERPILOT_VERSION,
205
+ claims=claims, metadata={
206
+ 'worker_protocol_version': CP_WORKER_PROTOCOL_VERSION,
207
+ 'verified_ortools_version': VERIFIED_ORTOOLS_VERSION,
208
+ 'isolated_subprocess': True,
209
+ },
210
+ )
211
+
212
+ def is_available(self):
213
+ return (
214
+ importlib.util.find_spec('ortools') is not None
215
+ and self.binding_version == VERIFIED_ORTOOLS_VERSION
216
+ )
217
+
218
+ def _require(self):
219
+ if importlib.util.find_spec('ortools') is None:
220
+ raise RuntimeError(
221
+ f'P9 fail-closed: OR-Tools CP-SAT is not installed; '
222
+ f'verified target is {VERIFIED_ORTOOLS_VERSION}'
223
+ )
224
+ if self.binding_version != VERIFIED_ORTOOLS_VERSION:
225
+ raise RuntimeError(
226
+ f'P9 fail-closed: OR-Tools {self.binding_version} is not the '
227
+ f'verified version {VERIFIED_ORTOOLS_VERSION}'
228
+ )
229
+
230
+ def _solve_in_process(self, problem: CPProblem):
231
+ self._require()
232
+ import ortools
233
+ from ortools.sat.python import cp_model
234
+
235
+ m, vmap, _ = _translate_to_cp_sat(problem, cp_model)
236
+ validation_message = m.validate()
237
+ if validation_message:
238
+ return CPSolveResult(
239
+ 'model_invalid',
240
+ None,
241
+ None,
242
+ validate_cp_solution(problem, {}),
243
+ False,
244
+ self.name,
245
+ {
246
+ 'ortools_version': ortools.__version__,
247
+ 'status': 'MODEL_INVALID_PRE_SOLVE',
248
+ 'model_validation': validation_message,
249
+ },
250
+ )
251
+
252
+ solver = cp_model.CpSolver()
253
+ solver.parameters.num_workers = int(self.num_workers)
254
+ if self.max_time_s is not None:
255
+ solver.parameters.max_time_in_seconds = float(self.max_time_s)
256
+
257
+ st = solver.solve(m)
258
+ status_name = solver.status_name(st)
259
+ if st not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
260
+ return CPSolveResult(
261
+ status_name.lower(),
262
+ None,
263
+ None,
264
+ validate_cp_solution(problem, {}),
265
+ st == cp_model.INFEASIBLE,
266
+ self.name,
267
+ {
268
+ 'ortools_version': ortools.__version__,
269
+ 'status': status_name,
270
+ 'model_validation': '',
271
+ },
272
+ )
273
+
274
+ assignment = {i: int(solver.value(v)) for i, v in vmap.items()}
275
+ pre_validation = validate_cp_solution(problem, assignment)
276
+ # The canonical objective is integral. Recompute it from the returned
277
+ # assignment instead of trusting/rounding CP-SAT's floating API value.
278
+ objective = pre_validation.objective_recomputed
279
+ report = validate_cp_solution(problem, assignment, objective)
280
+ raw = {
281
+ 'ortools_version': ortools.__version__,
282
+ 'status': status_name,
283
+ 'model_validation': '',
284
+ }
285
+ if problem.objective is not None:
286
+ raw.update(
287
+ solver_objective_value=float(solver.objective_value),
288
+ best_bound=float(solver.best_objective_bound),
289
+ )
290
+ return CPSolveResult(
291
+ 'optimal' if st == cp_model.OPTIMAL else 'feasible',
292
+ assignment,
293
+ objective,
294
+ report,
295
+ st == cp_model.OPTIMAL,
296
+ self.name,
297
+ raw,
298
+ )
299
+ def solve(self, problem: CPProblem):
300
+ """Solve CP-SAT in a version- and request-bound isolated process.
301
+
302
+ The worker starts under Python isolated mode, from a temporary CWD, with
303
+ ``PYTHON*`` injection removed. The request/result protocol binds the response
304
+ to this exact SolverPilot version, verified OR-Tools version, request nonce,
305
+ and both canonical problem hashes. The parent independently revalidates every
306
+ returned primal assignment/objective before preserving backend optimality.
307
+ """
308
+ self._require()
309
+ request_id = secrets.token_hex(16)
310
+ request = {
311
+ 'schema': CP_WORKER_REQUEST_SCHEMA,
312
+ 'protocol_version': CP_WORKER_PROTOCOL_VERSION,
313
+ 'request_id': request_id,
314
+ 'solverpilot_version': SOLVERPILOT_VERSION,
315
+ 'ortools_version': self.binding_version,
316
+ 'problem_structural_hash': problem.structural_hash,
317
+ 'problem_data_hash': problem.data_hash,
318
+ 'problem': problem.canonical_dict(),
319
+ 'max_time_s': self.max_time_s,
320
+ 'num_workers': self.num_workers,
321
+ }
322
+ solver_timeout = None if self.max_time_s is None else max(30.0, float(self.max_time_s) + 15.0)
323
+ timeout = float(self.controller_timeout_s) if solver_timeout is None else min(float(self.controller_timeout_s), solver_timeout)
324
+ try:
325
+ with tempfile.TemporaryDirectory(prefix='solverpilot-cp-worker-') as worker_cwd:
326
+ proc = subprocess.run(
327
+ _isolated_worker_command(),
328
+ input=json.dumps(request, sort_keys=True, separators=(',', ':')),
329
+ text=True, capture_output=True, timeout=timeout, cwd=worker_cwd,
330
+ env=_isolated_worker_environment(),
331
+ )
332
+ except subprocess.TimeoutExpired as exc:
333
+ raise RuntimeError(f'P9 isolated CP-SAT worker exceeded controller timeout {timeout:.3f}s') from exc
334
+ if proc.returncode != 0:
335
+ detail = proc.stderr.strip() or proc.stdout.strip() or f'exit {proc.returncode}'
336
+ raise RuntimeError(f'P9 isolated CP-SAT worker failed: {detail}')
337
+ try:
338
+ payload = json.loads(proc.stdout)
339
+ except json.JSONDecodeError as exc:
340
+ raise RuntimeError('P9 isolated CP-SAT worker returned invalid JSON') from exc
341
+ if not isinstance(payload, dict):
342
+ raise RuntimeError('P9 isolated CP-SAT worker returned a non-object payload')
343
+ if payload.get('schema') != CP_WORKER_RESULT_SCHEMA:
344
+ raise RuntimeError('P9 isolated CP-SAT worker returned an unknown schema')
345
+ if payload.get('protocol_version') != CP_WORKER_PROTOCOL_VERSION:
346
+ raise RuntimeError('P9 isolated CP-SAT worker protocol version mismatch')
347
+ expected_bindings = {
348
+ 'request_id': request_id,
349
+ 'solverpilot_version': SOLVERPILOT_VERSION,
350
+ 'ortools_version': self.binding_version,
351
+ 'problem_structural_hash': problem.structural_hash,
352
+ 'problem_data_hash': problem.data_hash,
353
+ }
354
+ for field, expected in expected_bindings.items():
355
+ if payload.get(field) != expected:
356
+ raise RuntimeError(f'P9 isolated CP-SAT worker binding mismatch: {field}')
357
+
358
+ status = payload.get('status')
359
+ if not isinstance(status, str) or not status:
360
+ raise RuntimeError('P9 isolated CP-SAT worker returned invalid status')
361
+ assignment_payload = payload.get('assignment')
362
+ if assignment_payload is None:
363
+ assignment = None
364
+ else:
365
+ if not isinstance(assignment_payload, Mapping):
366
+ raise RuntimeError('P9 isolated CP-SAT worker returned invalid assignment')
367
+ assignment = {}
368
+ for key, value in assignment_payload.items():
369
+ try:
370
+ var_id = int(key)
371
+ except (TypeError, ValueError) as exc:
372
+ raise RuntimeError('P9 isolated CP-SAT worker returned invalid assignment key') from exc
373
+ if var_id in assignment:
374
+ raise RuntimeError('P9 isolated CP-SAT worker returned duplicate normalized assignment key')
375
+ if var_id not in problem.variable_map:
376
+ raise RuntimeError(f'P9 isolated CP-SAT worker returned unknown variable id {var_id}')
377
+ assignment[var_id] = _require_json_int(value, where='assignment value')
378
+
379
+ objective_payload = payload.get('objective')
380
+ objective = None if objective_payload is None else _require_json_int(objective_payload, where='objective')
381
+ if problem.objective is None and objective is not None:
382
+ raise RuntimeError('P9 isolated CP-SAT worker returned an objective for an objective-free problem')
383
+ if problem.objective is not None and assignment is not None and objective is None:
384
+ raise RuntimeError('P9 isolated CP-SAT worker omitted the objective for a primal solution')
385
+ if status in {'optimal', 'feasible'} and assignment is None:
386
+ raise RuntimeError(f'P9 isolated CP-SAT worker returned {status} without an assignment')
387
+ if status == 'infeasible' and assignment is not None:
388
+ raise RuntimeError('P9 isolated CP-SAT worker returned infeasible with an assignment')
389
+
390
+ raw_payload = payload.get('raw_statistics', {})
391
+ if not isinstance(raw_payload, Mapping):
392
+ raise RuntimeError('P9 isolated CP-SAT worker returned invalid raw_statistics')
393
+ raw = dict(raw_payload)
394
+ if raw.get('ortools_version') != self.binding_version:
395
+ raise RuntimeError('P9 isolated CP-SAT worker binding mismatch: raw_statistics.ortools_version')
396
+ worker_proof = _require_json_bool(payload.get('optimality_proven'), where='optimality_proven')
397
+
398
+ report = validate_cp_solution(problem, assignment or {}, objective)
399
+ raw['isolated_worker'] = True
400
+ raw['worker_protocol_version'] = CP_WORKER_PROTOCOL_VERSION
401
+ raw['worker_request_bound'] = True
402
+ raw['worker_binding_verified'] = True
403
+ raw['parent_revalidated'] = assignment is not None
404
+ raw['worker_optimality_proven'] = worker_proof
405
+ raw['parent_validation_valid'] = bool(report.valid)
406
+
407
+ proof_consistent = False
408
+ raw_status = raw.get('status')
409
+ if (worker_proof and status == 'optimal' and assignment is not None and report.valid
410
+ and raw_status == 'OPTIMAL' and raw.get('model_validation', '') == ''):
411
+ if problem.objective is None:
412
+ proof_consistent = True
413
+ else:
414
+ proof_consistent = (objective is not None
415
+ and _bound_matches_integer(raw.get('solver_objective_value'), objective)
416
+ and _bound_matches_integer(raw.get('best_bound'), objective))
417
+ elif (worker_proof and status == 'infeasible' and assignment is None and objective is None
418
+ and raw_status == 'INFEASIBLE' and raw.get('model_validation', '') == ''):
419
+ proof_consistent = True
420
+
421
+ raw['worker_proof_consistent'] = proof_consistent
422
+ proof_preserved = bool(worker_proof and proof_consistent)
423
+ return CPSolveResult(status, assignment, objective, report, proof_preserved, self.name, raw)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from solverpilot._version import __version__ as SOLVERPILOT_VERSION
7
+ from .ortools_backend import (
8
+ CP_WORKER_PROTOCOL_VERSION,
9
+ CP_WORKER_REQUEST_SCHEMA,
10
+ CP_WORKER_RESULT_SCHEMA,
11
+ ORToolsCPSATBackend,
12
+ VERIFIED_ORTOOLS_VERSION,
13
+ )
14
+ from .serialization import problem_from_canonical_dict
15
+
16
+
17
+ def _require_request_text(request: dict, field: str) -> str:
18
+ value = request.get(field)
19
+ if not isinstance(value, str) or not value:
20
+ raise ValueError(f'invalid CP-SAT worker request field {field}')
21
+ return value
22
+
23
+
24
+ def main() -> int:
25
+ try:
26
+ request = json.load(sys.stdin)
27
+ if not isinstance(request, dict):
28
+ raise ValueError('CP-SAT worker request must be a JSON object')
29
+ if request.get('schema') != CP_WORKER_REQUEST_SCHEMA:
30
+ raise ValueError('unknown CP-SAT worker request schema')
31
+ if request.get('protocol_version') != CP_WORKER_PROTOCOL_VERSION:
32
+ raise ValueError('CP-SAT worker protocol version mismatch')
33
+
34
+ request_id = _require_request_text(request, 'request_id')
35
+ requested_solverpilot = _require_request_text(request, 'solverpilot_version')
36
+ requested_ortools = _require_request_text(request, 'ortools_version')
37
+ expected_structural_hash = _require_request_text(request, 'problem_structural_hash')
38
+ expected_data_hash = _require_request_text(request, 'problem_data_hash')
39
+ if requested_solverpilot != SOLVERPILOT_VERSION:
40
+ raise RuntimeError(f'SolverPilot worker version mismatch: request={requested_solverpilot} local={SOLVERPILOT_VERSION}')
41
+
42
+ backend = ORToolsCPSATBackend(max_time_s=request.get('max_time_s'), num_workers=request.get('num_workers', 1))
43
+ problem = problem_from_canonical_dict(request['problem'])
44
+ if problem.structural_hash != expected_structural_hash:
45
+ raise RuntimeError('CP-SAT worker structural hash mismatch')
46
+ if problem.data_hash != expected_data_hash:
47
+ raise RuntimeError('CP-SAT worker data hash mismatch')
48
+ backend._require()
49
+ if backend.binding_version != VERIFIED_ORTOOLS_VERSION or requested_ortools != backend.binding_version:
50
+ raise RuntimeError(f'OR-Tools worker version mismatch: request={requested_ortools} local={backend.binding_version}')
51
+
52
+ result = backend._solve_in_process(problem)
53
+ payload = {
54
+ 'schema': CP_WORKER_RESULT_SCHEMA,
55
+ 'protocol_version': CP_WORKER_PROTOCOL_VERSION,
56
+ 'request_id': request_id,
57
+ 'solverpilot_version': SOLVERPILOT_VERSION,
58
+ 'ortools_version': backend.binding_version,
59
+ 'problem_structural_hash': problem.structural_hash,
60
+ 'problem_data_hash': problem.data_hash,
61
+ 'status': result.status,
62
+ 'assignment': None if result.assignment is None else {str(k): int(v) for k, v in result.assignment.items()},
63
+ 'objective': result.objective,
64
+ 'optimality_proven': bool(result.optimality_proven),
65
+ 'raw_statistics': dict(result.raw_statistics),
66
+ }
67
+ sys.stdout.write(json.dumps(payload, sort_keys=True, separators=(',', ':')))
68
+ return 0
69
+ except Exception as exc:
70
+ sys.stderr.write(f'{type(exc).__name__}: {exc}\n')
71
+ return 2
72
+
73
+
74
+ if __name__ == '__main__':
75
+ raise SystemExit(main())