quicopt 0.1.0 → 0.3.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 +4 -4
- data/.yardopts +2 -1
- data/README.md +262 -33
- data/lib/quicopt/client.rb +156 -49
- data/lib/quicopt/indicators.rb +235 -0
- data/lib/quicopt/model.rb +442 -57
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +437 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +171 -36
- data/lib/quicopt.rb +7 -6
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
data/lib/quicopt/model.rb
CHANGED
|
@@ -9,24 +9,28 @@
|
|
|
9
9
|
# expressions (+x + y <= 5+) builds a +Constraint+ — neither evaluates to a number
|
|
10
10
|
# or a boolean.
|
|
11
11
|
#
|
|
12
|
-
# Expressions are
|
|
12
|
+
# Expressions are collected as they are built: an +Expression+ is always a
|
|
13
13
|
# constant plus a map of linear terms plus a map of quadratic terms, never an
|
|
14
14
|
# unreduced operator tree. That is what makes the degree check exact — anything
|
|
15
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
|
|
16
|
+
# reaching the service, per the "raise loudly, never approximate" rule the other
|
|
17
17
|
# clients follow.
|
|
18
18
|
#
|
|
19
|
-
# Only flat (unindexed) variables exist here. The
|
|
20
|
-
# sets
|
|
21
|
-
#
|
|
19
|
+
# Only flat (unindexed) variables exist here. The message format also carries
|
|
20
|
+
# index sets and quantified constraints; the Ruby DSL deliberately does not — the
|
|
21
|
+
# Python and Julia clients reach those. It does carry permutations, partitions
|
|
22
|
+
# and lookup tables (+quicopt/structured+) and terms that switch
|
|
23
|
+
# (+quicopt/indicators+), which enter an +Expression+ as factors of degree 1,
|
|
24
|
+
# alongside the variables.
|
|
22
25
|
|
|
23
26
|
module Quicopt
|
|
24
27
|
# Base class of every error this gem raises.
|
|
25
28
|
class Error < StandardError; end
|
|
26
29
|
|
|
27
|
-
# An expression
|
|
28
|
-
# product of three variables, or a division by a variable.
|
|
29
|
-
# of construction so the offending term is still in the
|
|
30
|
+
# An expression Quicopt cannot express, because it is neither linear nor
|
|
31
|
+
# quadratic — a product of three variables, say, or a division by a variable.
|
|
32
|
+
# Raised at the point of construction so the offending term is still in the
|
|
33
|
+
# backtrace.
|
|
30
34
|
class UnsupportedExpression < Error; end
|
|
31
35
|
|
|
32
36
|
# Number and term rendering shared by the DSL's +to_s+ methods. Presentation
|
|
@@ -76,7 +80,7 @@ module Quicopt
|
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
# Division by a numeric constant only — dividing by an expression is not a
|
|
79
|
-
# polynomial and
|
|
83
|
+
# polynomial, and a Quicopt model is a polynomial.
|
|
80
84
|
#
|
|
81
85
|
# @return [Expression] +self / divisor+
|
|
82
86
|
# @raise [UnsupportedExpression] if +divisor+ is not a nonzero number
|
|
@@ -90,7 +94,7 @@ module Quicopt
|
|
|
90
94
|
to_expr.scale(1.0 / divisor)
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
# Integer powers 0, 1 and 2 only —
|
|
97
|
+
# Integer powers 0, 1 and 2 only — a Quicopt model is linear or quadratic.
|
|
94
98
|
#
|
|
95
99
|
# @return [Expression] +self ** exponent+
|
|
96
100
|
# @raise [UnsupportedExpression] on any other exponent
|
|
@@ -101,7 +105,8 @@ module Quicopt
|
|
|
101
105
|
when 2 then to_expr.times(to_expr)
|
|
102
106
|
else
|
|
103
107
|
raise UnsupportedExpression,
|
|
104
|
-
"exponent #{exponent.inspect} is
|
|
108
|
+
"exponent #{exponent.inspect} is not supported: a Quicopt model is " \
|
|
109
|
+
"linear or quadratic, so the exponent must be 0, 1 or 2"
|
|
105
110
|
end
|
|
106
111
|
end
|
|
107
112
|
|
|
@@ -125,6 +130,21 @@ module Quicopt
|
|
|
125
130
|
Constraint.build(self, other, :ge)
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
# A strict comparison, for +Quicopt.holds+ only: no constraint row can be
|
|
134
|
+
# strict, so +Model#add+ refuses it.
|
|
135
|
+
#
|
|
136
|
+
# @return [Constraint] the comparison +self < other+
|
|
137
|
+
def <(other)
|
|
138
|
+
Constraint.build(self, other, :lt)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A strict comparison, for +Quicopt.holds+ only (see +<+).
|
|
142
|
+
#
|
|
143
|
+
# @return [Constraint] the comparison +self > other+
|
|
144
|
+
def >(other)
|
|
145
|
+
Constraint.build(self, other, :gt)
|
|
146
|
+
end
|
|
147
|
+
|
|
128
148
|
# Builds an *equality row*, not a boolean — +x == 5+ is a constraint.
|
|
129
149
|
#
|
|
130
150
|
# A non-numeric, non-expression operand falls back to identity, so an
|
|
@@ -139,15 +159,15 @@ module Quicopt
|
|
|
139
159
|
Constraint.build(self, other, :eq)
|
|
140
160
|
end
|
|
141
161
|
|
|
142
|
-
# A
|
|
143
|
-
#
|
|
162
|
+
# A "not equal to" comparison, for +Quicopt.holds+ only: +Model#add+ refuses
|
|
163
|
+
# it as a row. Defined so that Ruby does not derive +!=+ from +==+ and
|
|
164
|
+
# silently answer +false+.
|
|
144
165
|
#
|
|
145
|
-
# @
|
|
166
|
+
# @return [Constraint, Boolean]
|
|
146
167
|
def !=(other)
|
|
147
168
|
return !equal?(other) unless Expression.castable?(other)
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
"!= is not a constraint the wire schema can carry; model it with a binary and a big-M row"
|
|
170
|
+
Constraint.build(self, other, :ne)
|
|
151
171
|
end
|
|
152
172
|
|
|
153
173
|
# Lets Ruby fold a numeric on the left into an expression, so +3 * x+ and
|
|
@@ -162,17 +182,19 @@ module Quicopt
|
|
|
162
182
|
|
|
163
183
|
# A decision variable, owned by the +Model+ that created it.
|
|
164
184
|
#
|
|
165
|
-
# +index+ is the variable's position in its model; it also fixes the
|
|
166
|
-
#
|
|
185
|
+
# +index+ is the variable's position in its model; it also fixes the order of a
|
|
186
|
+
# quadratic term's two factors (see +order+), so +x * y+ and +y * x+ land on the
|
|
167
187
|
# same coefficient.
|
|
168
188
|
class Variable
|
|
169
189
|
include Operators
|
|
170
190
|
|
|
171
191
|
# @return [Model] the model that declared this variable
|
|
172
192
|
attr_reader :model
|
|
173
|
-
# @return [String] the
|
|
193
|
+
# @return [String] the variable's name; solutions come back keyed by it
|
|
174
194
|
attr_reader :name
|
|
175
|
-
# @return [Symbol] +:CONTINUOUS+, +:INTEGER
|
|
195
|
+
# @return [Symbol] +:CONTINUOUS+, +:INTEGER+, +:BINARY+, or one of the two
|
|
196
|
+
# domains that also allow 0: +:SEMICONTINUOUS+ (0 or any value within the
|
|
197
|
+
# bounds) and +:SEMIINTEGER+ (0 or a whole number within them)
|
|
176
198
|
attr_reader :domain
|
|
177
199
|
# @return [Float] the lower bound (may be +-Float::INFINITY+)
|
|
178
200
|
attr_reader :lower
|
|
@@ -199,6 +221,34 @@ module Quicopt
|
|
|
199
221
|
Expression.new(linear: { self => 1.0 })
|
|
200
222
|
end
|
|
201
223
|
|
|
224
|
+
# Whether the variable is in use: 1 when its value is not 0, else 0. Made
|
|
225
|
+
# for a variable declared with +semi: true+, which is 0 or within its
|
|
226
|
+
# bounds: a delivery of either nothing or 10 to 25 units, say. A fee per
|
|
227
|
+
# delivery is then
|
|
228
|
+
#
|
|
229
|
+
# fee * ship.active
|
|
230
|
+
#
|
|
231
|
+
# and a model that is otherwise linear stays one the service solves exactly.
|
|
232
|
+
#
|
|
233
|
+
# @return [Expression] a 0/1 expression
|
|
234
|
+
def active
|
|
235
|
+
Conditions.term(:indicator, to_expr)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Where this variable sorts among its model's terms: before every read of a
|
|
239
|
+
# permutation or lookup table, then by +index+.
|
|
240
|
+
#
|
|
241
|
+
# @api private
|
|
242
|
+
def order
|
|
243
|
+
[0, @index]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [Array<Variable>] this variable alone
|
|
247
|
+
# @api private
|
|
248
|
+
def variables
|
|
249
|
+
[self]
|
|
250
|
+
end
|
|
251
|
+
|
|
202
252
|
# Identity equality, kept separate from +==+ (which builds a constraint) so
|
|
203
253
|
# variables stay usable as Hash and Set keys.
|
|
204
254
|
def eql?(other)
|
|
@@ -210,7 +260,7 @@ module Quicopt
|
|
|
210
260
|
object_id.hash
|
|
211
261
|
end
|
|
212
262
|
|
|
213
|
-
# @return [String] the variable's
|
|
263
|
+
# @return [String] the variable's name
|
|
214
264
|
def to_s
|
|
215
265
|
@name
|
|
216
266
|
end
|
|
@@ -221,8 +271,12 @@ module Quicopt
|
|
|
221
271
|
end
|
|
222
272
|
end
|
|
223
273
|
|
|
224
|
-
# A
|
|
225
|
-
# +constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ+.
|
|
274
|
+
# A polynomial of degree ≤ 2 over a model's variables, always kept in collected
|
|
275
|
+
# form: +constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ+.
|
|
276
|
+
#
|
|
277
|
+
# A value read from a permutation (+route.item_at(0)+) or a lookup table
|
|
278
|
+
# (+dist[i, j]+) counts as one factor, like a variable: +2 * dist[i, j]+ is
|
|
279
|
+
# linear, +x * dist[i, j]+ quadratic.
|
|
226
280
|
#
|
|
227
281
|
# Instances are immutable — every operator returns a new expression — so an
|
|
228
282
|
# expression can be shared, reused and stored without aliasing surprises.
|
|
@@ -231,7 +285,8 @@ module Quicopt
|
|
|
231
285
|
|
|
232
286
|
# @return [Float] the constant term
|
|
233
287
|
attr_reader :constant
|
|
234
|
-
# @return [Hash{Variable=>Float}] linear coefficients, in insertion order
|
|
288
|
+
# @return [Hash{Variable=>Float}] linear coefficients, in insertion order.
|
|
289
|
+
# A read of a permutation or lookup table is a key here too.
|
|
235
290
|
attr_reader :linear
|
|
236
291
|
# @return [Hash{Array(Variable,Variable)=>Float}] quadratic coefficients,
|
|
237
292
|
# keyed by the factor pair in model order, in insertion order
|
|
@@ -297,7 +352,7 @@ module Quicopt
|
|
|
297
352
|
quadratic: @quadratic.transform_values { |c| c * f })
|
|
298
353
|
end
|
|
299
354
|
|
|
300
|
-
# Multiply two expressions, staying
|
|
355
|
+
# Multiply two expressions, staying at degree 2 or below.
|
|
301
356
|
#
|
|
302
357
|
# A constant factor just scales; two linear factors expand into quadratic
|
|
303
358
|
# terms. Anything else would exceed degree 2 and raises instead.
|
|
@@ -310,7 +365,8 @@ module Quicopt
|
|
|
310
365
|
|
|
311
366
|
if degree > 1 || other.degree > 1
|
|
312
367
|
raise UnsupportedExpression,
|
|
313
|
-
"
|
|
368
|
+
"multiplying degree #{degree} by degree #{other.degree} gives a cubic or " \
|
|
369
|
+
"higher term; a Quicopt model is linear or quadratic"
|
|
314
370
|
end
|
|
315
371
|
|
|
316
372
|
quadratic = {}
|
|
@@ -326,10 +382,20 @@ module Quicopt
|
|
|
326
382
|
quadratic: Expression.prune(quadratic))
|
|
327
383
|
end
|
|
328
384
|
|
|
329
|
-
# Every variable this expression mentions, in first-use order
|
|
385
|
+
# Every variable this expression mentions, in first-use order — including
|
|
386
|
+
# those in the positions of a lookup table read.
|
|
330
387
|
#
|
|
331
388
|
# @return [Array<Variable>]
|
|
332
389
|
def variables
|
|
390
|
+
factors.flat_map(&:variables).uniq
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Every factor of every term — variables and reads of permutations and lookup
|
|
394
|
+
# tables — in first-use order.
|
|
395
|
+
#
|
|
396
|
+
# @return [Array<Variable, Placement, TableEntry, Condition, Extremum>]
|
|
397
|
+
# @api private
|
|
398
|
+
def factors
|
|
333
399
|
seen = @linear.keys
|
|
334
400
|
@quadratic.each_key { |a, b| seen |= [a, b] }
|
|
335
401
|
seen
|
|
@@ -339,8 +405,8 @@ module Quicopt
|
|
|
339
405
|
def to_s
|
|
340
406
|
terms = []
|
|
341
407
|
terms << Format.number(@constant) unless @constant.zero?
|
|
342
|
-
@linear.each { |v, c| terms << Format.term(c, v.
|
|
343
|
-
@quadratic.each { |(a, b), c| terms << Format.term(c, "#{a
|
|
408
|
+
@linear.each { |v, c| terms << Format.term(c, v.to_s) }
|
|
409
|
+
@quadratic.each { |(a, b), c| terms << Format.term(c, "#{a}*#{b}") }
|
|
344
410
|
return "0" if terms.empty?
|
|
345
411
|
|
|
346
412
|
terms.join(" + ").gsub(" + -", " - ")
|
|
@@ -383,23 +449,29 @@ module Quicopt
|
|
|
383
449
|
#
|
|
384
450
|
# @api private
|
|
385
451
|
def self.pair(left, right)
|
|
386
|
-
(left.
|
|
452
|
+
((left.order <=> right.order) <= 0 ? [left, right] : [right, left]).freeze
|
|
387
453
|
end
|
|
388
454
|
end
|
|
389
455
|
|
|
390
456
|
# One constraint row: a residual expression compared against zero.
|
|
391
457
|
#
|
|
392
458
|
# +expr+ is +lhs - rhs+, so the row is +expr <= 0+, +expr >= 0+ or +expr == 0+.
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
459
|
+
# +Quicopt::Wire+ then splits that into the body (the variable terms) and the
|
|
460
|
+
# numeric +bound+ (the constant, moved to the other side), which is the form
|
|
461
|
+
# Quicopt sends.
|
|
462
|
+
#
|
|
463
|
+
# A comparison built with +<+, +>+ or +!=+ is not a row — +Model#add+ refuses it
|
|
464
|
+
# — but it can be turned into a 0/1 expression with +Quicopt.holds+.
|
|
396
465
|
class Constraint
|
|
397
466
|
# The comparison operator each sense renders as.
|
|
398
|
-
SENSES = { le: "<=", ge: ">=", eq: "==" }.freeze
|
|
467
|
+
SENSES = { le: "<=", ge: ">=", eq: "==", lt: "<", gt: ">", ne: "!=" }.freeze
|
|
468
|
+
# The senses a constraint row can have.
|
|
469
|
+
ROWS = %i[le ge eq].freeze
|
|
399
470
|
|
|
400
471
|
# @return [Expression] the residual +lhs - rhs+
|
|
401
472
|
attr_reader :expr
|
|
402
|
-
# @return [Symbol] +:le+, +:ge+ or +:eq+
|
|
473
|
+
# @return [Symbol] +:le+, +:ge+ or +:eq+ for a row; +:lt+, +:gt+ or +:ne+ for
|
|
474
|
+
# a comparison only +Quicopt.holds+ takes
|
|
403
475
|
attr_reader :sense
|
|
404
476
|
|
|
405
477
|
# Build a row from the two sides of a comparison.
|
|
@@ -448,8 +520,8 @@ module Quicopt
|
|
|
448
520
|
# m.maximize(3 * x + 2 * y)
|
|
449
521
|
# m.add(x + y <= 5)
|
|
450
522
|
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
523
|
+
# You build the model; +to_program+ / +encode+ turn it into the message the
|
|
524
|
+
# service reads, and +Quicopt::Client#solve+ sends it.
|
|
453
525
|
class Model
|
|
454
526
|
# @return [Array<Variable>] the declared variables, in declaration order
|
|
455
527
|
attr_reader :variables
|
|
@@ -459,10 +531,25 @@ module Quicopt
|
|
|
459
531
|
attr_reader :objective
|
|
460
532
|
# @return [String] +"min"+ or +"max"+
|
|
461
533
|
attr_reader :sense
|
|
534
|
+
# @return [Array<Permutation>] the declared permutations, in declaration order
|
|
535
|
+
attr_reader :permutations
|
|
536
|
+
# @return [Array<LookupTable>] the declared lookup tables, in declaration order
|
|
537
|
+
attr_reader :tables
|
|
538
|
+
# @return [Array<Partition>] the declared partitions, in declaration order
|
|
539
|
+
attr_reader :partitions
|
|
540
|
+
# @return [Array<Block>] the declared blocks, in declaration order
|
|
541
|
+
attr_reader :blocks
|
|
462
542
|
|
|
463
543
|
def initialize
|
|
464
544
|
@variables = []
|
|
465
545
|
@by_name = {}
|
|
546
|
+
@permutations = []
|
|
547
|
+
@partitions = []
|
|
548
|
+
@tables = []
|
|
549
|
+
@blocks = []
|
|
550
|
+
@structured = {}
|
|
551
|
+
@interned = {}
|
|
552
|
+
@reads = 0
|
|
466
553
|
@constraints = []
|
|
467
554
|
@objective = Expression.new
|
|
468
555
|
@sense = "min"
|
|
@@ -470,27 +557,46 @@ module Quicopt
|
|
|
470
557
|
|
|
471
558
|
# Declare a continuous variable.
|
|
472
559
|
#
|
|
560
|
+
# With +semi: true+ the variable is 0 or within its bounds, never in between:
|
|
561
|
+
# a shipment of either nothing or 2.5 to 8 tonnes. Such a variable is called
|
|
562
|
+
# semicontinuous. Its bounds must then be finite, and when 0 lies within them
|
|
563
|
+
# it is an ordinary variable.
|
|
564
|
+
#
|
|
473
565
|
# @param lower [Numeric, nil] the lower bound; +nil+ means unbounded below
|
|
474
566
|
# @param upper [Numeric, nil] the upper bound; +nil+ means unbounded above
|
|
475
|
-
# @param name [String, nil] the
|
|
567
|
+
# @param name [String, nil] the variable's name; auto-generated when omitted
|
|
476
568
|
# @param start [Numeric, nil] the starting point; defaults to 0 clamped into
|
|
477
|
-
# the bounds, matching the
|
|
569
|
+
# the bounds, matching the Python and Julia clients (to 0 with +semi+)
|
|
570
|
+
# @param semi [Boolean] whether the variable may also be 0
|
|
478
571
|
# @return [Variable]
|
|
479
|
-
|
|
480
|
-
|
|
572
|
+
# @raise [ArgumentError] with +semi+, if a bound is missing or infinite
|
|
573
|
+
def num_var(lower = nil, upper = nil, name = nil, start: nil, semi: false)
|
|
574
|
+
declare(semi ? :SEMICONTINUOUS : :CONTINUOUS, lower, upper, name, start)
|
|
481
575
|
end
|
|
482
576
|
|
|
483
577
|
# Declare an integer variable.
|
|
484
578
|
#
|
|
485
579
|
# Bounds of exactly +[0, 1]+ declare a +:BINARY+ variable rather than an
|
|
486
|
-
# integer one — the same
|
|
487
|
-
#
|
|
580
|
+
# integer one — the same rule the Python and Julia clients apply, so the same
|
|
581
|
+
# model means the same thing whichever language wrote it.
|
|
488
582
|
#
|
|
583
|
+
# With +semi: true+ the variable is 0 or a whole number within its bounds,
|
|
584
|
+
# never in between: a delivery of either nothing or 10 to 25 units. Such a
|
|
585
|
+
# variable is called semi-integer. Its bounds must then be finite.
|
|
586
|
+
#
|
|
587
|
+
# ship = m.int_var(10, 25, "ship", semi: true) # none, or 10 to 25 units
|
|
588
|
+
# m.minimize(2 * ship + 30 * ship.active) # 2 per unit, 30 per delivery
|
|
589
|
+
#
|
|
590
|
+
# @param semi [Boolean] whether the variable may also be 0
|
|
489
591
|
# @return [Variable]
|
|
490
|
-
|
|
592
|
+
# @raise [ArgumentError] with +semi+, if a bound is missing or infinite
|
|
593
|
+
def int_var(lower = nil, upper = nil, name = nil, start: nil, semi: false)
|
|
491
594
|
lo = coerce_bound(lower, -Float::INFINITY)
|
|
492
595
|
hi = coerce_bound(upper, Float::INFINITY)
|
|
493
|
-
domain =
|
|
596
|
+
domain = if semi then :SEMIINTEGER
|
|
597
|
+
elsif lo.zero? && hi == 1.0 then :BINARY
|
|
598
|
+
else :INTEGER
|
|
599
|
+
end
|
|
494
600
|
declare(domain, lo, hi, name, start)
|
|
495
601
|
end
|
|
496
602
|
|
|
@@ -501,13 +607,201 @@ module Quicopt
|
|
|
501
607
|
declare(:BINARY, 0.0, 1.0, name, start)
|
|
502
608
|
end
|
|
503
609
|
|
|
504
|
-
# Look a variable up by
|
|
610
|
+
# Look a variable up by name.
|
|
505
611
|
#
|
|
506
612
|
# @return [Variable, nil]
|
|
507
613
|
def variable(name)
|
|
508
614
|
@by_name[name.to_s]
|
|
509
615
|
end
|
|
510
616
|
|
|
617
|
+
# Declare an order as a decision: +n+ items placed in +n+ slots, one item per
|
|
618
|
+
# slot, both numbered from 0. For a route the items are the stops and the
|
|
619
|
+
# slots are the visits, so slot 0 holds the first stop visited; for a machine
|
|
620
|
+
# the items are the jobs and the slots the positions in its queue.
|
|
621
|
+
#
|
|
622
|
+
# Five stops along a road; find the shortest route through all of them that
|
|
623
|
+
# visits stop 3 before stop 0:
|
|
624
|
+
#
|
|
625
|
+
# position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
|
|
626
|
+
# m = Quicopt::Model.new
|
|
627
|
+
# dist = m.lookup_table("dist", position.map { |a| position.map { |b| (a - b).abs } })
|
|
628
|
+
# route = m.permutation("route", 5)
|
|
629
|
+
# route.precede(3, 0) # stop 3 is visited before stop 0
|
|
630
|
+
# m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
|
|
631
|
+
#
|
|
632
|
+
# result = Quicopt::Client.new.solve(m)
|
|
633
|
+
# result.objective # 4.0
|
|
634
|
+
# result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
|
|
635
|
+
#
|
|
636
|
+
# A model with a permutation is solved by a search, so its result has the
|
|
637
|
+
# status +"heuristic"+.
|
|
638
|
+
#
|
|
639
|
+
# @param name [String, Symbol] identifies the permutation in the result, as
|
|
640
|
+
# +result.structures[name]+; shares one namespace with the model's variables
|
|
641
|
+
# and lookup tables
|
|
642
|
+
# @param n [Integer] the number of items, at least 2
|
|
643
|
+
# @param start [Array<Integer>, nil] the arrangement the service's search
|
|
644
|
+
# starts from: +start[i]+ is the slot of item +i+, so it holds each of
|
|
645
|
+
# +0...n+ once. Left out, item +i+ starts in slot +i+.
|
|
646
|
+
# @return [Permutation]
|
|
647
|
+
# @raise [TypeError] if +n+ is not an Integer
|
|
648
|
+
# @raise [ArgumentError] if +n+ is below 2, the name is taken, or +start+ is
|
|
649
|
+
# not an arrangement of +0...n+
|
|
650
|
+
def permutation(name, n, start: nil)
|
|
651
|
+
raise TypeError, "a permutation's size is a whole number, got #{n.inspect}" unless n.is_a?(Integer)
|
|
652
|
+
raise ArgumentError, "a permutation has at least 2 items, got #{n}" if n < 2
|
|
653
|
+
|
|
654
|
+
nm = claim(name)
|
|
655
|
+
unless start.nil? || (start.is_a?(Array) && start.all?(Integer) && start.sort == (0...n).to_a)
|
|
656
|
+
raise ArgumentError, "permutation #{nm.inspect}: start must hold each of 0 to #{n - 1} once " \
|
|
657
|
+
"(start[i] is the slot item i starts in)"
|
|
658
|
+
end
|
|
659
|
+
permutation = Permutation.new(self, nm, n, start&.dup)
|
|
660
|
+
@permutations << permutation
|
|
661
|
+
@structured[nm] = permutation
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Declare an assignment of +n+ items to +parts+ parts as a decision: which
|
|
665
|
+
# team each person joins, which truck each parcel rides in. Items and parts
|
|
666
|
+
# are both numbered from 0, and a part may stay empty.
|
|
667
|
+
#
|
|
668
|
+
# Six players of strengths 7, 5, 4, 3, 2 and 1 form a team of four and a
|
|
669
|
+
# team of two, as evenly matched as possible:
|
|
670
|
+
#
|
|
671
|
+
# strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
|
|
672
|
+
# m = Quicopt::Model.new
|
|
673
|
+
# teams = m.partition("teams", 6, 2) # 6 players into teams 0 and 1
|
|
674
|
+
# total = (0...2).map { |t| (0...6).sum { |p| strength[p] * Quicopt.holds(teams.part_of(p) == t) } }
|
|
675
|
+
# m.add(teams.size_of(0) == 4) # team 0 has four players
|
|
676
|
+
# m.minimize(Quicopt.max(total[0], total[1]))
|
|
677
|
+
#
|
|
678
|
+
# result = Quicopt::Client.new.solve(m)
|
|
679
|
+
# result.objective # 11.0
|
|
680
|
+
# result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
|
|
681
|
+
#
|
|
682
|
+
# @param name [String, Symbol] identifies the partition in the result, as
|
|
683
|
+
# +result.structures[name]+; shares one namespace with the model's
|
|
684
|
+
# variables, permutations and lookup tables
|
|
685
|
+
# @param n [Integer] the number of items, at least 1
|
|
686
|
+
# @param parts [Integer] the number of parts, at least 2
|
|
687
|
+
# @param start [Array<Integer>, nil] the assignment the service's search
|
|
688
|
+
# starts from: +start[i]+ is the part of item +i+, one of +0...parts+.
|
|
689
|
+
# Left out, item +i+ starts in part +i % parts+.
|
|
690
|
+
# @return [Partition]
|
|
691
|
+
# @raise [TypeError] if +n+ or +parts+ is not an Integer
|
|
692
|
+
# @raise [ArgumentError] if +n+ is below 1, +parts+ below 2, the name is
|
|
693
|
+
# taken, or +start+ does not give each item a part
|
|
694
|
+
def partition(name, n, parts, start: nil)
|
|
695
|
+
raise TypeError, "a partition's number of items is a whole number, got #{n.inspect}" unless n.is_a?(Integer)
|
|
696
|
+
raise TypeError, "a partition's number of parts is a whole number, got #{parts.inspect}" unless parts.is_a?(Integer)
|
|
697
|
+
raise ArgumentError, "a partition has at least 1 item, got #{n}" if n < 1
|
|
698
|
+
raise ArgumentError, "a partition has at least 2 parts, got #{parts}" if parts < 2
|
|
699
|
+
|
|
700
|
+
nm = claim(name)
|
|
701
|
+
unless start.nil? || (start.is_a?(Array) && start.size == n && start.all? { |b| b.is_a?(Integer) && b >= 0 && b < parts })
|
|
702
|
+
raise ArgumentError, "partition #{nm.inspect}: start gives each of the #{n} items a part " \
|
|
703
|
+
"from 0 to #{parts - 1}"
|
|
704
|
+
end
|
|
705
|
+
partition = Partition.new(self, nm, n, parts, start&.dup)
|
|
706
|
+
@partitions << partition
|
|
707
|
+
@structured[nm] = partition
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Declare the variables of one subproblem as a block: one customer's
|
|
711
|
+
# supply, one project's staffing. The constraints over a block's own
|
|
712
|
+
# variables alone, such as that customer's demand, hold in every solution
|
|
713
|
+
# the service returns. A block changes nothing else about the model: the
|
|
714
|
+
# same solutions are feasible and the objective is the same.
|
|
715
|
+
#
|
|
716
|
+
# supply = (0...3).map { |w| m.int_var(10, 25, "ship_#{w}", semi: true) }
|
|
717
|
+
# m.block("customer", supply)
|
|
718
|
+
#
|
|
719
|
+
# @param name [String, Symbol] the block's name, distinct from every other
|
|
720
|
+
# block's
|
|
721
|
+
# @param vars [Array<Variable>] its variables, each with finite bounds and in
|
|
722
|
+
# no other block
|
|
723
|
+
# @return [Block]
|
|
724
|
+
# @raise [TypeError] if a member is not a variable
|
|
725
|
+
# @raise [ArgumentError] if the block is empty, the name is taken, a member
|
|
726
|
+
# belongs to another model or block or has an infinite bound
|
|
727
|
+
def block(name, vars)
|
|
728
|
+
raise TypeError, "a block's name is a String or a Symbol, got #{name.inspect}" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
729
|
+
|
|
730
|
+
nm = name.to_s
|
|
731
|
+
raise ArgumentError, "a block's name cannot be empty" if nm.empty?
|
|
732
|
+
raise ArgumentError, "there is already a block named #{nm.inspect}" if @blocks.any? { |b| b.name == nm }
|
|
733
|
+
|
|
734
|
+
members = Array(vars)
|
|
735
|
+
raise ArgumentError, "block #{nm.inspect} has no variables" if members.empty?
|
|
736
|
+
|
|
737
|
+
members.each_with_index do |v, k|
|
|
738
|
+
raise TypeError, "block #{nm.inspect}: a member is a variable, got #{v.class}" unless v.is_a?(Variable)
|
|
739
|
+
raise ArgumentError, "variable #{v.name.inspect} belongs to a different Quicopt::Model" unless v.model.equal?(self)
|
|
740
|
+
raise ArgumentError, "variable #{v.name.inspect} is listed twice in block #{nm.inspect}" if members[0...k].any? { |u| u.equal?(v) }
|
|
741
|
+
if (owner = @blocks.find { |b| b.variables.any? { |u| u.equal?(v) } })
|
|
742
|
+
raise ArgumentError, "variable #{v.name.inspect} is already in block #{owner.name.inspect}"
|
|
743
|
+
end
|
|
744
|
+
unless v.lower.finite? && v.upper.finite?
|
|
745
|
+
raise ArgumentError, "variable #{v.name.inspect} has bounds [#{v.lower}, #{v.upper}]; " \
|
|
746
|
+
"a block's variables need finite bounds"
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
block = Block.new(nm, members.dup.freeze)
|
|
750
|
+
@blocks << block
|
|
751
|
+
block
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# Declare a table of data that can be indexed by decisions. A model sometimes
|
|
755
|
+
# needs a number from a table at a position that is not known yet: the distance
|
|
756
|
+
# between the first and the second stop of a route whose order the service is
|
|
757
|
+
# still choosing, or the price of an option an integer variable picks. A Ruby
|
|
758
|
+
# array cannot be indexed this way; the table returned here can.
|
|
759
|
+
#
|
|
760
|
+
# price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
|
|
761
|
+
# choice = m.int_var(0, 3, "choice")
|
|
762
|
+
# m.minimize(price[choice]) # picks option 1, at 1.0
|
|
763
|
+
#
|
|
764
|
+
# Indexing with whole numbers only gives the entry itself; indexing with an
|
|
765
|
+
# expression, such as +Permutation#item_at+ or an integer variable, gives an
|
|
766
|
+
# expression whose value is the entry at the positions the service chooses.
|
|
767
|
+
#
|
|
768
|
+
# The whole table is sent with the model, so a very large table makes the
|
|
769
|
+
# request large.
|
|
770
|
+
#
|
|
771
|
+
# @param name [String, Symbol] the table's name; shares one namespace with the
|
|
772
|
+
# model's variables and permutations
|
|
773
|
+
# @param values [Array<Numeric>, Array<Array<Numeric>>] a vector (indexed
|
|
774
|
+
# +table[i]+) or a matrix (+table[i, j]+, every row the same length) of
|
|
775
|
+
# numbers, none of them NaN
|
|
776
|
+
# @return [LookupTable]
|
|
777
|
+
# @raise [TypeError] if +values+ is neither a vector nor a matrix of numbers
|
|
778
|
+
# @raise [ArgumentError] if it is empty, ragged or holds NaN, or the name is
|
|
779
|
+
# taken
|
|
780
|
+
def lookup_table(name, values)
|
|
781
|
+
nm = claim(name)
|
|
782
|
+
data, shape = table_data(nm, values)
|
|
783
|
+
table = LookupTable.new(self, nm, data, shape)
|
|
784
|
+
@tables << table
|
|
785
|
+
@structured[nm] = table
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# The +order+ of the next read of a permutation, partition or lookup table,
|
|
789
|
+
# or of a term that switches: after every variable, then in the order they
|
|
790
|
+
# were first made.
|
|
791
|
+
#
|
|
792
|
+
# @api private
|
|
793
|
+
def next_order
|
|
794
|
+
[1, @reads += 1]
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# The term stored under +key+, built by the block on first use, so that
|
|
798
|
+
# equal terms are one object.
|
|
799
|
+
#
|
|
800
|
+
# @api private
|
|
801
|
+
def intern(key)
|
|
802
|
+
@interned[key] ||= yield
|
|
803
|
+
end
|
|
804
|
+
|
|
511
805
|
# Minimize +expr+.
|
|
512
806
|
#
|
|
513
807
|
# @return [Expression] the objective now set
|
|
@@ -528,24 +822,31 @@ module Quicopt
|
|
|
528
822
|
# @return [Constraint] the row just added
|
|
529
823
|
# @raise [TypeError] if handed something that is not a constraint — most
|
|
530
824
|
# often a bare expression, or a comparison Ruby already reduced to a boolean
|
|
825
|
+
# @raise [ArgumentError] for a comparison with +<+, +>+ or +!=+, which no row
|
|
826
|
+
# can express
|
|
531
827
|
def add(constraint)
|
|
532
828
|
unless constraint.is_a?(Constraint)
|
|
533
829
|
raise TypeError,
|
|
534
830
|
"add expects a constraint such as `x + y <= 5`, got #{constraint.class}"
|
|
535
831
|
end
|
|
832
|
+
unless Constraint::ROWS.include?(constraint.sense)
|
|
833
|
+
raise ArgumentError,
|
|
834
|
+
"#{constraint} is not a constraint Quicopt can hold: use <=, >= or ==, " \
|
|
835
|
+
"or turn the comparison into a 0/1 expression with Quicopt.holds"
|
|
836
|
+
end
|
|
536
837
|
check_ownership(constraint.expr)
|
|
537
838
|
@constraints << constraint
|
|
538
839
|
constraint
|
|
539
840
|
end
|
|
540
841
|
|
|
541
|
-
#
|
|
842
|
+
# Turn this model into the structured message the service reads.
|
|
542
843
|
#
|
|
543
844
|
# @return [Quicopt::Modeler::V1::Program]
|
|
544
845
|
def to_program
|
|
545
846
|
Wire.program(self)
|
|
546
847
|
end
|
|
547
848
|
|
|
548
|
-
#
|
|
849
|
+
# Turn this model into the message the service reads and encode it.
|
|
549
850
|
#
|
|
550
851
|
# @return [String] binary protobuf bytes
|
|
551
852
|
def encode
|
|
@@ -558,8 +859,17 @@ module Quicopt
|
|
|
558
859
|
lines << "subject to" unless @constraints.empty?
|
|
559
860
|
@constraints.each { |c| lines << " #{c}" }
|
|
560
861
|
@variables.each do |v|
|
|
561
|
-
|
|
862
|
+
range = "[#{Format.number(v.lower)}, #{Format.number(v.upper)}]"
|
|
863
|
+
range = "{0} ∪ #{range}" if %i[SEMICONTINUOUS SEMIINTEGER].include?(v.domain)
|
|
864
|
+
lines << " #{v.name} in #{v.domain} #{range}"
|
|
865
|
+
end
|
|
866
|
+
@permutations.each do |p|
|
|
867
|
+
order = p.precedences.map { |a, b| ", #{a} before #{b}" }.join
|
|
868
|
+
lines << " #{p.name} orders items 0 to #{p.size - 1}#{order}"
|
|
562
869
|
end
|
|
870
|
+
@partitions.each { |p| lines << " #{p.name} puts items 0 to #{p.size - 1} in parts 0 to #{p.parts - 1}" }
|
|
871
|
+
@tables.each { |t| lines << " #{t.name} is a #{t.shape.join(' × ')} lookup table" }
|
|
872
|
+
@blocks.each { |b| lines << " #{b.name} is a block of #{b.variables.join(', ')}" }
|
|
563
873
|
lines.join("\n")
|
|
564
874
|
end
|
|
565
875
|
|
|
@@ -575,8 +885,15 @@ module Quicopt
|
|
|
575
885
|
hi = coerce_bound(upper, Float::INFINITY)
|
|
576
886
|
raise ArgumentError, "lower bound #{lo} exceeds upper bound #{hi}" if lo > hi
|
|
577
887
|
|
|
888
|
+
semi = %i[SEMICONTINUOUS SEMIINTEGER].include?(domain)
|
|
889
|
+
if semi && !(lo.finite? && hi.finite?)
|
|
890
|
+
raise ArgumentError, "a variable that is 0 or in [#{lo}, #{hi}] needs finite bounds"
|
|
891
|
+
end
|
|
892
|
+
start = 0.0 if semi && start.nil?
|
|
893
|
+
|
|
578
894
|
nm = name.nil? ? auto_name : name.to_s
|
|
579
895
|
raise ArgumentError, "duplicate variable name #{nm.inspect}" if @by_name.key?(nm)
|
|
896
|
+
raise ArgumentError, "the name #{nm.inspect} is already declared on this model" if @structured.key?(nm)
|
|
580
897
|
|
|
581
898
|
variable = Variable.new(self, nm, domain, lo, hi, coerce_start(start, lo, hi), @variables.size)
|
|
582
899
|
@variables << variable
|
|
@@ -591,8 +908,8 @@ module Quicopt
|
|
|
591
908
|
value.to_f
|
|
592
909
|
end
|
|
593
910
|
|
|
594
|
-
# 0 clamped into the bounds — finite for any bounds, matching the
|
|
595
|
-
#
|
|
911
|
+
# 0 clamped into the bounds — finite for any bounds, matching the Python and
|
|
912
|
+
# Julia clients so an unset start comes out the same everywhere.
|
|
596
913
|
def coerce_start(start, lower, upper)
|
|
597
914
|
return [[0.0, lower].max, upper].min if start.nil?
|
|
598
915
|
raise ArgumentError, "start must be a number, got #{start.class}" unless start.is_a?(Numeric)
|
|
@@ -602,10 +919,54 @@ module Quicopt
|
|
|
602
919
|
|
|
603
920
|
def auto_name
|
|
604
921
|
n = @variables.size + 1
|
|
605
|
-
n += 1 while @by_name.key?("x#{n}")
|
|
922
|
+
n += 1 while @by_name.key?("x#{n}") || @structured.key?("x#{n}")
|
|
606
923
|
"x#{n}"
|
|
607
924
|
end
|
|
608
925
|
|
|
926
|
+
# +name+ as a String, checked to be free among the model's variables,
|
|
927
|
+
# permutations and lookup tables: on the wire one name must mean one thing.
|
|
928
|
+
def claim(name)
|
|
929
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
930
|
+
raise TypeError, "a name is a String or a Symbol, got #{name.inspect}"
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
nm = name.to_s
|
|
934
|
+
raise ArgumentError, "a name cannot be empty" if nm.empty?
|
|
935
|
+
if @by_name.key?(nm) || @structured.key?(nm)
|
|
936
|
+
raise ArgumentError, "the name #{nm.inspect} is already declared on this model"
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
nm
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# A table's entries as frozen Floats, and its shape.
|
|
943
|
+
def table_data(name, values)
|
|
944
|
+
raise TypeError, "lookup table #{name.inspect}: values is an Array, got #{values.class}" unless values.is_a?(Array)
|
|
945
|
+
raise ArgumentError, "lookup table #{name.inspect} has no entries" if values.empty?
|
|
946
|
+
|
|
947
|
+
if values.all?(Numeric)
|
|
948
|
+
data = values.map(&:to_f)
|
|
949
|
+
shape = [data.size]
|
|
950
|
+
flat = data
|
|
951
|
+
elsif values.all?(Array)
|
|
952
|
+
unless values.all? { |row| row.all?(Numeric) }
|
|
953
|
+
raise TypeError, "lookup table #{name.inspect}: a matrix holds numbers only"
|
|
954
|
+
end
|
|
955
|
+
if values.first.empty? || values.any? { |row| row.size != values.first.size }
|
|
956
|
+
raise ArgumentError, "lookup table #{name.inspect}: every row of a matrix has the same, " \
|
|
957
|
+
"nonzero number of entries"
|
|
958
|
+
end
|
|
959
|
+
data = values.map { |row| row.map(&:to_f).freeze }
|
|
960
|
+
shape = [data.size, data.first.size]
|
|
961
|
+
flat = data.flatten
|
|
962
|
+
else
|
|
963
|
+
raise TypeError, "lookup table #{name.inspect}: values is a vector or a matrix of numbers"
|
|
964
|
+
end
|
|
965
|
+
raise ArgumentError, "lookup table #{name.inspect} holds NaN" if flat.any?(&:nan?)
|
|
966
|
+
|
|
967
|
+
[data.freeze, shape]
|
|
968
|
+
end
|
|
969
|
+
|
|
609
970
|
def set_objective(expr, sense)
|
|
610
971
|
objective = Expression.cast(expr)
|
|
611
972
|
check_ownership(objective)
|
|
@@ -614,14 +975,38 @@ module Quicopt
|
|
|
614
975
|
objective
|
|
615
976
|
end
|
|
616
977
|
|
|
617
|
-
# Reject variables belonging to another model: their
|
|
618
|
-
# arbitrarily and their coefficients would silently merge
|
|
978
|
+
# Reject variables, permutations and tables belonging to another model: their
|
|
979
|
+
# names would collide arbitrarily and their coefficients would silently merge
|
|
980
|
+
# on the wire.
|
|
619
981
|
def check_ownership(expr)
|
|
620
|
-
expr.
|
|
621
|
-
next if
|
|
982
|
+
expr.factors.each do |f|
|
|
983
|
+
next if f.model.equal?(self)
|
|
622
984
|
|
|
623
|
-
|
|
985
|
+
what = f.is_a?(Variable) ? "variable #{f.name.inspect}" : f.to_s
|
|
986
|
+
raise ArgumentError, "#{what} belongs to a different Quicopt::Model"
|
|
624
987
|
end
|
|
625
988
|
end
|
|
626
989
|
end
|
|
990
|
+
|
|
991
|
+
# The variables of one subproblem, declared with +Model#block+.
|
|
992
|
+
class Block
|
|
993
|
+
# @return [String] the block's name
|
|
994
|
+
attr_reader :name
|
|
995
|
+
# @return [Array<Variable>] its variables, in the order given
|
|
996
|
+
attr_reader :variables
|
|
997
|
+
|
|
998
|
+
# Declared with +Model#block+, not built directly.
|
|
999
|
+
#
|
|
1000
|
+
# @api private
|
|
1001
|
+
def initialize(name, variables)
|
|
1002
|
+
@name = name
|
|
1003
|
+
@variables = variables
|
|
1004
|
+
freeze
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# @return [String] name and members
|
|
1008
|
+
def inspect
|
|
1009
|
+
"#<Quicopt::Block #{@name}: #{@variables.join(', ')}>"
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
627
1012
|
end
|