quicopt 0.1.0
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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/LICENSE +201 -0
- data/README.md +205 -0
- data/lib/quicopt/client.rb +504 -0
- data/lib/quicopt/model.rb +627 -0
- data/lib/quicopt/modeler/v1/program_pb.rb +48 -0
- data/lib/quicopt/version.rb +9 -0
- data/lib/quicopt/wire.rb +179 -0
- data/lib/quicopt.rb +50 -0
- data/proto/quicopt/modeler/v1/program.proto +240 -0
- data/templates/default/fulldoc/html/css/common.css +474 -0
- data/templates/default/fulldoc/html/css/quicopt-icon-color.svg +244 -0
- data/templates/default/fulldoc/html/css/quicopt-icon-white.svg +235 -0
- data/templates/default/fulldoc/html/setup.rb +17 -0
- metadata +72 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# quicopt/model — the native Ruby modelling DSL and the model it builds.
|
|
6
|
+
#
|
|
7
|
+
# A +Model+ owns decision variables, an objective and constraint rows. Arithmetic
|
|
8
|
+
# on variables (+3 * x + 2 * y+) builds an +Expression+, and comparing two
|
|
9
|
+
# expressions (+x + y <= 5+) builds a +Constraint+ — neither evaluates to a number
|
|
10
|
+
# or a boolean.
|
|
11
|
+
#
|
|
12
|
+
# Expressions are normalised as they are built: an +Expression+ is always a
|
|
13
|
+
# constant plus a map of linear terms plus a map of quadratic terms, never an
|
|
14
|
+
# unreduced operator tree. That is what makes the degree check exact — anything
|
|
15
|
+
# that would exceed degree 2 raises at the point of multiplication rather than
|
|
16
|
+
# reaching the service, per the "raise loudly, never approximate" rule the sibling
|
|
17
|
+
# clients follow.
|
|
18
|
+
#
|
|
19
|
+
# Only flat (unindexed) variables exist here. The wire schema also carries index
|
|
20
|
+
# sets, parameter tables and quantified constraints; the Ruby DSL deliberately
|
|
21
|
+
# does not — see the layered front-ends in the other clients for those.
|
|
22
|
+
|
|
23
|
+
module Quicopt
|
|
24
|
+
# Base class of every error this gem raises.
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
# An expression outside the linear/quadratic subset the DSL serves — e.g. a
|
|
28
|
+
# product of three variables, or a division by a variable. Raised at the point
|
|
29
|
+
# of construction so the offending term is still in the backtrace.
|
|
30
|
+
class UnsupportedExpression < Error; end
|
|
31
|
+
|
|
32
|
+
# Number and term rendering shared by the DSL's +to_s+ methods. Presentation
|
|
33
|
+
# only — nothing here touches the wire.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
module Format
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# A float without its trailing +.0+ when it is whole.
|
|
40
|
+
def number(value)
|
|
41
|
+
return value.to_s unless value.finite?
|
|
42
|
+
|
|
43
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One term, dropping a unit coefficient (+x+, +-x+, +3*x+).
|
|
47
|
+
def term(coefficient, factors)
|
|
48
|
+
return factors if coefficient == 1.0
|
|
49
|
+
return "-#{factors}" if coefficient == -1.0
|
|
50
|
+
|
|
51
|
+
"#{number(coefficient)}*#{factors}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The operator surface shared by +Variable+ and +Expression+.
|
|
56
|
+
#
|
|
57
|
+
# Every operator is written against +to_expr+, so a variable and an expression
|
|
58
|
+
# behave identically. +coerce+ makes a numeric on the left work too, which is
|
|
59
|
+
# what lets +3 * x+ parse: Ruby hands the multiplication back to us rather than
|
|
60
|
+
# failing in +Integer#*+.
|
|
61
|
+
module Operators
|
|
62
|
+
# @return [Expression] +self + other+
|
|
63
|
+
def +(other)
|
|
64
|
+
to_expr.plus(Expression.cast(other))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Expression] +self - other+
|
|
68
|
+
def -(other)
|
|
69
|
+
to_expr.plus(Expression.cast(other).scale(-1.0))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Expression] +self * other+
|
|
73
|
+
# @raise [UnsupportedExpression] if the product would exceed degree 2
|
|
74
|
+
def *(other)
|
|
75
|
+
to_expr.times(Expression.cast(other))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Division by a numeric constant only — dividing by an expression is not a
|
|
79
|
+
# polynomial and has no wire form.
|
|
80
|
+
#
|
|
81
|
+
# @return [Expression] +self / divisor+
|
|
82
|
+
# @raise [UnsupportedExpression] if +divisor+ is not a nonzero number
|
|
83
|
+
def /(divisor)
|
|
84
|
+
unless divisor.is_a?(Numeric)
|
|
85
|
+
raise UnsupportedExpression,
|
|
86
|
+
"cannot divide by #{divisor.class}: only division by a numeric constant is supported"
|
|
87
|
+
end
|
|
88
|
+
raise ZeroDivisionError, "divided by 0" if divisor.zero?
|
|
89
|
+
|
|
90
|
+
to_expr.scale(1.0 / divisor)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Integer powers 0, 1 and 2 only — the degree the subset admits.
|
|
94
|
+
#
|
|
95
|
+
# @return [Expression] +self ** exponent+
|
|
96
|
+
# @raise [UnsupportedExpression] on any other exponent
|
|
97
|
+
def **(exponent)
|
|
98
|
+
case exponent
|
|
99
|
+
when 0 then Expression.new(constant: 1.0)
|
|
100
|
+
when 1 then to_expr
|
|
101
|
+
when 2 then to_expr.times(to_expr)
|
|
102
|
+
else
|
|
103
|
+
raise UnsupportedExpression,
|
|
104
|
+
"exponent #{exponent.inspect} is outside the linear/quadratic subset (0, 1 or 2)"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Expression] the negation of +self+
|
|
109
|
+
def -@
|
|
110
|
+
to_expr.scale(-1.0)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Expression] +self+, unchanged
|
|
114
|
+
def +@
|
|
115
|
+
to_expr
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Constraint] the row +self <= other+
|
|
119
|
+
def <=(other)
|
|
120
|
+
Constraint.build(self, other, :le)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Constraint] the row +self >= other+
|
|
124
|
+
def >=(other)
|
|
125
|
+
Constraint.build(self, other, :ge)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Builds an *equality row*, not a boolean — +x == 5+ is a constraint.
|
|
129
|
+
#
|
|
130
|
+
# A non-numeric, non-expression operand falls back to identity, so an
|
|
131
|
+
# incidental comparison (+x == nil+, +x == "x"+) still answers +false+ instead
|
|
132
|
+
# of raising. Hash and Set membership are unaffected either way: they key off
|
|
133
|
+
# +hash+/+eql?+, which +Variable+ defines separately.
|
|
134
|
+
#
|
|
135
|
+
# @return [Constraint, Boolean]
|
|
136
|
+
def ==(other)
|
|
137
|
+
return equal?(other) unless Expression.castable?(other)
|
|
138
|
+
|
|
139
|
+
Constraint.build(self, other, :eq)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A disequality is not representable on the wire, and Ruby would otherwise
|
|
143
|
+
# derive it from +==+ and silently answer +false+ for every row.
|
|
144
|
+
#
|
|
145
|
+
# @raise [UnsupportedExpression] always, for an expression-like operand
|
|
146
|
+
def !=(other)
|
|
147
|
+
return !equal?(other) unless Expression.castable?(other)
|
|
148
|
+
|
|
149
|
+
raise UnsupportedExpression,
|
|
150
|
+
"!= is not a constraint the wire schema can carry; model it with a binary and a big-M row"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Lets Ruby fold a numeric on the left into an expression, so +3 * x+ and
|
|
154
|
+
# +10 - x+ dispatch here instead of failing inside +Integer+/+Float+.
|
|
155
|
+
#
|
|
156
|
+
# @param other [Numeric]
|
|
157
|
+
# @return [Array(Expression, Expression)]
|
|
158
|
+
def coerce(other)
|
|
159
|
+
[Expression.cast(other), to_expr]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A decision variable, owned by the +Model+ that created it.
|
|
164
|
+
#
|
|
165
|
+
# +index+ is the variable's position in its model; it also fixes the canonical
|
|
166
|
+
# order of a quadratic term's two factors, so +x * y+ and +y * x+ land on the
|
|
167
|
+
# same coefficient.
|
|
168
|
+
class Variable
|
|
169
|
+
include Operators
|
|
170
|
+
|
|
171
|
+
# @return [Model] the model that declared this variable
|
|
172
|
+
attr_reader :model
|
|
173
|
+
# @return [String] the wire name; solutions come back keyed by it
|
|
174
|
+
attr_reader :name
|
|
175
|
+
# @return [Symbol] +:CONTINUOUS+, +:INTEGER+ or +:BINARY+
|
|
176
|
+
attr_reader :domain
|
|
177
|
+
# @return [Float] the lower bound (may be +-Float::INFINITY+)
|
|
178
|
+
attr_reader :lower
|
|
179
|
+
# @return [Float] the upper bound (may be +Float::INFINITY+)
|
|
180
|
+
attr_reader :upper
|
|
181
|
+
# @return [Float] the starting point handed to the solver
|
|
182
|
+
attr_reader :start
|
|
183
|
+
# @return [Integer] the variable's position in its model
|
|
184
|
+
attr_reader :index
|
|
185
|
+
|
|
186
|
+
def initialize(model, name, domain, lower, upper, start, index)
|
|
187
|
+
@model = model
|
|
188
|
+
@name = name
|
|
189
|
+
@domain = domain
|
|
190
|
+
@lower = lower
|
|
191
|
+
@upper = upper
|
|
192
|
+
@start = start
|
|
193
|
+
@index = index
|
|
194
|
+
freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @return [Expression] this variable as a degree-1 expression
|
|
198
|
+
def to_expr
|
|
199
|
+
Expression.new(linear: { self => 1.0 })
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Identity equality, kept separate from +==+ (which builds a constraint) so
|
|
203
|
+
# variables stay usable as Hash and Set keys.
|
|
204
|
+
def eql?(other)
|
|
205
|
+
equal?(other)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Hashes by identity, to match +eql?+.
|
|
209
|
+
def hash
|
|
210
|
+
object_id.hash
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [String] the variable's wire name
|
|
214
|
+
def to_s
|
|
215
|
+
@name
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [String] name, domain and bounds
|
|
219
|
+
def inspect
|
|
220
|
+
"#<Quicopt::Variable #{@name} #{@domain} [#{@lower}, #{@upper}]>"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A normalised polynomial of degree ≤ 2 over a model's variables:
|
|
225
|
+
# +constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ+.
|
|
226
|
+
#
|
|
227
|
+
# Instances are immutable — every operator returns a new expression — so an
|
|
228
|
+
# expression can be shared, reused and stored without aliasing surprises.
|
|
229
|
+
class Expression
|
|
230
|
+
include Operators
|
|
231
|
+
|
|
232
|
+
# @return [Float] the constant term
|
|
233
|
+
attr_reader :constant
|
|
234
|
+
# @return [Hash{Variable=>Float}] linear coefficients, in insertion order
|
|
235
|
+
attr_reader :linear
|
|
236
|
+
# @return [Hash{Array(Variable,Variable)=>Float}] quadratic coefficients,
|
|
237
|
+
# keyed by the factor pair in model order, in insertion order
|
|
238
|
+
attr_reader :quadratic
|
|
239
|
+
|
|
240
|
+
def initialize(constant: 0.0, linear: {}, quadratic: {})
|
|
241
|
+
@constant = constant.to_f
|
|
242
|
+
@linear = linear.freeze
|
|
243
|
+
@quadratic = quadratic.freeze
|
|
244
|
+
freeze
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @return [Boolean] whether +value+ can become an expression
|
|
248
|
+
def self.castable?(value)
|
|
249
|
+
value.is_a?(Expression) || value.is_a?(Variable) || value.is_a?(Numeric)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Coerce a number, variable or expression into an +Expression+.
|
|
253
|
+
#
|
|
254
|
+
# @raise [TypeError] for anything else
|
|
255
|
+
def self.cast(value)
|
|
256
|
+
case value
|
|
257
|
+
when Expression then value
|
|
258
|
+
when Variable then value.to_expr
|
|
259
|
+
when Numeric then new(constant: value)
|
|
260
|
+
else
|
|
261
|
+
raise TypeError, "cannot use #{value.class} in a Quicopt expression"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @return [Expression] self
|
|
266
|
+
def to_expr
|
|
267
|
+
self
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [Integer] 0, 1 or 2 — the highest degree present
|
|
271
|
+
def degree
|
|
272
|
+
return 2 unless @quadratic.empty?
|
|
273
|
+
return 1 unless @linear.empty?
|
|
274
|
+
|
|
275
|
+
0
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @return [Boolean] whether this is a bare constant
|
|
279
|
+
def constant?
|
|
280
|
+
degree.zero?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [Expression] +self + other+
|
|
284
|
+
def plus(other)
|
|
285
|
+
Expression.new(constant: @constant + other.constant,
|
|
286
|
+
linear: Expression.merge(@linear, other.linear),
|
|
287
|
+
quadratic: Expression.merge(@quadratic, other.quadratic))
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @return [Expression] +self+ scaled by the numeric +factor+
|
|
291
|
+
def scale(factor)
|
|
292
|
+
f = factor.to_f
|
|
293
|
+
return Expression.new if f.zero?
|
|
294
|
+
|
|
295
|
+
Expression.new(constant: @constant * f,
|
|
296
|
+
linear: @linear.transform_values { |c| c * f },
|
|
297
|
+
quadratic: @quadratic.transform_values { |c| c * f })
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Multiply two expressions, staying inside the degree-2 subset.
|
|
301
|
+
#
|
|
302
|
+
# A constant factor just scales; two linear factors expand into quadratic
|
|
303
|
+
# terms. Anything else would exceed degree 2 and raises instead.
|
|
304
|
+
#
|
|
305
|
+
# @return [Expression]
|
|
306
|
+
# @raise [UnsupportedExpression] if the product would exceed degree 2
|
|
307
|
+
def times(other)
|
|
308
|
+
return scale(other.constant) if other.constant?
|
|
309
|
+
return other.scale(@constant) if constant?
|
|
310
|
+
|
|
311
|
+
if degree > 1 || other.degree > 1
|
|
312
|
+
raise UnsupportedExpression,
|
|
313
|
+
"product of degree #{degree} and degree #{other.degree} exceeds the quadratic subset"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
quadratic = {}
|
|
317
|
+
@linear.each do |va, ca|
|
|
318
|
+
other.linear.each do |vb, cb|
|
|
319
|
+
key = Expression.pair(va, vb)
|
|
320
|
+
quadratic[key] = (quadratic[key] || 0.0) + (ca * cb)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
Expression.new(constant: @constant * other.constant,
|
|
324
|
+
linear: Expression.merge(@linear.transform_values { |c| c * other.constant },
|
|
325
|
+
other.linear.transform_values { |c| c * @constant }),
|
|
326
|
+
quadratic: Expression.prune(quadratic))
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Every variable this expression mentions, in first-use order.
|
|
330
|
+
#
|
|
331
|
+
# @return [Array<Variable>]
|
|
332
|
+
def variables
|
|
333
|
+
seen = @linear.keys
|
|
334
|
+
@quadratic.each_key { |a, b| seen |= [a, b] }
|
|
335
|
+
seen
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Renders the polynomial, e.g. +3*x + 2*y - 4+.
|
|
339
|
+
def to_s
|
|
340
|
+
terms = []
|
|
341
|
+
terms << Format.number(@constant) unless @constant.zero?
|
|
342
|
+
@linear.each { |v, c| terms << Format.term(c, v.name) }
|
|
343
|
+
@quadratic.each { |(a, b), c| terms << Format.term(c, "#{a.name}*#{b.name}") }
|
|
344
|
+
return "0" if terms.empty?
|
|
345
|
+
|
|
346
|
+
terms.join(" + ").gsub(" + -", " - ")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @return [String] the polynomial, tagged with the class
|
|
350
|
+
def inspect
|
|
351
|
+
"#<Quicopt::Expression #{self}>"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @return [Boolean] value equality — the same constant and the same
|
|
355
|
+
# coefficient maps. Named +eq?+ because +==+ builds a constraint row.
|
|
356
|
+
def eq?(other)
|
|
357
|
+
other.is_a?(Expression) && @constant == other.constant &&
|
|
358
|
+
@linear == other.linear && @quadratic == other.quadratic
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Sum two coefficient maps, dropping terms that cancel exactly.
|
|
362
|
+
#
|
|
363
|
+
# @api private
|
|
364
|
+
def self.merge(left, right)
|
|
365
|
+
return left.dup if right.empty?
|
|
366
|
+
return right.dup if left.empty?
|
|
367
|
+
|
|
368
|
+
out = left.dup
|
|
369
|
+
right.each { |k, c| out[k] = (out[k] || 0.0) + c }
|
|
370
|
+
prune(out)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Drop exactly-zero coefficients, so a cancelled term leaves no trace on the
|
|
374
|
+
# wire.
|
|
375
|
+
#
|
|
376
|
+
# @api private
|
|
377
|
+
def self.prune(terms)
|
|
378
|
+
terms.reject { |_, c| c.zero? }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# The canonical key for a quadratic term: the two factors in model order, so
|
|
382
|
+
# +x*y+ and +y*x+ accumulate on one coefficient.
|
|
383
|
+
#
|
|
384
|
+
# @api private
|
|
385
|
+
def self.pair(left, right)
|
|
386
|
+
(left.index <= right.index ? [left, right] : [right, left]).freeze
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# One constraint row: a residual expression compared against zero.
|
|
391
|
+
#
|
|
392
|
+
# +expr+ is +lhs - rhs+, so the row is +expr <= 0+, +expr >= 0+ or +expr == 0+.
|
|
393
|
+
# The lowering in +Quicopt::Wire+ splits that into the body +f+ (the variable
|
|
394
|
+
# terms) and the numeric +bound+ (the constant, moved to the other side), which
|
|
395
|
+
# is the shape the wire's function-in-set rows take.
|
|
396
|
+
class Constraint
|
|
397
|
+
# The comparison operator each sense renders as.
|
|
398
|
+
SENSES = { le: "<=", ge: ">=", eq: "==" }.freeze
|
|
399
|
+
|
|
400
|
+
# @return [Expression] the residual +lhs - rhs+
|
|
401
|
+
attr_reader :expr
|
|
402
|
+
# @return [Symbol] +:le+, +:ge+ or +:eq+
|
|
403
|
+
attr_reader :sense
|
|
404
|
+
|
|
405
|
+
# Build a row from the two sides of a comparison.
|
|
406
|
+
def self.build(lhs, rhs, sense)
|
|
407
|
+
new(Expression.cast(lhs).plus(Expression.cast(rhs).scale(-1.0)), sense)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def initialize(expr, sense)
|
|
411
|
+
raise ArgumentError, "unknown constraint sense #{sense.inspect}" unless SENSES.key?(sense)
|
|
412
|
+
|
|
413
|
+
@expr = expr
|
|
414
|
+
@sense = sense
|
|
415
|
+
freeze
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The row body: the variable terms alone, with the constant moved to +bound+.
|
|
419
|
+
#
|
|
420
|
+
# @return [Expression]
|
|
421
|
+
def body
|
|
422
|
+
Expression.new(linear: @expr.linear, quadratic: @expr.quadratic)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The right-hand side after moving the constant across.
|
|
426
|
+
#
|
|
427
|
+
# @return [Float]
|
|
428
|
+
def bound
|
|
429
|
+
-@expr.constant
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Renders the row as +body <op> bound+.
|
|
433
|
+
def to_s
|
|
434
|
+
"#{body} #{SENSES[@sense]} #{Format.number(bound)}"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# @return [String] the row, tagged with the class
|
|
438
|
+
def inspect
|
|
439
|
+
"#<Quicopt::Constraint #{self}>"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# An optimization model: variables, one objective, and constraint rows.
|
|
444
|
+
#
|
|
445
|
+
# m = Quicopt::Model.new
|
|
446
|
+
# x = m.int_var(0, 4, "x")
|
|
447
|
+
# y = m.bin_var("y")
|
|
448
|
+
# m.maximize(3 * x + 2 * y)
|
|
449
|
+
# m.add(x + y <= 5)
|
|
450
|
+
#
|
|
451
|
+
# The model is the authoring surface; +to_program+ / +encode+ lower it to the
|
|
452
|
+
# wire IR, and +Quicopt::Client#solve+ ships it.
|
|
453
|
+
class Model
|
|
454
|
+
# @return [Array<Variable>] the declared variables, in declaration order
|
|
455
|
+
attr_reader :variables
|
|
456
|
+
# @return [Array<Constraint>] the constraint rows, in insertion order
|
|
457
|
+
attr_reader :constraints
|
|
458
|
+
# @return [Expression] the objective (the zero expression until set)
|
|
459
|
+
attr_reader :objective
|
|
460
|
+
# @return [String] +"min"+ or +"max"+
|
|
461
|
+
attr_reader :sense
|
|
462
|
+
|
|
463
|
+
def initialize
|
|
464
|
+
@variables = []
|
|
465
|
+
@by_name = {}
|
|
466
|
+
@constraints = []
|
|
467
|
+
@objective = Expression.new
|
|
468
|
+
@sense = "min"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Declare a continuous variable.
|
|
472
|
+
#
|
|
473
|
+
# @param lower [Numeric, nil] the lower bound; +nil+ means unbounded below
|
|
474
|
+
# @param upper [Numeric, nil] the upper bound; +nil+ means unbounded above
|
|
475
|
+
# @param name [String, nil] the wire name; auto-generated when omitted
|
|
476
|
+
# @param start [Numeric, nil] the starting point; defaults to 0 clamped into
|
|
477
|
+
# the bounds, matching the other clients' front-ends
|
|
478
|
+
# @return [Variable]
|
|
479
|
+
def num_var(lower = nil, upper = nil, name = nil, start: nil)
|
|
480
|
+
declare(:CONTINUOUS, lower, upper, name, start)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Declare an integer variable.
|
|
484
|
+
#
|
|
485
|
+
# Bounds of exactly +[0, 1]+ declare a +:BINARY+ variable rather than an
|
|
486
|
+
# integer one — the same normalisation the other front-ends apply, so the
|
|
487
|
+
# same model reaches the service as the same IR whichever client authored it.
|
|
488
|
+
#
|
|
489
|
+
# @return [Variable]
|
|
490
|
+
def int_var(lower = nil, upper = nil, name = nil, start: nil)
|
|
491
|
+
lo = coerce_bound(lower, -Float::INFINITY)
|
|
492
|
+
hi = coerce_bound(upper, Float::INFINITY)
|
|
493
|
+
domain = lo.zero? && hi == 1.0 ? :BINARY : :INTEGER
|
|
494
|
+
declare(domain, lo, hi, name, start)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Declare a binary variable.
|
|
498
|
+
#
|
|
499
|
+
# @return [Variable]
|
|
500
|
+
def bin_var(name = nil, start: nil)
|
|
501
|
+
declare(:BINARY, 0.0, 1.0, name, start)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Look a variable up by its wire name.
|
|
505
|
+
#
|
|
506
|
+
# @return [Variable, nil]
|
|
507
|
+
def variable(name)
|
|
508
|
+
@by_name[name.to_s]
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Minimize +expr+.
|
|
512
|
+
#
|
|
513
|
+
# @return [Expression] the objective now set
|
|
514
|
+
def minimize(expr)
|
|
515
|
+
set_objective(expr, "min")
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Maximize +expr+.
|
|
519
|
+
#
|
|
520
|
+
# @return [Expression] the objective now set
|
|
521
|
+
def maximize(expr)
|
|
522
|
+
set_objective(expr, "max")
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Add a constraint row built by +<=+, +>=+ or +==+.
|
|
526
|
+
#
|
|
527
|
+
# @param constraint [Constraint]
|
|
528
|
+
# @return [Constraint] the row just added
|
|
529
|
+
# @raise [TypeError] if handed something that is not a constraint — most
|
|
530
|
+
# often a bare expression, or a comparison Ruby already reduced to a boolean
|
|
531
|
+
def add(constraint)
|
|
532
|
+
unless constraint.is_a?(Constraint)
|
|
533
|
+
raise TypeError,
|
|
534
|
+
"add expects a constraint such as `x + y <= 5`, got #{constraint.class}"
|
|
535
|
+
end
|
|
536
|
+
check_ownership(constraint.expr)
|
|
537
|
+
@constraints << constraint
|
|
538
|
+
constraint
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Lower this model to the wire IR.
|
|
542
|
+
#
|
|
543
|
+
# @return [Quicopt::Modeler::V1::Program]
|
|
544
|
+
def to_program
|
|
545
|
+
Wire.program(self)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Lower this model and serialize it to wire bytes.
|
|
549
|
+
#
|
|
550
|
+
# @return [String] binary protobuf bytes
|
|
551
|
+
def encode
|
|
552
|
+
Wire.encode(self)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Renders the whole model: objective, rows, then declarations.
|
|
556
|
+
def to_s
|
|
557
|
+
lines = ["#{@sense} #{@objective}"]
|
|
558
|
+
lines << "subject to" unless @constraints.empty?
|
|
559
|
+
@constraints.each { |c| lines << " #{c}" }
|
|
560
|
+
@variables.each do |v|
|
|
561
|
+
lines << " #{v.name} in #{v.domain} [#{Format.number(v.lower)}, #{Format.number(v.upper)}]"
|
|
562
|
+
end
|
|
563
|
+
lines.join("\n")
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# @return [String] a one-line summary: variable and row counts
|
|
567
|
+
def inspect
|
|
568
|
+
"#<Quicopt::Model #{@variables.size} vars, #{@constraints.size} constraints>"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
private
|
|
572
|
+
|
|
573
|
+
def declare(domain, lower, upper, name, start)
|
|
574
|
+
lo = coerce_bound(lower, -Float::INFINITY)
|
|
575
|
+
hi = coerce_bound(upper, Float::INFINITY)
|
|
576
|
+
raise ArgumentError, "lower bound #{lo} exceeds upper bound #{hi}" if lo > hi
|
|
577
|
+
|
|
578
|
+
nm = name.nil? ? auto_name : name.to_s
|
|
579
|
+
raise ArgumentError, "duplicate variable name #{nm.inspect}" if @by_name.key?(nm)
|
|
580
|
+
|
|
581
|
+
variable = Variable.new(self, nm, domain, lo, hi, coerce_start(start, lo, hi), @variables.size)
|
|
582
|
+
@variables << variable
|
|
583
|
+
@by_name[nm] = variable
|
|
584
|
+
variable
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def coerce_bound(value, default)
|
|
588
|
+
return default if value.nil?
|
|
589
|
+
raise ArgumentError, "bound must be a number, got #{value.class}" unless value.is_a?(Numeric)
|
|
590
|
+
|
|
591
|
+
value.to_f
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# 0 clamped into the bounds — finite for any bounds, matching the sibling
|
|
595
|
+
# front-ends so an unset start lowers identically across clients.
|
|
596
|
+
def coerce_start(start, lower, upper)
|
|
597
|
+
return [[0.0, lower].max, upper].min if start.nil?
|
|
598
|
+
raise ArgumentError, "start must be a number, got #{start.class}" unless start.is_a?(Numeric)
|
|
599
|
+
|
|
600
|
+
start.to_f
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def auto_name
|
|
604
|
+
n = @variables.size + 1
|
|
605
|
+
n += 1 while @by_name.key?("x#{n}")
|
|
606
|
+
"x#{n}"
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def set_objective(expr, sense)
|
|
610
|
+
objective = Expression.cast(expr)
|
|
611
|
+
check_ownership(objective)
|
|
612
|
+
@objective = objective
|
|
613
|
+
@sense = sense
|
|
614
|
+
objective
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Reject variables belonging to another model: their names would collide
|
|
618
|
+
# arbitrarily and their coefficients would silently merge on the wire.
|
|
619
|
+
def check_ownership(expr)
|
|
620
|
+
expr.variables.each do |v|
|
|
621
|
+
next if v.model.equal?(self)
|
|
622
|
+
|
|
623
|
+
raise ArgumentError, "variable #{v.name.inspect} belongs to a different Quicopt::Model"
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
6
|
+
# source: quicopt/modeler/v1/program.proto
|
|
7
|
+
|
|
8
|
+
require 'google/protobuf'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
descriptor_data = "\n quicopt/modeler/v1/program.proto\x12\x12quicopt.modeler.v1\"2\n\tIndexElem\x12\r\n\x03int\x18\x01 \x01(\x03H\x00\x12\x0e\n\x04name\x18\x02 \x01(\tH\x00\x42\x06\n\x04kind\"5\n\x05Index\x12,\n\x05\x65lems\x18\x01 \x03(\x0b\x32\x1d.quicopt.modeler.v1.IndexElem\"\xdc\x01\n\nExpression\x12\x12\n\x08\x63onstant\x18\x01 \x01(\x01H\x00\x12-\n\x05param\x18\x02 \x01(\x0b\x32\x1c.quicopt.modeler.v1.ParamRefH\x00\x12)\n\x03var\x18\x03 \x01(\x0b\x32\x1a.quicopt.modeler.v1.VarRefH\x00\x12*\n\x05\x61pply\x18\x04 \x01(\x0b\x32\x19.quicopt.modeler.v1.ApplyH\x00\x12,\n\x06reduce\x18\x05 \x01(\x0b\x32\x1a.quicopt.modeler.v1.ReduceH\x00\x42\x06\n\x04node\"B\n\x08ParamRef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12(\n\x05index\x18\x02 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\"@\n\x06VarRef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12(\n\x05index\x18\x02 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\"A\n\x05\x41pply\x12\n\n\x02op\x18\x01 \x01(\t\x12,\n\x04\x61rgs\x18\x02 \x03(\x0b\x32\x1e.quicopt.modeler.v1.Expression\"?\n\x06SetRef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\'\n\x04\x61rgs\x18\x02 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\"\xa7\x01\n\x06Reduce\x12\n\n\x02op\x18\x01 \x01(\t\x12\x0b\n\x03idx\x18\x02 \x01(\t\x12(\n\x04over\x18\x03 \x01(\x0b\x32\x1a.quicopt.modeler.v1.SetRef\x12,\n\x04\x62ody\x18\x04 \x01(\x0b\x32\x1e.quicopt.modeler.v1.Expression\x12,\n\x04\x63ond\x18\x05 \x01(\x0b\x32\x1e.quicopt.modeler.v1.Expression\"\x06\n\x04Zero\"\x08\n\x06Nonneg\"_\n\tIndicator\x12\'\n\x03\x62in\x18\x01 \x01(\x0b\x32\x1a.quicopt.modeler.v1.VarRef\x12)\n\x05inner\x18\x02 \x01(\x0b\x32\x1a.quicopt.modeler.v1.ConSet\"\x9c\x01\n\x06\x43onSet\x12(\n\x04zero\x18\x01 \x01(\x0b\x32\x18.quicopt.modeler.v1.ZeroH\x00\x12,\n\x06nonneg\x18\x02 \x01(\x0b\x32\x1a.quicopt.modeler.v1.NonnegH\x00\x12\x32\n\tindicator\x18\x03 \x01(\x0b\x32\x1d.quicopt.modeler.v1.IndicatorH\x00\x42\x06\n\x04kind\"D\n\x0cQuantBinding\x12\x0b\n\x03idx\x18\x01 \x01(\t\x12\'\n\x03set\x18\x02 \x01(\x0b\x32\x1a.quicopt.modeler.v1.SetRef\"\x90\x01\n\nConstraint\x12)\n\x01\x66\x18\x01 \x01(\x0b\x32\x1e.quicopt.modeler.v1.Expression\x12\'\n\x03set\x18\x02 \x01(\x0b\x32\x1a.quicopt.modeler.v1.ConSet\x12.\n\x04over\x18\x03 \x03(\x0b\x32 .quicopt.modeler.v1.QuantBinding\"2\n\x05\x42ound\x12\x10\n\x06scalar\x18\x01 \x01(\x01H\x00\x12\x0f\n\x05param\x18\x02 \x01(\tH\x00\x42\x06\n\x04kind\"\xb4\x01\n\x07VarDecl\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x61xes\x18\x02 \x03(\t\x12*\n\x06\x64omain\x18\x03 \x01(\x0e\x32\x1a.quicopt.modeler.v1.Domain\x12(\n\x05lower\x18\x04 \x01(\x0b\x32\x19.quicopt.modeler.v1.Bound\x12(\n\x05upper\x18\x05 \x01(\x0b\x32\x19.quicopt.modeler.v1.Bound\x12\r\n\x05start\x18\x06 \x01(\x01\"I\n\x08IndexSet\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\x08\x65lements\x18\x02 \x03(\x0b\x32\x1d.quicopt.modeler.v1.IndexElem\"g\n\x0fIndexedSetFibre\x12&\n\x03key\x18\x01 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\x12,\n\x05value\x18\x02 \x03(\x0b\x32\x1d.quicopt.modeler.v1.IndexElem\"O\n\nIndexedSet\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x33\n\x06\x66ibres\x18\x02 \x03(\x0b\x32#.quicopt.modeler.v1.IndexedSetFibre\"C\n\nParamEntry\x12&\n\x03key\x18\x01 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\x12\r\n\x05value\x18\x02 \x01(\x01\"K\n\nParamTable\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\x07\x65ntries\x18\x02 \x03(\x0b\x32\x1e.quicopt.modeler.v1.ParamEntry\"P\n\x08\x46ixEntry\x12\x0b\n\x03var\x18\x01 \x01(\t\x12(\n\x05index\x18\x02 \x01(\x0b\x32\x19.quicopt.modeler.v1.Index\x12\r\n\x05value\x18\x03 \x01(\x01\"\xe8\x02\n\x07Program\x12*\n\x04sets\x18\x01 \x03(\x0b\x32\x1c.quicopt.modeler.v1.IndexSet\x12\x34\n\x0cindexed_sets\x18\x02 \x03(\x0b\x32\x1e.quicopt.modeler.v1.IndexedSet\x12.\n\x06params\x18\x03 \x03(\x0b\x32\x1e.quicopt.modeler.v1.ParamTable\x12)\n\x04vars\x18\x04 \x03(\x0b\x32\x1b.quicopt.modeler.v1.VarDecl\x12\x31\n\tobjective\x18\x05 \x01(\x0b\x32\x1e.quicopt.modeler.v1.Expression\x12\r\n\x05sense\x18\x06 \x01(\t\x12\x33\n\x0b\x63onstraints\x18\x07 \x03(\x0b\x32\x1e.quicopt.modeler.v1.Constraint\x12)\n\x03\x66ix\x18\x08 \x03(\x0b\x32\x1c.quicopt.modeler.v1.FixEntry\";\n\tParamData\x12.\n\x06params\x18\x01 \x03(\x0b\x32\x1e.quicopt.modeler.v1.ParamTable\"0\n\x05OpDef\x12\n\n\x02op\x18\x01 \x01(\t\x12\r\n\x05\x61rity\x18\x02 \x01(\x05\x12\x0c\n\x04tags\x18\x03 \x03(\t\"B\n\x07\x43\x61talog\x12\x0f\n\x07version\x18\x01 \x01(\r\x12&\n\x03ops\x18\x02 \x03(\x0b\x32\x19.quicopt.modeler.v1.OpDef*I\n\x06\x44omain\x12\x16\n\x12\x44OMAIN_UNSPECIFIED\x10\x00\x12\x0e\n\nCONTINUOUS\x10\x01\x12\x0b\n\x07INTEGER\x10\x02\x12\n\n\x06\x42INARY\x10\x03\x62\x06proto3"
|
|
12
|
+
|
|
13
|
+
pool = ::Google::Protobuf::DescriptorPool.generated_pool
|
|
14
|
+
pool.add_serialized_file(descriptor_data)
|
|
15
|
+
|
|
16
|
+
module Quicopt
|
|
17
|
+
module Modeler
|
|
18
|
+
module V1
|
|
19
|
+
IndexElem = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.IndexElem").msgclass
|
|
20
|
+
Index = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Index").msgclass
|
|
21
|
+
Expression = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Expression").msgclass
|
|
22
|
+
ParamRef = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.ParamRef").msgclass
|
|
23
|
+
VarRef = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.VarRef").msgclass
|
|
24
|
+
Apply = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Apply").msgclass
|
|
25
|
+
SetRef = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.SetRef").msgclass
|
|
26
|
+
Reduce = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Reduce").msgclass
|
|
27
|
+
Zero = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Zero").msgclass
|
|
28
|
+
Nonneg = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Nonneg").msgclass
|
|
29
|
+
Indicator = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Indicator").msgclass
|
|
30
|
+
ConSet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.ConSet").msgclass
|
|
31
|
+
QuantBinding = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.QuantBinding").msgclass
|
|
32
|
+
Constraint = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Constraint").msgclass
|
|
33
|
+
Bound = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Bound").msgclass
|
|
34
|
+
VarDecl = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.VarDecl").msgclass
|
|
35
|
+
IndexSet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.IndexSet").msgclass
|
|
36
|
+
IndexedSetFibre = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.IndexedSetFibre").msgclass
|
|
37
|
+
IndexedSet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.IndexedSet").msgclass
|
|
38
|
+
ParamEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.ParamEntry").msgclass
|
|
39
|
+
ParamTable = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.ParamTable").msgclass
|
|
40
|
+
FixEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.FixEntry").msgclass
|
|
41
|
+
Program = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Program").msgclass
|
|
42
|
+
ParamData = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.ParamData").msgclass
|
|
43
|
+
OpDef = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.OpDef").msgclass
|
|
44
|
+
Catalog = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Catalog").msgclass
|
|
45
|
+
Domain = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("quicopt.modeler.v1.Domain").enummodule
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Quicopt
|
|
6
|
+
# The gem version, tracking the client — not the wire schema, which is
|
|
7
|
+
# versioned separately by its protobuf package (`quicopt.modeler.v1`).
|
|
8
|
+
VERSION = "0.1.0"
|
|
9
|
+
end
|