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.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
solverpilot/cp/model.py
ADDED
|
@@ -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())
|