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.
@@ -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