quicopt 0.1.0

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