aura-lang 1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +154 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +59 -0
- data/aura-lang.gemspec +29 -0
- data/bin/aura +151 -0
- data/examples/assistant.aura +12 -0
- data/examples/chatbot.aura +7 -0
- data/examples/hello.aura +10 -0
- data/examples/mnist_classifier.aura +36 -0
- data/examples/sentiment.aura +13 -0
- data/examples/transfer_api.aura +25 -0
- data/lib/aura/analyzer.rb +125 -0
- data/lib/aura/codegen.rb +636 -0
- data/lib/aura/diagnostics.rb +86 -0
- data/lib/aura/docker.rb +81 -0
- data/lib/aura/emitter.rb +61 -0
- data/lib/aura/parser.rb +208 -0
- data/lib/aura/transformer.rb +152 -0
- data/lib/aura/vercel.rb +88 -0
- data/lib/aura/version.rb +8 -0
- data/lib/aura.rb +125 -0
- metadata +158 -0
data/lib/aura/codegen.rb
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "emitter"
|
|
4
|
+
|
|
5
|
+
module Aura
|
|
6
|
+
# Generates runnable Ruby from the semantic node list. Torch models become
|
|
7
|
+
# Torch::NN::Module subclasses with a real forward pass (shapes tracked so
|
|
8
|
+
# Linear in-features are computed correctly); LLM models become HTTP client
|
|
9
|
+
# methods; routes become classic-Sinatra handlers.
|
|
10
|
+
class CodeGen
|
|
11
|
+
OPTIMIZERS = { adam: "Adam", adamw: "AdamW", sgd: "SGD", rmsprop: "RMSprop", adagrad: "Adagrad" }.freeze
|
|
12
|
+
LOSSES = { cross_entropy: "CrossEntropyLoss", mse: "MSELoss", bce: "BCELoss",
|
|
13
|
+
bce_with_logits: "BCEWithLogitsLoss", nll: "NLLLoss" }.freeze
|
|
14
|
+
SCHEDULERS = { step_lr: "StepLR", exponential_lr: "ExponentialLR",
|
|
15
|
+
cosine_annealing_lr: "CosineAnnealingLR" }.freeze
|
|
16
|
+
|
|
17
|
+
def self.generate(nodes)
|
|
18
|
+
new(nodes).generate
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(nodes)
|
|
22
|
+
@nodes = nodes
|
|
23
|
+
@e = Emitter.new
|
|
24
|
+
@registry = build_registry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def generate
|
|
28
|
+
header
|
|
29
|
+
@nodes.each { |node| emit(node) }
|
|
30
|
+
@e.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_registry
|
|
36
|
+
@nodes.select { |n| n[:type] == :model }
|
|
37
|
+
.each_with_object({}) { |m, h| h[m[:name]] = m }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def llm?
|
|
41
|
+
@registry.values.any? { |m| m[:kind] == :llm }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transfer?
|
|
45
|
+
@registry.values.any? { |m| m[:kind] == :transfer }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True when the program trains or evaluates, i.e. needs the dataset loader.
|
|
49
|
+
def needs_dataset?
|
|
50
|
+
@nodes.any? { |n| %i[train evaluate].include?(n[:type]) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True when the generated app actually depends on Torch -- a torch/transfer
|
|
54
|
+
# model, or a training/evaluation loop. LLM-only and text apps don't, so we
|
|
55
|
+
# skip the torch require/DEVICE (which also keeps them Vercel-deployable).
|
|
56
|
+
def torch?
|
|
57
|
+
@registry.values.any? { |m| %i[torch transfer].include?(m[:kind]) } || needs_dataset?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The `device` from an `environment` block, if any (:cpu / :cuda).
|
|
61
|
+
def environment_device
|
|
62
|
+
env = @nodes.find { |n| n[:type] == :environment }
|
|
63
|
+
env && env[:settings] && env[:settings][:device]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def header
|
|
67
|
+
@e.comment "Generated by Aura v#{Aura::VERSION}"
|
|
68
|
+
@e.line %(begin; require "dotenv/load"; rescue LoadError; end)
|
|
69
|
+
@e.line %(require "sinatra")
|
|
70
|
+
@e.line %(require "json")
|
|
71
|
+
if llm?
|
|
72
|
+
@e.line %(require "net/http")
|
|
73
|
+
@e.line %(require "uri")
|
|
74
|
+
end
|
|
75
|
+
if torch?
|
|
76
|
+
@e.line %(begin; require "torch"; rescue LoadError; end)
|
|
77
|
+
@e.line %(begin; require "torchvision"; rescue LoadError; end) if transfer?
|
|
78
|
+
if needs_dataset?
|
|
79
|
+
@e.line %(begin; require "datasets"; rescue LoadError; end)
|
|
80
|
+
@e.line %(require "csv")
|
|
81
|
+
end
|
|
82
|
+
if environment_device == :cpu
|
|
83
|
+
@e.line %(DEVICE = "cpu")
|
|
84
|
+
else
|
|
85
|
+
@e.line %(DEVICE = (defined?(Torch::CUDA) && Torch::CUDA.available?) ? "cuda" : "cpu")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
@e.blank
|
|
89
|
+
emit_input_tensor_helper if torch?
|
|
90
|
+
emit_dataset_helper if needs_dataset?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build a model-ready tensor from JSON request input. Shared by every torch
|
|
94
|
+
# route so inference reshapes the payload to the model's input dims instead
|
|
95
|
+
# of crashing on a bare array.
|
|
96
|
+
def emit_input_tensor_helper
|
|
97
|
+
@e.comment "Build a model-ready tensor from JSON input. Accepts a single sample"
|
|
98
|
+
@e.comment "or a batch and reshapes to the model's input dims. Adjust the"
|
|
99
|
+
@e.comment "normalization to match how the model was trained."
|
|
100
|
+
@e.block("def aura_input_tensor(data, reshape)") do
|
|
101
|
+
@e.line "tensor = Torch.tensor(data, dtype: :float32)"
|
|
102
|
+
@e.line "tensor = tensor.reshape([-1] + reshape) if reshape"
|
|
103
|
+
@e.line "tensor.to(DEVICE)"
|
|
104
|
+
end
|
|
105
|
+
@e.blank
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def emit(node)
|
|
109
|
+
case node[:type]
|
|
110
|
+
when :environment then emit_environment(node)
|
|
111
|
+
when :dataset then emit_dataset(node)
|
|
112
|
+
when :model then emit_model(node)
|
|
113
|
+
when :train then emit_train(node)
|
|
114
|
+
when :evaluate then emit_evaluate(node)
|
|
115
|
+
when :route then emit_route(node)
|
|
116
|
+
when :run_web then emit_run_web(node)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ---- environment ---------------------------------------------------------
|
|
121
|
+
def emit_environment(node)
|
|
122
|
+
pairs = node[:settings].map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
123
|
+
@e.block("class AuraConfig") do
|
|
124
|
+
@e.line "SETTINGS = { #{pairs} }.freeze"
|
|
125
|
+
end
|
|
126
|
+
@e.blank
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ---- dataset -------------------------------------------------------------
|
|
130
|
+
def emit_dataset(node)
|
|
131
|
+
@e.comment %(Dataset: #{node[:name]} (#{node[:source]} "#{node[:path]}"))
|
|
132
|
+
@e.line "#{const(node[:name])}_DATASET = { source: #{node[:source].inspect}, " \
|
|
133
|
+
"path: #{node[:path].inspect}, options: #{node[:options].inspect} }.freeze"
|
|
134
|
+
@e.blank
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A shared loader, emitted once when the program trains or evaluates. It
|
|
138
|
+
# streams a dataset as [inputs, targets] tensor batches. Built-in loaders
|
|
139
|
+
# cover the red-datasets sets (MNIST / Fashion-MNIST / CIFAR); any other
|
|
140
|
+
# source raises a clear error so you can drop in your own loader.
|
|
141
|
+
def emit_dataset_helper
|
|
142
|
+
@e.block("def aura_each_batch(name, batch_size: 32, reshape: nil)") do
|
|
143
|
+
@e.line "return enum_for(:aura_each_batch, name, batch_size: batch_size, reshape: reshape) unless block_given?"
|
|
144
|
+
@e.comment "CSV: last column is the label, the rest are float features."
|
|
145
|
+
@e.block("if name.to_s =~ /\\.csv\\z/i") do
|
|
146
|
+
@e.line "rows = CSV.read(name.to_s)"
|
|
147
|
+
@e.line "rows.shift if rows.first && rows.first.any? { |c| c.to_s =~ /[A-Za-z]/ }"
|
|
148
|
+
@e.block("rows.each_slice(batch_size) do |slice|") do
|
|
149
|
+
@e.line "xs = slice.map { |r| r[0..-2].map(&:to_f) }"
|
|
150
|
+
@e.line "ys = slice.map { |r| r[-1].to_i }"
|
|
151
|
+
@e.line "yield aura_batch_tensors(xs, ys, reshape)"
|
|
152
|
+
end
|
|
153
|
+
@e.line "return"
|
|
154
|
+
end
|
|
155
|
+
@e.line %(type = name.to_s.include?("test") ? :test : :train)
|
|
156
|
+
@e.block("source = case name.to_s") do
|
|
157
|
+
@e.line "when /fashion/i then Datasets::FashionMNIST.new(type: type)"
|
|
158
|
+
@e.line "when /mnist/i then Datasets::MNIST.new(type: type)"
|
|
159
|
+
@e.line "when /cifar/i then Datasets::CIFAR.new(type: type)"
|
|
160
|
+
@e.line "else"
|
|
161
|
+
@e.indent do
|
|
162
|
+
@e.line %q{raise "Aura: no built-in loader for dataset '#{name}' [supported: mnist, fashion-mnist, cifar]. Add a loader here that yields [inputs, targets] tensors."}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
@e.line "xs = []"
|
|
166
|
+
@e.line "ys = []"
|
|
167
|
+
@e.block("source.each do |record|") do
|
|
168
|
+
@e.line "xs << Array(record.pixels).map { |px| px / 255.0 }"
|
|
169
|
+
@e.line "ys << record.label"
|
|
170
|
+
@e.block("if xs.length == batch_size") do
|
|
171
|
+
@e.line "yield aura_batch_tensors(xs, ys, reshape)"
|
|
172
|
+
@e.line "xs = []; ys = []"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
@e.line "yield aura_batch_tensors(xs, ys, reshape) unless xs.empty?"
|
|
176
|
+
end
|
|
177
|
+
@e.blank
|
|
178
|
+
@e.block("def aura_batch_tensors(xs, ys, reshape)") do
|
|
179
|
+
@e.line "inputs = Torch.tensor(xs, dtype: :float32)"
|
|
180
|
+
@e.line "inputs = inputs.reshape([inputs.size(0)] + reshape) if reshape"
|
|
181
|
+
@e.line "[inputs.to(DEVICE), Torch.tensor(ys, dtype: :int64).to(DEVICE)]"
|
|
182
|
+
end
|
|
183
|
+
@e.blank
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# ---- models --------------------------------------------------------------
|
|
187
|
+
def emit_model(node)
|
|
188
|
+
case node[:kind]
|
|
189
|
+
when :torch then emit_torch_model(node)
|
|
190
|
+
when :transfer then emit_transfer_model(node)
|
|
191
|
+
when :llm then emit_llm_model(node)
|
|
192
|
+
when :text then emit_text_model(node)
|
|
193
|
+
end
|
|
194
|
+
@e.blank
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def emit_torch_model(node)
|
|
198
|
+
cls = "#{camel(node[:name])}Model"
|
|
199
|
+
init_lines, fwd_lines = plan_layers(node[:layers])
|
|
200
|
+
|
|
201
|
+
emit_input_preprocessing(node)
|
|
202
|
+
@e.block("class #{cls} < Torch::NN::Module") do
|
|
203
|
+
@e.block("def initialize") do
|
|
204
|
+
@e.line "super"
|
|
205
|
+
init_lines.each { |l| @e.line l }
|
|
206
|
+
end
|
|
207
|
+
@e.block("def forward(x)") do
|
|
208
|
+
fwd_lines.each { |l| @e.line l }
|
|
209
|
+
@e.line "x"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
accessor(node[:name], "#{cls}.new.to(DEVICE)")
|
|
213
|
+
emit_weight_io(node)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Persistence directives declared in the model body (`load weights from` /
|
|
217
|
+
# `save weights to`) become real load calls and a save helper.
|
|
218
|
+
def emit_weight_io(node)
|
|
219
|
+
node[:layers].each do |layer|
|
|
220
|
+
case layer[:type]
|
|
221
|
+
when :load_weights
|
|
222
|
+
@e.line "#{node[:name]}_model.load_state_dict(Torch.load(#{layer[:path].inspect})) if File.exist?(#{layer[:path].inspect})"
|
|
223
|
+
when :save_weights
|
|
224
|
+
@e.line "def #{node[:name]}_save_weights; Torch.save(#{node[:name]}_model.state_dict, #{layer[:path].inspect}); end"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Walk the layers once, tracking the running tensor shape so Conv2d
|
|
230
|
+
# in-channels and Linear in-features come out right. Returns [init, forward].
|
|
231
|
+
def plan_layers(layers)
|
|
232
|
+
init = []
|
|
233
|
+
fwd = []
|
|
234
|
+
spatial = false
|
|
235
|
+
channels = h = w = nil
|
|
236
|
+
flat = nil
|
|
237
|
+
counts = Hash.new(0)
|
|
238
|
+
|
|
239
|
+
if (input = layers.find { |l| l[:type] == :input })
|
|
240
|
+
dims = input[:shape]
|
|
241
|
+
if dims.length == 3
|
|
242
|
+
h, w, channels = dims # Aura uses (H, W, C); Torch is channels-first
|
|
243
|
+
spatial = true
|
|
244
|
+
else
|
|
245
|
+
flat = dims.empty? ? nil : dims.reduce(:*)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
flatten = lambda do
|
|
250
|
+
if spatial
|
|
251
|
+
flat = channels * h * w
|
|
252
|
+
spatial = false
|
|
253
|
+
fwd << "x = x.view(x.size(0), -1)"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
layers.each do |layer|
|
|
258
|
+
case layer[:type]
|
|
259
|
+
when :conv2d
|
|
260
|
+
iv = "@conv#{counts[:conv] += 1}"
|
|
261
|
+
channels ||= 1 # no `input shape` declared -> assume 1 channel (keeps Ruby valid)
|
|
262
|
+
pad = (layer[:kernel] - 1) / 2
|
|
263
|
+
init << "#{iv} = Torch::NN::Conv2d.new(#{channels}, #{layer[:filters]}, #{layer[:kernel]}, padding: #{pad})"
|
|
264
|
+
fwd << "x = Torch::NN::F.relu(#{iv}.call(x))"
|
|
265
|
+
channels = layer[:filters]
|
|
266
|
+
when :maxpool2d
|
|
267
|
+
iv = "@pool#{counts[:pool] += 1}"
|
|
268
|
+
init << "#{iv} = Torch::NN::MaxPool2d.new(#{layer[:size]})"
|
|
269
|
+
fwd << "x = #{iv}.call(x)"
|
|
270
|
+
h = h / layer[:size] if h
|
|
271
|
+
w = w / layer[:size] if w
|
|
272
|
+
when :batchnorm
|
|
273
|
+
iv = "@bn#{counts[:bn] += 1}"
|
|
274
|
+
init << if spatial
|
|
275
|
+
"#{iv} = Torch::NN::BatchNorm2d.new(#{channels})"
|
|
276
|
+
else
|
|
277
|
+
"#{iv} = Torch::NN::BatchNorm1d.new(#{flat})"
|
|
278
|
+
end
|
|
279
|
+
fwd << "x = #{iv}.call(x)"
|
|
280
|
+
when :flatten
|
|
281
|
+
flatten.call
|
|
282
|
+
when :dropout
|
|
283
|
+
iv = "@drop#{counts[:drop] += 1}"
|
|
284
|
+
init << "#{iv} = Torch::NN::Dropout.new(p: #{layer[:rate]})"
|
|
285
|
+
fwd << "x = #{iv}.call(x)"
|
|
286
|
+
when :embedding
|
|
287
|
+
iv = "@emb#{counts[:emb] += 1}"
|
|
288
|
+
init << "#{iv} = Torch::NN::Embedding.new(#{layer[:vocab]}, #{layer[:dim]})"
|
|
289
|
+
fwd << "x = #{iv}.call(x)"
|
|
290
|
+
flat = layer[:dim]
|
|
291
|
+
spatial = false
|
|
292
|
+
when :lstm, :gru
|
|
293
|
+
klass = layer[:type] == :lstm ? "LSTM" : "GRU"
|
|
294
|
+
iv = "@rnn#{counts[:rnn] += 1}"
|
|
295
|
+
init << "#{iv} = Torch::NN::#{klass}.new(#{flat || 1}, #{layer[:units]}, batch_first: true)"
|
|
296
|
+
fwd << "x, _ = #{iv}.call(x)"
|
|
297
|
+
fwd << "x = x[0.., -1, 0..] # last timestep (best-effort; verify vs torch-rb)"
|
|
298
|
+
flat = layer[:units]
|
|
299
|
+
spatial = false
|
|
300
|
+
when :dense
|
|
301
|
+
flatten.call
|
|
302
|
+
iv = "@fc#{counts[:fc] += 1}"
|
|
303
|
+
init << "#{iv} = Torch::NN::Linear.new(#{flat || 1}, #{layer[:units]})"
|
|
304
|
+
fwd << "x = #{activation_expr("#{iv}.call(x)", layer[:activation])}"
|
|
305
|
+
flat = layer[:units]
|
|
306
|
+
when :output
|
|
307
|
+
flatten.call
|
|
308
|
+
init << "@out = Torch::NN::Linear.new(#{flat || 1}, #{layer[:units]})"
|
|
309
|
+
fwd << "x = #{activation_expr("@out.call(x)", layer[:activation])}"
|
|
310
|
+
flat = layer[:units]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
[init, fwd]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def activation_expr(call_expr, activation)
|
|
318
|
+
case activation
|
|
319
|
+
when :softmax then "Torch::NN::F.softmax(#{call_expr}, dim: 1)"
|
|
320
|
+
when :linear, nil then call_expr
|
|
321
|
+
else "Torch::NN::F.#{activation}(#{call_expr})"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def emit_transfer_model(node)
|
|
326
|
+
cls = "#{camel(node[:name])}Model"
|
|
327
|
+
layers = Array(node[:layers])
|
|
328
|
+
output = layers.find { |l| l[:type] == :output }
|
|
329
|
+
freeze = layers.any? { |l| l[:type] == :freeze }
|
|
330
|
+
unfreeze = layers.any? { |l| l[:type] == :unfreeze_all }
|
|
331
|
+
|
|
332
|
+
@e.block("class #{cls} < Torch::NN::Module") do
|
|
333
|
+
@e.block("def initialize") do
|
|
334
|
+
@e.line "super"
|
|
335
|
+
@e.line "@base = TorchVision::Models.#{node[:base_model]}(pretrained: true)"
|
|
336
|
+
if unfreeze
|
|
337
|
+
@e.line "@base.parameters.each { |p| p.requires_grad = true }"
|
|
338
|
+
elsif freeze
|
|
339
|
+
@e.comment "Freeze the pretrained backbone; only the new head trains."
|
|
340
|
+
@e.line "@base.parameters.each { |p| p.requires_grad = false }"
|
|
341
|
+
end
|
|
342
|
+
if output
|
|
343
|
+
@e.comment "New classification head. 1000 = ImageNet logit width of the"
|
|
344
|
+
@e.comment "base; adjust if your base model exposes a different size."
|
|
345
|
+
@e.line "@head = Torch::NN::Linear.new(1000, #{output[:units]})"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
if output
|
|
349
|
+
@e.block("def forward(x)") do
|
|
350
|
+
@e.line "x = @base.call(x)"
|
|
351
|
+
@e.line "x = #{activation_expr('@head.call(x)', output[:activation])}"
|
|
352
|
+
@e.line "x"
|
|
353
|
+
end
|
|
354
|
+
else
|
|
355
|
+
@e.line "def forward(x); @base.call(x); end"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
accessor(node[:name], "#{cls}.new.to(DEVICE)")
|
|
359
|
+
emit_weight_io(node)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def emit_llm_model(node)
|
|
363
|
+
case node[:provider]
|
|
364
|
+
when :openai then emit_openai(node)
|
|
365
|
+
when :ollama then emit_ollama(node)
|
|
366
|
+
else emit_openai(node)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def emit_openai(node)
|
|
371
|
+
@e.block("def #{node[:name]}_predict(prompt)") do
|
|
372
|
+
@e.line %(api_key = ENV["OPENAI_API_KEY"])
|
|
373
|
+
@e.line %(uri = URI("https://api.openai.com/v1/chat/completions"))
|
|
374
|
+
@e.line "http = Net::HTTP.new(uri.host, uri.port)"
|
|
375
|
+
@e.line "http.use_ssl = true"
|
|
376
|
+
@e.line "http.open_timeout = 10"
|
|
377
|
+
@e.line "http.read_timeout = 60"
|
|
378
|
+
@e.line "request = Net::HTTP::Post.new(uri)"
|
|
379
|
+
@e.line %(request["Content-Type"] = "application/json")
|
|
380
|
+
@e.line %(request["Authorization"] = "Bearer #{'#{api_key}'}")
|
|
381
|
+
if node[:system]
|
|
382
|
+
@e.line "messages = [{ role: \"system\", content: #{node[:system].inspect} }, { role: \"user\", content: prompt }]"
|
|
383
|
+
else
|
|
384
|
+
@e.line "messages = [{ role: \"user\", content: prompt }]"
|
|
385
|
+
end
|
|
386
|
+
parts = ["model: #{node[:model_id].inspect}", "messages: messages"]
|
|
387
|
+
parts << "temperature: #{node[:temperature]}" if node[:temperature]
|
|
388
|
+
parts << "max_tokens: #{node[:max_tokens]}" if node[:max_tokens]
|
|
389
|
+
@e.line "request.body = { #{parts.join(', ')} }.to_json"
|
|
390
|
+
@e.line "response = http.request(request)"
|
|
391
|
+
@e.line %(return { error: "upstream " + response.code.to_s } unless response.is_a?(Net::HTTPSuccess))
|
|
392
|
+
@e.line %(JSON.parse(response.body).dig("choices", 0, "message", "content"))
|
|
393
|
+
@e.line "rescue => e"
|
|
394
|
+
@e.indent { @e.line "{ error: e.message }" }
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def emit_ollama(node)
|
|
399
|
+
@e.block("def #{node[:name]}_predict(prompt)") do
|
|
400
|
+
@e.line %(uri = URI("http://localhost:11434/api/generate"))
|
|
401
|
+
@e.line "http = Net::HTTP.new(uri.host, uri.port)"
|
|
402
|
+
@e.line "http.open_timeout = 10"
|
|
403
|
+
@e.line "http.read_timeout = 120"
|
|
404
|
+
@e.line "request = Net::HTTP::Post.new(uri)"
|
|
405
|
+
@e.line %(request["Content-Type"] = "application/json")
|
|
406
|
+
parts = ["model: #{node[:model_id].inspect}", "prompt: prompt", "stream: false"]
|
|
407
|
+
parts << "system: #{node[:system].inspect}" if node[:system]
|
|
408
|
+
opts = []
|
|
409
|
+
opts << "temperature: #{node[:temperature]}" if node[:temperature]
|
|
410
|
+
opts << "num_predict: #{node[:max_tokens]}" if node[:max_tokens]
|
|
411
|
+
parts << "options: { #{opts.join(', ')} }" unless opts.empty?
|
|
412
|
+
@e.line "request.body = { #{parts.join(', ')} }.to_json"
|
|
413
|
+
@e.line "response = http.request(request)"
|
|
414
|
+
@e.line %(return { error: "upstream " + response.code.to_s } unless response.is_a?(Net::HTTPSuccess))
|
|
415
|
+
@e.line %(JSON.parse(response.body)["response"])
|
|
416
|
+
@e.line "rescue => e"
|
|
417
|
+
@e.indent { @e.line "{ error: e.message }" }
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def emit_text_model(node)
|
|
422
|
+
@e.line "def #{node[:name]}_greeting; #{node[:greeting].inspect}; end"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# ---- training ------------------------------------------------------------
|
|
426
|
+
# Training only runs in training mode (AURA_TRAIN=1, via `aura train`), so it
|
|
427
|
+
# does not re-run on every server boot. `aura run` loads weights and serves.
|
|
428
|
+
def emit_train(node)
|
|
429
|
+
cfg = node[:config]
|
|
430
|
+
model = node[:model]
|
|
431
|
+
model_node = @registry[model]
|
|
432
|
+
epochs = cfg[:epochs] || 1
|
|
433
|
+
batch_size = cfg[:batch_size] || 32
|
|
434
|
+
reshape = input_reshape(model_node)
|
|
435
|
+
track_acc = Array(cfg[:metrics]).include?(:accuracy)
|
|
436
|
+
has_sched = cfg[:scheduler] && SCHEDULERS.key?(cfg[:scheduler])
|
|
437
|
+
batch_call = %(aura_each_batch(#{node[:dataset].inspect}, batch_size: #{batch_size}, reshape: #{reshape.inspect}))
|
|
438
|
+
|
|
439
|
+
@e.comment %(Training: #{model} on "#{node[:dataset]}" (runs only when AURA_TRAIN=1))
|
|
440
|
+
@e.block(%(if ENV["AURA_TRAIN"] == "1")) do
|
|
441
|
+
@e.line "#{model}_model.train"
|
|
442
|
+
@e.line "optimizer = Torch::Optim::#{OPTIMIZERS.fetch(cfg[:optimizer], 'Adam')}.new(#{model}_model.parameters, lr: #{cfg[:lr] || 0.001})"
|
|
443
|
+
@e.line "criterion = Torch::NN::#{LOSSES.fetch(cfg[:loss], 'CrossEntropyLoss')}.new"
|
|
444
|
+
@e.line "scheduler = #{scheduler_ctor(cfg[:scheduler], epochs)}" if has_sched
|
|
445
|
+
|
|
446
|
+
@e.block("#{epochs}.times do |epoch|") do
|
|
447
|
+
@e.line "running_loss = 0.0"
|
|
448
|
+
if track_acc
|
|
449
|
+
@e.line "correct = 0"
|
|
450
|
+
@e.line "total = 0"
|
|
451
|
+
end
|
|
452
|
+
@e.block("#{batch_call} do |inputs, targets|") do
|
|
453
|
+
@e.line "optimizer.zero_grad"
|
|
454
|
+
@e.line "outputs = #{model}_model.call(inputs)"
|
|
455
|
+
@e.line "loss = criterion.call(outputs, targets)"
|
|
456
|
+
@e.line "loss.backward"
|
|
457
|
+
@e.line "optimizer.step"
|
|
458
|
+
@e.line "running_loss += loss.item"
|
|
459
|
+
if track_acc
|
|
460
|
+
@e.line "correct += outputs.argmax(1).eq(targets).sum.item"
|
|
461
|
+
@e.line "total += targets.size(0)"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
@e.line "scheduler.step" if has_sched
|
|
465
|
+
if track_acc
|
|
466
|
+
@e.line %(puts "Epoch " + (epoch + 1).to_s + "/#{epochs} - loss: " + running_loss.round(4).to_s + " - accuracy: " + (total.zero? ? 0.0 : (correct.to_f / total).round(4)).to_s)
|
|
467
|
+
else
|
|
468
|
+
@e.line %(puts "Epoch " + (epoch + 1).to_s + "/#{epochs} - loss: " + running_loss.round(4).to_s)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
@e.line "#{model}_save_weights" if saves_weights?(model_node)
|
|
472
|
+
end
|
|
473
|
+
@e.blank
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Each LR scheduler takes different constructor arguments; emit the right
|
|
477
|
+
# ones (defaults chosen sensibly). Verify against your torch-rb version.
|
|
478
|
+
def scheduler_ctor(scheduler, epochs)
|
|
479
|
+
args = case scheduler
|
|
480
|
+
when :exponential_lr then "gamma: 0.9"
|
|
481
|
+
when :cosine_annealing_lr then "t_max: #{epochs}"
|
|
482
|
+
else "step_size: 1, gamma: 0.1" # step_lr
|
|
483
|
+
end
|
|
484
|
+
"Torch::Optim::LRScheduler::#{SCHEDULERS[scheduler]}.new(optimizer, #{args})"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# ---- evaluation ----------------------------------------------------------
|
|
488
|
+
def emit_evaluate(node)
|
|
489
|
+
model = node[:model]
|
|
490
|
+
reshape = input_reshape(@registry[model])
|
|
491
|
+
batch_call = %(aura_each_batch(#{node[:dataset].inspect}, reshape: #{reshape.inspect}))
|
|
492
|
+
|
|
493
|
+
@e.comment %(Evaluation: #{model} on "#{node[:dataset]}" (runs only when AURA_TRAIN=1))
|
|
494
|
+
@e.block(%(if ENV["AURA_TRAIN"] == "1")) do
|
|
495
|
+
@e.line "#{model}_model.eval"
|
|
496
|
+
@e.line "eval_correct = 0"
|
|
497
|
+
@e.line "eval_total = 0"
|
|
498
|
+
@e.block("Torch.no_grad do") do
|
|
499
|
+
@e.block("#{batch_call} do |inputs, targets|") do
|
|
500
|
+
@e.line "outputs = #{model}_model.call(inputs)"
|
|
501
|
+
@e.line "eval_correct += outputs.argmax(1).eq(targets).sum.item"
|
|
502
|
+
@e.line "eval_total += targets.size(0)"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
@e.line %(puts "Eval accuracy (#{model}): " + (eval_total.zero? ? 0.0 : (eval_correct.to_f / eval_total).round(4)).to_s)
|
|
506
|
+
end
|
|
507
|
+
@e.blank
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Channels-first [C, H, W] reshape hint for a model whose input is a 3-D
|
|
511
|
+
# (H, W, C) image, so flat dataset rows can be folded back into images.
|
|
512
|
+
# nil for flat inputs. A `resize N` input transform overrides the spatial
|
|
513
|
+
# dimensions.
|
|
514
|
+
def input_reshape(model_node)
|
|
515
|
+
return nil unless model_node
|
|
516
|
+
|
|
517
|
+
input = Array(model_node[:layers]).find { |l| l[:type] == :input }
|
|
518
|
+
return nil unless input && Array(input[:shape]).length == 3
|
|
519
|
+
|
|
520
|
+
h, w, c = input[:shape]
|
|
521
|
+
if (resize = Array(input[:transforms]).find { |t| t[:transform] == :resize })
|
|
522
|
+
h = w = resize[:value]
|
|
523
|
+
end
|
|
524
|
+
[c, h, w]
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def saves_weights?(model_node)
|
|
528
|
+
model_node && Array(model_node[:layers]).any? { |l| l[:type] == :save_weights }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Surface declared `input shape(...) do resize N; to_tensor end` preprocessing
|
|
532
|
+
# in the output. `resize` feeds the dataset loader's reshape (see
|
|
533
|
+
# input_reshape); the rest is documented for the data pipeline.
|
|
534
|
+
def emit_input_preprocessing(node)
|
|
535
|
+
input = Array(node[:layers]).find { |l| l[:type] == :input }
|
|
536
|
+
transforms = input && Array(input[:transforms])
|
|
537
|
+
return if transforms.nil? || transforms.empty?
|
|
538
|
+
|
|
539
|
+
desc = transforms.map { |t| t[:value] ? "#{t[:transform]} #{t[:value]}" : t[:transform].to_s }.join(", ")
|
|
540
|
+
@e.comment "Input preprocessing: #{desc}"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# ---- routes --------------------------------------------------------------
|
|
544
|
+
def emit_route(node)
|
|
545
|
+
verb = node[:method].downcase
|
|
546
|
+
model = @registry[node[:model]]
|
|
547
|
+
@e.block("#{verb} #{node[:path].inspect} do") do
|
|
548
|
+
emit_auth if node[:auth]
|
|
549
|
+
@e.line "content_type :json" if node[:format] == :json || node[:format].nil?
|
|
550
|
+
emit_route_body(model, node[:input_var], node[:postprocess])
|
|
551
|
+
end
|
|
552
|
+
@e.blank
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Constant-time bearer-token check against AURA_API_TOKEN; refuses outright
|
|
556
|
+
# when the server token isn't configured.
|
|
557
|
+
def emit_auth
|
|
558
|
+
@e.comment "Bearer-token auth; set AURA_API_TOKEN in the environment (.env)."
|
|
559
|
+
@e.line %(auth_token = ENV["AURA_API_TOKEN"].to_s)
|
|
560
|
+
@e.line %(halt 401, { error: "auth not configured" }.to_json if auth_token.empty?)
|
|
561
|
+
@e.line %(provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/\\ABearer /, ""))
|
|
562
|
+
@e.line "halt 401 unless Rack::Utils.secure_compare(auth_token, provided)"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# `input_var` is the variable named in the DSL's `model.predict(<var>)`, used
|
|
566
|
+
# as the JSON key the handler reads the request payload from.
|
|
567
|
+
def emit_route_body(model, input_var, post_process = nil)
|
|
568
|
+
key = (input_var || "input").to_s
|
|
569
|
+
kind = model && model[:kind]
|
|
570
|
+
case kind
|
|
571
|
+
when :llm
|
|
572
|
+
@e.line "payload = JSON.parse(request.body.read) rescue {}"
|
|
573
|
+
@e.line "result = #{model[:name]}_predict(payload[#{key.inspect}])"
|
|
574
|
+
@e.line "{ response: result }.to_json"
|
|
575
|
+
when :text
|
|
576
|
+
@e.line "{ greeting: #{model[:name]}_greeting }.to_json"
|
|
577
|
+
when :torch, :transfer
|
|
578
|
+
@e.line "payload = JSON.parse(request.body.read) rescue {}"
|
|
579
|
+
@e.line "input = payload[#{key.inspect}]"
|
|
580
|
+
@e.line %(halt 400, { error: "missing '#{key}' in request body" }.to_json if input.nil?)
|
|
581
|
+
@e.line "begin"
|
|
582
|
+
@e.indent do
|
|
583
|
+
@e.line "tensor = aura_input_tensor(input, #{input_reshape(model).inspect})"
|
|
584
|
+
@e.block("result = Torch.no_grad do") do
|
|
585
|
+
@e.line "#{model[:name]}_model.eval"
|
|
586
|
+
@e.line "#{model[:name]}_model.call(tensor)"
|
|
587
|
+
end
|
|
588
|
+
if post_process == :label
|
|
589
|
+
@e.line "{ label: result.argmax(1).to_a }.to_json"
|
|
590
|
+
else
|
|
591
|
+
@e.line "{ prediction: result.to_a }.to_json"
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
@e.line "rescue => e"
|
|
595
|
+
@e.indent { @e.line %(halt 500, { error: e.message }.to_json) }
|
|
596
|
+
@e.line "end"
|
|
597
|
+
else
|
|
598
|
+
@e.line "{ status: \"ok\" }.to_json"
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# ---- run -----------------------------------------------------------------
|
|
603
|
+
# In training mode (AURA_TRAIN=1) the app trains and exits without serving;
|
|
604
|
+
# otherwise `set :run, true` boots the server (classic Sinatra starts at exit
|
|
605
|
+
# even when loaded via eval/require).
|
|
606
|
+
def emit_run_web(node)
|
|
607
|
+
unless @nodes.any? { |n| n[:type] == :route && n[:path] == "/health" }
|
|
608
|
+
@e.comment "Auto health check."
|
|
609
|
+
@e.block(%(get "/health" do)) do
|
|
610
|
+
@e.line "content_type :json"
|
|
611
|
+
@e.line %({ status: "ok" }.to_json)
|
|
612
|
+
end
|
|
613
|
+
@e.blank
|
|
614
|
+
end
|
|
615
|
+
@e.comment "Serve unless we're in training mode (then train and exit)."
|
|
616
|
+
@e.line "set :port, #{node[:port]}"
|
|
617
|
+
@e.line %(set :bind, "0.0.0.0")
|
|
618
|
+
@e.line %(set :run, ENV["AURA_TRAIN"] != "1")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# ---- helpers -------------------------------------------------------------
|
|
622
|
+
# A memoized, top-level accessor so the model is reachable from inside
|
|
623
|
+
# Sinatra route blocks (which run in their own context).
|
|
624
|
+
def accessor(name, expression)
|
|
625
|
+
@e.line "def #{name}_model; $aura_#{name}_model ||= #{expression}; end"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def camel(name)
|
|
629
|
+
name.to_s.split(/[_\s]+/).map { |p| p.empty? ? p : p[0].upcase + p[1..] }.join
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def const(name)
|
|
633
|
+
name.to_s.gsub(/[^a-zA-Z0-9]/, "_").upcase
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|