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