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.
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
@@ -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,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 disequality is not representable on the wire, and Ruby would otherwise
143
- # derive it from +==+ and silently answer +false+ for every row.
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
- # @raise [UnsupportedExpression] always, for an expression-like operand
166
+ # @return [Constraint, Boolean]
146
167
  def !=(other)
147
168
  return !equal?(other) unless Expression.castable?(other)
148
169
 
149
- raise UnsupportedExpression,
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 canonical
166
- # order of a quadratic term's two factors, so +x * y+ and +y * x+ land on the
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 wire name; solutions come back keyed by it
193
+ # @return [String] the variable's name; solutions come back keyed by it
174
194
  attr_reader :name
175
- # @return [Symbol] +:CONTINUOUS+, +:INTEGER+ or +:BINARY+
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 wire name
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 normalised polynomial of degree ≤ 2 over a model's variables:
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 inside the degree-2 subset.
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
- "product of degree #{degree} and degree #{other.degree} exceeds the quadratic subset"
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.name) }
343
- @quadratic.each { |(a, b), c| terms << Format.term(c, "#{a.name}*#{b.name}") }
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.index <= right.index ? [left, right] : [right, left]).freeze
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
- # 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.
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
- # The model is the authoring surface; +to_program+ / +encode+ lower it to the
452
- # wire IR, and +Quicopt::Client#solve+ ships it.
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 wire name; auto-generated when omitted
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 other clients' front-ends
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
- def num_var(lower = nil, upper = nil, name = nil, start: nil)
480
- declare(:CONTINUOUS, lower, upper, name, start)
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 normalisation the other front-ends apply, so the
487
- # same model reaches the service as the same IR whichever client authored it.
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
- def int_var(lower = nil, upper = nil, name = nil, start: nil)
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 = lo.zero? && hi == 1.0 ? :BINARY : :INTEGER
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 its wire name.
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
- # Lower this model to the wire IR.
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
- # Lower this model and serialize it to wire bytes.
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
- lines << " #{v.name} in #{v.domain} [#{Format.number(v.lower)}, #{Format.number(v.upper)}]"
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 sibling
595
- # front-ends so an unset start lowers identically across clients.
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 names would collide
618
- # arbitrarily and their coefficients would silently merge on the wire.
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.variables.each do |v|
621
- next if v.model.equal?(self)
982
+ expr.factors.each do |f|
983
+ next if f.model.equal?(self)
622
984
 
623
- raise ArgumentError, "variable #{v.name.inspect} belongs to a different Quicopt::Model"
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