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.
- checksums.yaml +4 -4
- data/.yardopts +2 -1
- data/README.md +269 -48
- data/lib/quicopt/client.rb +219 -91
- data/lib/quicopt/indicators.rb +236 -0
- data/lib/quicopt/model.rb +463 -67
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +438 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +173 -37
- data/lib/quicopt.rb +8 -7
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# quicopt/indicators — terms that switch: whether a variable is in use
|
|
6
|
+
# (+Variable#active+), whether a comparison holds (+Quicopt.holds+), and the
|
|
7
|
+
# smaller or larger of several expressions (+Quicopt.min+, +Quicopt.max+).
|
|
8
|
+
#
|
|
9
|
+
# None of them is a polynomial, and each enters an +Expression+ the way a read of
|
|
10
|
+
# a permutation does (+quicopt/structured+): as one factor of degree 1, a key of
|
|
11
|
+
# the expression's linear terms. So +fee * x.active+ is linear, +q * x.active+
|
|
12
|
+
# quadratic, and the degree check stays exact.
|
|
13
|
+
#
|
|
14
|
+
# Factors are interned per model — asking twice for the same term returns the
|
|
15
|
+
# same object — so +x.active - x.active+ cancels and two equal terms meet on one
|
|
16
|
+
# coefficient. A comparison is mapped to its 0/1 form once, in +Quicopt.holds+,
|
|
17
|
+
# the same mapping as the R client's +holds()+:
|
|
18
|
+
#
|
|
19
|
+
# a <= b ⇒ step(b − a + tol) a >= b ⇒ step(a − b + tol)
|
|
20
|
+
# a < b ⇒ 1 − step(a − b − tol) a > b ⇒ 1 − step(b − a − tol)
|
|
21
|
+
# a == b ⇒ 1 − indicator(a − b) a != b ⇒ indicator(a − b)
|
|
22
|
+
#
|
|
23
|
+
# where +step(e)+ is 1 when +e ≥ 0+ and +indicator(e)+ is 1 when +e ≠ 0+; with a
|
|
24
|
+
# tolerance, +==+ and +!=+ compare +|a − b|+ against it instead.
|
|
25
|
+
|
|
26
|
+
module Quicopt
|
|
27
|
+
class << self
|
|
28
|
+
# 1 where the comparison holds and 0 where it does not, as an expression the
|
|
29
|
+
# service evaluates at the values it chooses. Its main use is a cost or a
|
|
30
|
+
# discount that applies once a total crosses a level: a warehouse's opening
|
|
31
|
+
# fee, due once it ships anything, is
|
|
32
|
+
#
|
|
33
|
+
# opening_fee * Quicopt.holds(shipped >= 1)
|
|
34
|
+
#
|
|
35
|
+
# and a discount of 30 from 60 units on is +-30 * Quicopt.holds(shipped >= 60)+.
|
|
36
|
+
#
|
|
37
|
+
# @param comparison [Constraint] built with +<=+, +>=+, +<+, +>+, +==+ or
|
|
38
|
+
# +!=+ between expressions, such as +shipped >= 60+
|
|
39
|
+
# @param tol [Numeric] how far a comparison may miss and still count as
|
|
40
|
+
# holding: +a >= b+ holds from +a = b − tol+ on, +a > b+ from
|
|
41
|
+
# <tt>a > b + tol</tt>. Non-negative.
|
|
42
|
+
# @return [Expression] a 0/1 expression; a number when the comparison
|
|
43
|
+
# involves no decision
|
|
44
|
+
# @raise [TypeError] if +comparison+ is not a comparison — often one Ruby
|
|
45
|
+
# already reduced to +true+ or +false+ because both sides were numbers
|
|
46
|
+
# @raise [ArgumentError] if +tol+ is negative, or the two sides belong to
|
|
47
|
+
# different models
|
|
48
|
+
def holds(comparison, tol: 0.0)
|
|
49
|
+
unless comparison.is_a?(Constraint)
|
|
50
|
+
raise TypeError, "holds takes a comparison such as `x >= 3`, got #{comparison.inspect}"
|
|
51
|
+
end
|
|
52
|
+
raise ArgumentError, "tol is a non-negative number, got #{tol.inspect}" unless tol.is_a?(Numeric) && tol >= 0
|
|
53
|
+
|
|
54
|
+
Conditions.holds(comparison.expr, comparison.sense, tol.to_f)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The smallest of the given expressions, as an expression the service
|
|
58
|
+
# evaluates. <tt>fee * Quicopt.min(1, a.active + b.active)</tt> is a fee
|
|
59
|
+
# that is due once, however many of +a+ and +b+ are in use.
|
|
60
|
+
#
|
|
61
|
+
# @param args [Array<Expression, Variable, Numeric>] at least two
|
|
62
|
+
# @return [Expression] a number when every argument is one
|
|
63
|
+
# @raise [ArgumentError] on fewer than two arguments, or arguments from
|
|
64
|
+
# different models
|
|
65
|
+
def min(*args)
|
|
66
|
+
Extremum.build(:min, args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The largest of the given expressions, as an expression the service
|
|
70
|
+
# evaluates.
|
|
71
|
+
#
|
|
72
|
+
# @param args [Array<Expression, Variable, Numeric>] at least two
|
|
73
|
+
# @return [Expression] a number when every argument is one
|
|
74
|
+
# @raise [ArgumentError] on fewer than two arguments, or arguments from
|
|
75
|
+
# different models
|
|
76
|
+
def max(*args)
|
|
77
|
+
Extremum.build(:max, args)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The mapping from a comparison to its 0/1 form, and the model a term belongs
|
|
82
|
+
# to.
|
|
83
|
+
#
|
|
84
|
+
# @api private
|
|
85
|
+
module Conditions
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
# +residual+ is +lhs − rhs+; the result follows the table in this file's
|
|
89
|
+
# header.
|
|
90
|
+
def holds(residual, sense, tol)
|
|
91
|
+
return Expression.new(constant: truth(residual.constant, sense, tol) ? 1.0 : 0.0) if residual.constant?
|
|
92
|
+
|
|
93
|
+
negated = residual.scale(-1.0)
|
|
94
|
+
case sense
|
|
95
|
+
when :le then term(:step, negated.plus(Expression.new(constant: tol)))
|
|
96
|
+
when :ge then term(:step, residual.plus(Expression.new(constant: tol)))
|
|
97
|
+
when :lt then one_minus(term(:step, residual.plus(Expression.new(constant: -tol))))
|
|
98
|
+
when :gt then one_minus(term(:step, negated.plus(Expression.new(constant: -tol))))
|
|
99
|
+
when :eq then tol.positive? ? term(:near, residual, tol) : one_minus(term(:indicator, residual))
|
|
100
|
+
when :ne then tol.positive? ? one_minus(term(:near, residual, tol)) : term(:indicator, residual)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether a comparison between two numbers (<tt>value = lhs − rhs</tt>) holds.
|
|
105
|
+
def truth(value, sense, tol)
|
|
106
|
+
case sense
|
|
107
|
+
when :le then -value + tol >= 0
|
|
108
|
+
when :ge then value + tol >= 0
|
|
109
|
+
when :lt then (value - tol).negative?
|
|
110
|
+
when :gt then (-value - tol).negative?
|
|
111
|
+
when :eq then tol.positive? ? value.abs <= tol : value.zero?
|
|
112
|
+
when :ne then tol.positive? ? value.abs > tol : !value.zero?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The interned +Condition+ +op+ over +argument+, as an expression.
|
|
117
|
+
def term(op, argument, tol = 0.0)
|
|
118
|
+
model = owner(argument.factors)
|
|
119
|
+
key = [:condition, op, argument_key(argument), tol]
|
|
120
|
+
Expression.new(linear: { model.intern(key) { Condition.new(model, op, argument, tol, model.next_order) } => 1.0 })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# +1 − e+.
|
|
124
|
+
def one_minus(expr)
|
|
125
|
+
Expression.new(constant: 1.0).plus(expr.scale(-1.0))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The one model every factor in +factors+ belongs to.
|
|
129
|
+
#
|
|
130
|
+
# @raise [ArgumentError] if they belong to more than one
|
|
131
|
+
def owner(factors)
|
|
132
|
+
models = factors.map(&:model).uniq(&:object_id)
|
|
133
|
+
raise ArgumentError, "the terms of one expression belong to different Quicopt::Models" if models.size > 1
|
|
134
|
+
|
|
135
|
+
models.first
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An expression as an interning key: its constant and its terms in model
|
|
139
|
+
# order. Factors are interned, so they key by identity.
|
|
140
|
+
def argument_key(expr)
|
|
141
|
+
[expr.constant, expr.linear.sort_by { |f, _| f.order }, expr.quadratic.sort_by { |(a, b), _| [a.order, b.order] }]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A 0/1 term: +indicator(e)+ (1 when +e ≠ 0+), +step(e)+ (1 when +e ≥ 0+) or
|
|
146
|
+
# the near test +step(tol − |e|)+. One factor of an +Expression+.
|
|
147
|
+
#
|
|
148
|
+
# @api private
|
|
149
|
+
class Condition
|
|
150
|
+
# @return [Model]
|
|
151
|
+
attr_reader :model
|
|
152
|
+
# @return [Symbol] +:indicator+, +:step+ or +:near+
|
|
153
|
+
attr_reader :op
|
|
154
|
+
# @return [Expression] the expression tested
|
|
155
|
+
attr_reader :argument
|
|
156
|
+
# @return [Float] the tolerance of +:near+, 0 otherwise
|
|
157
|
+
attr_reader :tol
|
|
158
|
+
# @return [Array] where this term sorts among a model's terms
|
|
159
|
+
attr_reader :order
|
|
160
|
+
|
|
161
|
+
def initialize(model, op, argument, tol, order)
|
|
162
|
+
@model = model
|
|
163
|
+
@op = op
|
|
164
|
+
@argument = argument
|
|
165
|
+
@tol = tol
|
|
166
|
+
@order = order
|
|
167
|
+
freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @return [Array<Variable>] the variables the tested expression mentions
|
|
171
|
+
def variables
|
|
172
|
+
@argument.variables
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @return [String] the term as sent, e.g. <tt>step(x + y - 1)</tt>; the
|
|
176
|
+
# indicator of a bare variable reads +x.active+
|
|
177
|
+
def to_s
|
|
178
|
+
bare = @argument.constant.zero? && @argument.quadratic.empty? && @argument.linear.size == 1 &&
|
|
179
|
+
@argument.linear.first[0].is_a?(Variable) && @argument.linear.first[1] == 1.0
|
|
180
|
+
case @op
|
|
181
|
+
when :indicator then bare ? "#{@argument}.active" : "indicator(#{@argument})"
|
|
182
|
+
when :step then "step(#{@argument})"
|
|
183
|
+
when :near then "step(#{Format.number(@tol)} - |#{@argument}|)"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The smaller or the larger of several expressions. One factor of an
|
|
189
|
+
# +Expression+.
|
|
190
|
+
#
|
|
191
|
+
# @api private
|
|
192
|
+
class Extremum
|
|
193
|
+
# @return [Model]
|
|
194
|
+
attr_reader :model
|
|
195
|
+
# @return [Symbol] +:min+ or +:max+
|
|
196
|
+
attr_reader :op
|
|
197
|
+
# @return [Array<Expression>] the arguments, in the order given
|
|
198
|
+
attr_reader :args
|
|
199
|
+
# @return [Array] where this term sorts among a model's terms
|
|
200
|
+
attr_reader :order
|
|
201
|
+
|
|
202
|
+
# The interned +op+ over +args+ as an expression, or the number itself when
|
|
203
|
+
# every argument is one.
|
|
204
|
+
def self.build(op, args)
|
|
205
|
+
raise ArgumentError, "#{op} takes at least two arguments, got #{args.size}" if args.size < 2
|
|
206
|
+
|
|
207
|
+
exprs = args.map { |a| Expression.cast(a) }
|
|
208
|
+
if exprs.all?(&:constant?)
|
|
209
|
+
values = exprs.map(&:constant)
|
|
210
|
+
return Expression.new(constant: op == :min ? values.min : values.max)
|
|
211
|
+
end
|
|
212
|
+
model = Conditions.owner(exprs.flat_map(&:factors))
|
|
213
|
+
key = [:extremum, op, exprs.map { |e| Conditions.argument_key(e) }]
|
|
214
|
+
Expression.new(linear: { model.intern(key) { new(model, op, exprs.freeze, model.next_order) } => 1.0 })
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def initialize(model, op, args, order)
|
|
218
|
+
@model = model
|
|
219
|
+
@op = op
|
|
220
|
+
@args = args
|
|
221
|
+
@order = order
|
|
222
|
+
freeze
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @return [Array<Variable>] the variables the arguments mention
|
|
226
|
+
def variables
|
|
227
|
+
@args.flat_map(&:variables).uniq
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @return [String] the term as written, e.g.
|
|
231
|
+
# <tt>min(1, a.active + b.active)</tt>
|
|
232
|
+
def to_s
|
|
233
|
+
"#{@op}(#{@args.join(', ')})"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|