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,179 @@
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
+ require "google/protobuf"
6
+ require_relative "modeler/v1/program_pb"
7
+
8
+ # quicopt/wire — a +Model+ → the wire IR +Program+ → protobuf bytes.
9
+ #
10
+ # The generated codec (+lib/quicopt/modeler/+, regenerated by +gen/gen.sh+ from
11
+ # the vendored +program.proto+) does the serialization; this file supplies the
12
+ # node constructors on top of it and the lowering from the DSL's normalised
13
+ # polynomials into the IR's expression trees.
14
+ #
15
+ # The canonical forms below are shared with the other clients, so the same model
16
+ # authored in Ruby, Python or Julia reaches the service as the same IR:
17
+ #
18
+ # f = c ⇒ f − c = 0 (Zero)
19
+ # f ≥ l ⇒ f − l ≥ 0 (Nonneg)
20
+ # f ≤ u ⇒ u − f ≥ 0 (Nonneg)
21
+ #
22
+ # A unit coefficient is dropped rather than emitted as +1 * e+, an additive zero
23
+ # constant is dropped from a sum, and a sum of one term is that term.
24
+ #
25
+ # Note on byte-level comparison: proto3 omits zero-valued scalars (+start: 0.0+)
26
+ # and does not canonicalize field order, so these bytes are *decode-equivalent*
27
+ # to the service's reference codec, not byte-identical — the same position the
28
+ # Julia client is in. Compare decoded +Program+ values in tests, never hex.
29
+ module Quicopt
30
+ # Node constructors over the generated codec, and the lowering from the DSL's
31
+ # normalised polynomials into the IR's expression trees.
32
+ module Wire
33
+ # The generated wire schema — +Program+, +Expression+, +Constraint+, … — as
34
+ # the client's IR. Aliased for brevity throughout the codec.
35
+ PB = Quicopt::Modeler::V1
36
+
37
+ class << self
38
+ # Lower +model+ to the wire IR.
39
+ #
40
+ # @param model [Quicopt::Model]
41
+ # @return [PB::Program]
42
+ def program(model)
43
+ PB::Program.new(
44
+ vars: model.variables.map { |v| var_decl(v) },
45
+ objective: expression(model.objective),
46
+ sense: model.sense,
47
+ constraints: model.constraints.map { |c| constraint(c) }
48
+ )
49
+ end
50
+
51
+ # Serialize a model (or an already-lowered +Program+) to wire bytes.
52
+ #
53
+ # @param model [Quicopt::Model, PB::Program]
54
+ # @return [String] binary protobuf bytes
55
+ def encode(model)
56
+ PB::Program.encode(model.is_a?(PB::Program) ? model : program(model))
57
+ end
58
+
59
+ # Parse wire bytes back into a +Program+ — the inverse of +encode+, and the
60
+ # only sound way to compare two encodings.
61
+ #
62
+ # @param bytes [String]
63
+ # @return [PB::Program]
64
+ def decode(bytes)
65
+ PB::Program.decode(bytes)
66
+ end
67
+
68
+ # ── node constructors ──────────────────────────────────────────────────
69
+
70
+ # A constant expression node. Always a set +oneof+ case, so +Const(0.0)+
71
+ # survives the round-trip rather than vanishing as a proto3 default.
72
+ def const(value)
73
+ PB::Expression.new(constant: number(value))
74
+ end
75
+
76
+ # Normalise negative zero on its way to the wire.
77
+ #
78
+ # IEEE keeps +-0.0+ distinct from +0.0+ — a distinct bit pattern, and a
79
+ # distinct protobuf value — and moving a bound across an inequality
80
+ # (+f ≤ 0+ ⇒ +0 − f ≥ 0+) produces one whenever that bound is zero. The
81
+ # sibling clients emit +0.0+ there, so collapse the sign here rather than
82
+ # shipping bytes that differ from theirs by a sign bit.
83
+ def number(value)
84
+ float = value.to_f
85
+ float.zero? ? 0.0 : float
86
+ end
87
+
88
+ # A reference to a flat (unindexed) variable. The +Index+ message is emitted
89
+ # present-but-empty, matching the reference codec.
90
+ def var(name)
91
+ PB::Expression.new(var: PB::VarRef.new(name: name, index: PB::Index.new))
92
+ end
93
+
94
+ # An operator application: +op+ applied to child expressions.
95
+ def apply(op, args)
96
+ PB::Expression.new(apply: PB::Apply.new(op: op, args: args))
97
+ end
98
+
99
+ # +coef · expr+, dropping a unit coefficient.
100
+ def scaled(coef, expr)
101
+ coef == 1.0 ? expr : apply("*", [const(coef), expr])
102
+ end
103
+
104
+ # Fold terms into an n-ary sum, dropping additive-zero constants. No terms
105
+ # is the zero constant; one term is itself.
106
+ def sum(terms)
107
+ surviving = terms.reject { |t| t.node == :constant && t.constant.zero? }
108
+ return const(0.0) if surviving.empty?
109
+ return surviving.first if surviving.size == 1
110
+
111
+ apply("+", surviving)
112
+ end
113
+
114
+ # ── lowering ───────────────────────────────────────────────────────────
115
+
116
+ # A normalised DSL expression → an IR expression tree: the constant (when
117
+ # nonzero), then the linear terms, then the quadratic ones — the term order
118
+ # the sibling front-ends emit.
119
+ #
120
+ # @param expr [Quicopt::Expression]
121
+ # @return [PB::Expression]
122
+ def expression(expr)
123
+ terms = []
124
+ terms << const(expr.constant) unless expr.constant.zero?
125
+ expr.linear.each { |v, c| terms << scaled(c, var(v.name)) }
126
+ expr.quadratic.each { |(a, b), c| terms << scaled(c, apply("*", [var(a.name), var(b.name)])) }
127
+ sum(terms)
128
+ end
129
+
130
+ # One DSL constraint row → an IR function-in-set row.
131
+ #
132
+ # @param con [Quicopt::Constraint]
133
+ # @return [PB::Constraint]
134
+ def constraint(con)
135
+ f = expression(con.body)
136
+ c = con.bound
137
+ case con.sense
138
+ when :eq then PB::Constraint.new(f: minus(f, c), set: con_set(:zero))
139
+ when :ge then PB::Constraint.new(f: minus(f, c), set: con_set(:nonneg))
140
+ when :le then PB::Constraint.new(f: geq(c, f), set: con_set(:nonneg))
141
+ else raise Error, "unknown constraint sense #{con.sense.inspect}"
142
+ end
143
+ end
144
+
145
+ # A variable declaration → +VarDecl+. Bounds are scalars (a Param-table
146
+ # bound needs indexed variables, which the DSL does not have); ±Inf passes
147
+ # straight through as a free direction.
148
+ #
149
+ # @param variable [Quicopt::Variable]
150
+ # @return [PB::VarDecl]
151
+ def var_decl(variable)
152
+ PB::VarDecl.new(name: variable.name,
153
+ domain: variable.domain,
154
+ lower: PB::Bound.new(scalar: number(variable.lower)),
155
+ upper: PB::Bound.new(scalar: number(variable.upper)),
156
+ start: number(variable.start))
157
+ end
158
+
159
+ private
160
+
161
+ # +f − c+, dropping the additive zero.
162
+ def minus(f, c)
163
+ c.zero? ? f : apply("-", [f, const(c)])
164
+ end
165
+
166
+ # +u − f+ — the body of +f ≤ u+ written as +u − f ≥ 0+.
167
+ def geq(u, f)
168
+ apply("-", [const(u), f])
169
+ end
170
+
171
+ def con_set(kind)
172
+ case kind
173
+ when :zero then PB::ConSet.new(zero: PB::Zero.new)
174
+ when :nonneg then PB::ConSet.new(nonneg: PB::Nonneg.new)
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
data/lib/quicopt.rb ADDED
@@ -0,0 +1,50 @@
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
+ require_relative "quicopt/version"
6
+ require_relative "quicopt/model"
7
+ require_relative "quicopt/wire"
8
+ require_relative "quicopt/client"
9
+
10
+ # Quicopt — a Ruby client for the Quicopt optimization service.
11
+ #
12
+ # Author a model in the native DSL, encode it to the versioned wire IR, and let
13
+ # the service solve it:
14
+ #
15
+ # require "quicopt"
16
+ #
17
+ # m = Quicopt::Model.new
18
+ # x = m.int_var(0, 4, "x")
19
+ # y = m.bin_var("y")
20
+ # z = m.num_var(0, 10, "z")
21
+ # m.maximize(3 * x + 2 * y + z)
22
+ # m.add(x + y <= 5)
23
+ #
24
+ # result = Quicopt::Client.new.solve(m)
25
+ # puts result.display
26
+ #
27
+ # Three pieces, nothing more: +Model+ authors, +Wire+ encodes, +Client+ ships.
28
+ module Quicopt
29
+ # The wire IR container, re-exported so callers can inspect or route the bytes
30
+ # themselves rather than going through +Client+.
31
+ Program = Wire::PB::Program
32
+
33
+ class << self
34
+ # Lower a model to wire bytes.
35
+ #
36
+ # @param model [Model, Program]
37
+ # @return [String] binary protobuf bytes
38
+ def encode(model)
39
+ Wire.encode(model)
40
+ end
41
+
42
+ # Parse wire bytes back into a +Program+.
43
+ #
44
+ # @param bytes [String]
45
+ # @return [Program]
46
+ def decode(bytes)
47
+ Wire.decode(bytes)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
3
+ // ===========================================================================
4
+ // Quicopt wire schema — v1
5
+ //
6
+ // The language-neutral API contract of the Quicopt service. A client authors a
7
+ // model in a front-end (JuMP, Pyomo, …), lowers it to the structured `Program`
8
+ // below, and serializes it to protobuf bytes; the service decodes and solves it.
9
+ // This file is the source of truth clients compile against.
10
+ // ---------------------------------------------------------------------------
11
+ // Schema evolution (v1):
12
+ // - Field numbers are permanent. Never reuse a number; `reserved` removed
13
+ // ones. Additive-only: new optional fields/messages may be appended.
14
+ // - `oneof` cases are append-only.
15
+ // - Breaking changes bump the package to `quicopt.modeler.v2` in a new file;
16
+ // v1 stays frozen.
17
+ //
18
+ // Conventions:
19
+ // - IR names (`x`, `i`, `S`, `+`, `min`) are `string` here; a client maps them
20
+ // to its own symbol type at the boundary. Data is never carried as a bare
21
+ // string, so the mapping is lossless.
22
+ // - Tuple-keyed tables (params, indexed_sets, fix) cannot be protobuf `map`
23
+ // (keys aren't scalar) — each is a `repeated Entry { key, value }`.
24
+ // ===========================================================================
25
+
26
+ syntax = "proto3";
27
+
28
+ package quicopt.modeler.v1;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Index machinery
32
+ // ---------------------------------------------------------------------------
33
+
34
+ // One entry of an index tuple: a concrete coordinate (`3`) or a bound index
35
+ // name (`i`).
36
+ message IndexElem {
37
+ oneof kind {
38
+ int64 int = 1; // a concrete coordinate
39
+ string name = 2; // a bound index name, e.g. i
40
+ }
41
+ }
42
+
43
+ // An index tuple: () scalar, (i,), (3,), (i, j) …
44
+ message Index {
45
+ repeated IndexElem elems = 1;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Expression tree (e ::= c │ p[idx] │ x[idx] │ op(e…) │ ⊕_{k∈S} e(k))
50
+ //
51
+ // Serialized as a tree — each node stands alone, so the structure is acyclic by
52
+ // construction.
53
+ // ---------------------------------------------------------------------------
54
+
55
+ message Expression {
56
+ oneof node {
57
+ double constant = 1; // Const(value) — always emitted (a set oneof case),
58
+ // so Const(0.0) survives the round-trip
59
+ ParamRef param = 2; // Param(name, index)
60
+ VarRef var = 3; // Var(name, index)
61
+ Apply apply = 4; // Apply(op, args)
62
+ Reduce reduce = 5; // Reduce(op, idx, over, body, cond)
63
+ }
64
+ }
65
+
66
+ message ParamRef {
67
+ string name = 1;
68
+ Index index = 2;
69
+ }
70
+
71
+ message VarRef {
72
+ string name = 1;
73
+ Index index = 2;
74
+ }
75
+
76
+ // Operator application. `op` is a key into the operator catalog (see Catalog),
77
+ // not code; an unknown op or an arity mismatch is rejected.
78
+ message Apply {
79
+ string op = 1;
80
+ repeated Expression args = 2;
81
+ }
82
+
83
+ // A reference to an index set, possibly applied to enclosing bound indices.
84
+ // args = () → a flat set (resolved against IndexSet); args = (i,) → a dependent
85
+ // set "S(i)" (resolved against IndexedSet once i is bound).
86
+ message SetRef {
87
+ string name = 1;
88
+ Index args = 2;
89
+ }
90
+
91
+ // Aggregation ⊕_{idx ∈ over} body, optionally filtered by `cond` (keep a term
92
+ // only where cond ≠ 0). `cond` absent (message not set) ⇒ no filter.
93
+ message Reduce {
94
+ string op = 1; // the fold operator key (+, *, …)
95
+ string idx = 2; // the bound dummy index name
96
+ SetRef over = 3;
97
+ Expression body = 4;
98
+ Expression cond = 5; // optional
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Constraints — function-in-set, both sides open
103
+ // ---------------------------------------------------------------------------
104
+
105
+ message Zero {} // f = 0
106
+ message Nonneg {} // f ≥ 0
107
+
108
+ // Indicator: the binary `bin` being active implies f lies in `inner`. Kept
109
+ // first-class on the wire (not desugared); the service decides how it is realized.
110
+ message Indicator {
111
+ VarRef bin = 1;
112
+ ConSet inner = 2;
113
+ }
114
+
115
+ message ConSet {
116
+ oneof kind {
117
+ Zero zero = 1;
118
+ Nonneg nonneg = 2;
119
+ Indicator indicator = 3;
120
+ }
121
+ }
122
+
123
+ // One (index symbol => set) binding of a constraint's quantifier.
124
+ message QuantBinding {
125
+ string idx = 1;
126
+ SetRef set = 2;
127
+ }
128
+
129
+ // f ∈ set, quantified over `over` (∅ over ⇒ a single flat constraint).
130
+ message Constraint {
131
+ Expression f = 1;
132
+ ConSet set = 2;
133
+ repeated QuantBinding over = 3;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Variables, sets, data
138
+ // ---------------------------------------------------------------------------
139
+
140
+ enum Domain {
141
+ DOMAIN_UNSPECIFIED = 0; // invalid on the wire; rejected on decode
142
+ CONTINUOUS = 1;
143
+ INTEGER = 2;
144
+ BINARY = 3;
145
+ }
146
+
147
+ // A per-variable bound: a scalar, or the name of a per-index Param table to look
148
+ // the bound up in (so a bound may vary by index).
149
+ message Bound {
150
+ oneof kind {
151
+ double scalar = 1;
152
+ string param = 2; // name of a Param table
153
+ }
154
+ }
155
+
156
+ // A variable *family* indexed over `axes` (set names; empty ⇒ scalar).
157
+ message VarDecl {
158
+ string name = 1;
159
+ repeated string axes = 2;
160
+ Domain domain = 3;
161
+ Bound lower = 4;
162
+ Bound upper = 5;
163
+ double start = 6;
164
+ }
165
+
166
+ // A flat index set: name + concrete elements.
167
+ message IndexSet {
168
+ string name = 1;
169
+ repeated IndexElem elements = 2;
170
+ }
171
+
172
+ // A dependent set carried as data: one fibre per key tuple, e.g. S[(i,)] → [j…].
173
+ message IndexedSetFibre {
174
+ Index key = 1;
175
+ repeated IndexElem value = 2;
176
+ }
177
+ message IndexedSet {
178
+ string name = 1;
179
+ repeated IndexedSetFibre fibres = 2;
180
+ }
181
+
182
+ // A named indexed data table: one entry per key tuple, e.g. p[(i,)], A[(i,j)].
183
+ message ParamEntry {
184
+ Index key = 1;
185
+ double value = 2;
186
+ }
187
+ message ParamTable {
188
+ string name = 1;
189
+ repeated ParamEntry entries = 2;
190
+ }
191
+
192
+ // A per-index pin: fix variable `var` at `index` to `value` (e.g. x[1] = 0).
193
+ message FixEntry {
194
+ string var = 1;
195
+ Index index = 2;
196
+ double value = 3;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Program — the structured authoring container (what crosses the wire)
201
+ // ---------------------------------------------------------------------------
202
+
203
+ message Program {
204
+ repeated IndexSet sets = 1;
205
+ repeated IndexedSet indexed_sets = 2;
206
+ repeated ParamTable params = 3;
207
+ repeated VarDecl vars = 4;
208
+ Expression objective = 5;
209
+ string sense = 6; // "min" | "max"
210
+ repeated Constraint constraints = 7;
211
+ repeated FixEntry fix = 8;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Rebinding — graph once, data per instance
216
+ //
217
+ // A client sends the Program (the graph) once, then a ParamData per instance to
218
+ // re-solve the same structure on new data without re-sending the expression trees.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ message ParamData {
222
+ repeated ParamTable params = 1;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Operator catalog — the versioned set of operators a client may emit
227
+ //
228
+ // Clients fetch the Catalog to discover the supported ops; `version` co-versions
229
+ // with the schema. arity = -1 ≡ variadic.
230
+ // ---------------------------------------------------------------------------
231
+
232
+ message OpDef {
233
+ string op = 1;
234
+ int32 arity = 2;
235
+ repeated string tags = 3;
236
+ }
237
+ message Catalog {
238
+ uint32 version = 1;
239
+ repeated OpDef ops = 2;
240
+ }