kumi 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +160 -8
- data/README.md +278 -200
- data/{documents → docs}/AST.md +29 -29
- data/{documents → docs}/DSL.md +3 -3
- data/{documents → docs}/SYNTAX.md +107 -24
- data/docs/features/README.md +45 -0
- data/docs/features/analysis-cascade-mutual-exclusion.md +89 -0
- data/docs/features/analysis-type-inference.md +42 -0
- data/docs/features/analysis-unsat-detection.md +71 -0
- data/docs/features/array-broadcasting.md +170 -0
- data/docs/features/input-declaration-system.md +42 -0
- data/docs/features/performance.md +16 -0
- data/examples/federal_tax_calculator_2024.rb +43 -40
- data/examples/game_of_life.rb +97 -0
- data/examples/simple_rpg_game.rb +1000 -0
- data/examples/static_analysis_errors.rb +178 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +1 -1
- data/lib/kumi/analyzer/analysis_state.rb +37 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +22 -16
- data/lib/kumi/analyzer/passes/broadcast_detector.rb +251 -0
- data/lib/kumi/analyzer/passes/{definition_validator.rb → declaration_validator.rb} +8 -7
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +106 -26
- data/lib/kumi/analyzer/passes/input_collector.rb +105 -23
- data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
- data/lib/kumi/analyzer/passes/pass_base.rb +11 -28
- data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +45 -9
- data/lib/kumi/analyzer/passes/type_checker.rb +34 -11
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
- data/lib/kumi/analyzer/passes/type_inferencer.rb +128 -21
- data/lib/kumi/analyzer/passes/unsat_detector.rb +312 -13
- data/lib/kumi/analyzer/passes/visitor_pass.rb +4 -3
- data/lib/kumi/analyzer.rb +41 -24
- data/lib/kumi/atom_unsat_solver.rb +45 -0
- data/lib/kumi/cli.rb +449 -0
- data/lib/kumi/compiler.rb +194 -16
- data/lib/kumi/constraint_relationship_solver.rb +638 -0
- data/lib/kumi/domain/validator.rb +0 -4
- data/lib/kumi/error_reporter.rb +6 -6
- data/lib/kumi/evaluation_wrapper.rb +20 -4
- data/lib/kumi/explain.rb +28 -28
- data/lib/kumi/export/node_registry.rb +26 -12
- data/lib/kumi/export/node_serializers.rb +1 -1
- data/lib/kumi/function_registry/collection_functions.rb +117 -9
- data/lib/kumi/function_registry/function_builder.rb +4 -3
- data/lib/kumi/function_registry.rb +8 -2
- data/lib/kumi/input/type_matcher.rb +3 -0
- data/lib/kumi/input/validator.rb +0 -3
- data/lib/kumi/parser/declaration_reference_proxy.rb +36 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +19 -8
- data/lib/kumi/parser/expression_converter.rb +80 -12
- data/lib/kumi/parser/input_builder.rb +40 -9
- data/lib/kumi/parser/input_field_proxy.rb +46 -0
- data/lib/kumi/parser/input_proxy.rb +3 -3
- data/lib/kumi/parser/nested_input.rb +15 -0
- data/lib/kumi/parser/parser.rb +2 -0
- data/lib/kumi/parser/schema_builder.rb +10 -9
- data/lib/kumi/parser/sugar.rb +171 -18
- data/lib/kumi/schema.rb +3 -1
- data/lib/kumi/schema_instance.rb +69 -3
- data/lib/kumi/syntax/array_expression.rb +15 -0
- data/lib/kumi/syntax/call_expression.rb +11 -0
- data/lib/kumi/syntax/cascade_expression.rb +11 -0
- data/lib/kumi/syntax/case_expression.rb +11 -0
- data/lib/kumi/syntax/declaration_reference.rb +11 -0
- data/lib/kumi/syntax/hash_expression.rb +11 -0
- data/lib/kumi/syntax/input_declaration.rb +12 -0
- data/lib/kumi/syntax/input_element_reference.rb +12 -0
- data/lib/kumi/syntax/input_reference.rb +12 -0
- data/lib/kumi/syntax/literal.rb +11 -0
- data/lib/kumi/syntax/root.rb +1 -0
- data/lib/kumi/syntax/trait_declaration.rb +11 -0
- data/lib/kumi/syntax/value_declaration.rb +11 -0
- data/lib/kumi/types/compatibility.rb +8 -0
- data/lib/kumi/types/validator.rb +1 -1
- data/lib/kumi/vectorization_metadata.rb +108 -0
- data/lib/kumi/version.rb +1 -1
- data/scripts/generate_function_docs.rb +22 -10
- metadata +38 -17
- data/CHANGELOG.md +0 -25
- data/lib/kumi/domain.rb +0 -8
- data/lib/kumi/input.rb +0 -8
- data/lib/kumi/syntax/declarations.rb +0 -23
- data/lib/kumi/syntax/expressions.rb +0 -30
- data/lib/kumi/syntax/terminal_expressions.rb +0 -27
- data/lib/kumi/syntax.rb +0 -9
- data/test_impossible_cascade.rb +0 -51
- /data/{documents → docs}/FUNCTIONS.md +0 -0
data/lib/kumi/cli.rb
ADDED
@@ -0,0 +1,449 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "yaml"
|
5
|
+
require "optparse"
|
6
|
+
require "irb"
|
7
|
+
|
8
|
+
module Kumi
|
9
|
+
module CLI
|
10
|
+
class Application
|
11
|
+
def initialize
|
12
|
+
@options = {
|
13
|
+
interactive: false,
|
14
|
+
schema_file: nil,
|
15
|
+
input_file: nil,
|
16
|
+
output_format: :pretty,
|
17
|
+
keys: [],
|
18
|
+
explain: false
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
def run(args = ARGV)
|
23
|
+
parse_options(args)
|
24
|
+
|
25
|
+
if @options[:interactive]
|
26
|
+
start_repl
|
27
|
+
elsif @options[:schema_file]
|
28
|
+
execute_schema_file
|
29
|
+
else
|
30
|
+
show_help_and_exit
|
31
|
+
end
|
32
|
+
rescue StandardError => e
|
33
|
+
puts "Error: #{e.message}"
|
34
|
+
exit 1
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def parse_options(args)
|
40
|
+
parser = OptionParser.new do |opts|
|
41
|
+
opts.banner = "Usage: kumi [options]"
|
42
|
+
opts.separator ""
|
43
|
+
opts.separator "Options:"
|
44
|
+
|
45
|
+
opts.on("-i", "--interactive", "Start interactive REPL mode") do
|
46
|
+
@options[:interactive] = true
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-f", "--file FILE", "Load schema from Ruby file") do |file|
|
50
|
+
@options[:schema_file] = file
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on("-d", "--data FILE", "Load input data from JSON/YAML file") do |file|
|
54
|
+
@options[:input_file] = file
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on("-k", "--keys KEY1,KEY2", Array, "Extract specific keys (comma-separated)") do |keys|
|
58
|
+
@options[:keys] = keys.map(&:to_sym)
|
59
|
+
end
|
60
|
+
|
61
|
+
opts.on("-e", "--explain KEY", "Explain how a specific key is computed") do |key|
|
62
|
+
@options[:explain] = key.to_sym
|
63
|
+
end
|
64
|
+
|
65
|
+
opts.on("-o", "--format FORMAT", %i[pretty json yaml], "Output format: pretty, json, yaml") do |format|
|
66
|
+
@options[:output_format] = format
|
67
|
+
end
|
68
|
+
|
69
|
+
opts.on("-h", "--help", "Show this help message") do
|
70
|
+
puts opts
|
71
|
+
exit
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
parser.parse!(args)
|
76
|
+
end
|
77
|
+
|
78
|
+
def show_help_and_exit
|
79
|
+
puts <<~HELP
|
80
|
+
Kumi CLI - Declarative decision modeling for Ruby
|
81
|
+
|
82
|
+
Usage:
|
83
|
+
kumi -i # Start interactive mode
|
84
|
+
kumi -f schema.rb -d data.json # Execute schema with data
|
85
|
+
kumi -f schema.rb -k key1,key2 # Extract specific keys
|
86
|
+
kumi -f schema.rb -e key_name # Explain computation
|
87
|
+
|
88
|
+
Examples:
|
89
|
+
# Interactive mode for rapid testing
|
90
|
+
kumi -i
|
91
|
+
|
92
|
+
# Execute schema file with JSON data
|
93
|
+
kumi -f my_schema.rb -d input.json
|
94
|
+
|
95
|
+
# Get specific values in JSON format
|
96
|
+
kumi -f my_schema.rb -d input.yaml -k salary,bonus -o json
|
97
|
+
|
98
|
+
# Debug a specific computation
|
99
|
+
kumi -f my_schema.rb -d input.json -e total_compensation
|
100
|
+
|
101
|
+
For more information, see: https://github.com/amuta/kumi
|
102
|
+
HELP
|
103
|
+
exit
|
104
|
+
end
|
105
|
+
|
106
|
+
def start_repl
|
107
|
+
puts "🚀 Kumi Interactive REPL"
|
108
|
+
puts "Type 'help' for commands, 'exit' to quit"
|
109
|
+
puts
|
110
|
+
|
111
|
+
repl = InteractiveREPL.new
|
112
|
+
repl.start
|
113
|
+
end
|
114
|
+
|
115
|
+
def execute_schema_file
|
116
|
+
schema_module = load_schema_file(@options[:schema_file])
|
117
|
+
input_data = load_input_data(@options[:input_file])
|
118
|
+
|
119
|
+
runner = schema_module.from(input_data)
|
120
|
+
|
121
|
+
if @options[:explain]
|
122
|
+
result = schema_module.explain(input_data, @options[:explain])
|
123
|
+
puts result
|
124
|
+
elsif @options[:keys].any?
|
125
|
+
result = runner.slice(*@options[:keys])
|
126
|
+
output_result(result)
|
127
|
+
else
|
128
|
+
# Show available keys if no specific keys requested
|
129
|
+
puts "Schema loaded successfully!"
|
130
|
+
available_bindings = schema_module.__compiled_schema__.bindings.keys
|
131
|
+
puts "Available keys: #{available_bindings.join(', ')}"
|
132
|
+
puts "Use -k to extract specific keys or -e to explain computations"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def load_schema_file(file_path)
|
137
|
+
raise "Schema file not found: #{file_path}" unless File.exist?(file_path)
|
138
|
+
|
139
|
+
# Load the file and extract the module
|
140
|
+
require_relative File.expand_path(file_path)
|
141
|
+
|
142
|
+
# Find the module name from the file
|
143
|
+
module_name = extract_module_name_from_file(file_path)
|
144
|
+
|
145
|
+
raise "Could not find module extending Kumi::Schema in #{file_path}" unless module_name
|
146
|
+
|
147
|
+
# Get the module constant
|
148
|
+
schema_module = Object.const_get(module_name)
|
149
|
+
|
150
|
+
raise "Module #{module_name} does not have a compiled schema" unless schema_module.__compiled_schema__
|
151
|
+
|
152
|
+
schema_module
|
153
|
+
end
|
154
|
+
|
155
|
+
def extract_module_name_from_file(file_path)
|
156
|
+
content = File.read(file_path)
|
157
|
+
|
158
|
+
# Look for "module ModuleName" pattern
|
159
|
+
if (match = content.match(/^\s*module\s+(\w+)/))
|
160
|
+
match[1]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def load_input_data(file_path)
|
165
|
+
return {} unless file_path
|
166
|
+
|
167
|
+
raise "Input file not found: #{file_path}" unless File.exist?(file_path)
|
168
|
+
|
169
|
+
case File.extname(file_path).downcase
|
170
|
+
when ".json"
|
171
|
+
JSON.parse(File.read(file_path), symbolize_names: true)
|
172
|
+
when ".yml", ".yaml"
|
173
|
+
YAML.safe_load_file(file_path, symbolize_names: true)
|
174
|
+
else
|
175
|
+
raise "Unsupported input file format. Use .json or .yaml"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def output_result(result)
|
180
|
+
case @options[:output_format]
|
181
|
+
when :json
|
182
|
+
puts JSON.pretty_generate(result)
|
183
|
+
when :yaml
|
184
|
+
puts result.to_yaml
|
185
|
+
else
|
186
|
+
output_pretty(result)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def output_pretty(result)
|
191
|
+
case result
|
192
|
+
when Hash
|
193
|
+
result.each do |key, value|
|
194
|
+
puts "#{key}: #{format_value(value)}"
|
195
|
+
end
|
196
|
+
when Kumi::Explain::Result
|
197
|
+
puts "Explanation for: #{result.key}"
|
198
|
+
puts "Value: #{format_value(result.value)}"
|
199
|
+
puts
|
200
|
+
puts "Computation trace:"
|
201
|
+
result.trace.each do |step|
|
202
|
+
puts " #{step[:operation]} -> #{format_value(step[:result])}"
|
203
|
+
end
|
204
|
+
else
|
205
|
+
puts format_value(result)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def format_value(value)
|
210
|
+
case value
|
211
|
+
when String
|
212
|
+
value.inspect
|
213
|
+
when Numeric
|
214
|
+
value.is_a?(Float) ? value.round(2) : value
|
215
|
+
when Array, Hash
|
216
|
+
value.inspect
|
217
|
+
else
|
218
|
+
value.to_s
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
class InteractiveREPL
|
224
|
+
def initialize
|
225
|
+
@schema_module = nil
|
226
|
+
@runner = nil
|
227
|
+
@input_data = {}
|
228
|
+
end
|
229
|
+
|
230
|
+
def start
|
231
|
+
loop do
|
232
|
+
print "kumi> "
|
233
|
+
input = gets&.chomp
|
234
|
+
break if input.nil? || input == "exit"
|
235
|
+
|
236
|
+
execute_command(input)
|
237
|
+
end
|
238
|
+
puts "Goodbye!"
|
239
|
+
end
|
240
|
+
|
241
|
+
private
|
242
|
+
|
243
|
+
def execute_command(input)
|
244
|
+
case input.strip
|
245
|
+
when "help"
|
246
|
+
show_help
|
247
|
+
when /^schema\s+(.+)/
|
248
|
+
load_schema_command(::Regexp.last_match(1))
|
249
|
+
when /^data\s+(.+)/
|
250
|
+
load_data_command(::Regexp.last_match(1))
|
251
|
+
when /^set\s+(\w+)\s+(.+)/
|
252
|
+
set_data_command(::Regexp.last_match(1), ::Regexp.last_match(2))
|
253
|
+
when /^get\s+(.+)/
|
254
|
+
get_value_command(::Regexp.last_match(1))
|
255
|
+
when /^explain\s+(.+)/
|
256
|
+
explain_command(::Regexp.last_match(1))
|
257
|
+
when /^slice\s+(.+)/
|
258
|
+
slice_command(::Regexp.last_match(1))
|
259
|
+
when "keys"
|
260
|
+
show_keys
|
261
|
+
when "clear"
|
262
|
+
clear_data
|
263
|
+
when ""
|
264
|
+
# ignore empty input
|
265
|
+
else
|
266
|
+
puts "Unknown command. Type 'help' for available commands."
|
267
|
+
end
|
268
|
+
rescue StandardError => e
|
269
|
+
puts "Error: #{e.message}"
|
270
|
+
puts e.backtrace.first if ENV["DEBUG"]
|
271
|
+
end
|
272
|
+
|
273
|
+
def show_help
|
274
|
+
puts <<~HELP
|
275
|
+
Available commands:
|
276
|
+
|
277
|
+
Schema management:
|
278
|
+
schema <file> Load schema from Ruby file
|
279
|
+
schema { ... } Define schema inline (experimental)
|
280
|
+
|
281
|
+
Data management:
|
282
|
+
data <file> Load input data from JSON/YAML file
|
283
|
+
set <key> <value> Set individual input value
|
284
|
+
clear Clear all input data
|
285
|
+
|
286
|
+
Evaluation:
|
287
|
+
get <key> Get computed value for key
|
288
|
+
explain <key> Show detailed computation trace
|
289
|
+
slice <key1,key2> Get multiple values
|
290
|
+
keys Show available keys
|
291
|
+
|
292
|
+
General:
|
293
|
+
help Show this help
|
294
|
+
exit Exit REPL
|
295
|
+
|
296
|
+
Examples:
|
297
|
+
schema examples/tax_2024.rb
|
298
|
+
data test_input.json
|
299
|
+
get total_tax
|
300
|
+
explain effective_rate
|
301
|
+
slice income,deductions,total_tax
|
302
|
+
HELP
|
303
|
+
end
|
304
|
+
|
305
|
+
def load_schema_command(file_path)
|
306
|
+
file_path = file_path.strip.gsub(/^["']|["']$/, "") # Remove quotes
|
307
|
+
|
308
|
+
unless File.exist?(file_path)
|
309
|
+
puts "Schema file not found: #{file_path}"
|
310
|
+
return
|
311
|
+
end
|
312
|
+
|
313
|
+
@schema_module = Module.new
|
314
|
+
@schema_module.extend(Kumi::Schema)
|
315
|
+
|
316
|
+
schema_content = File.read(file_path)
|
317
|
+
@schema_module.module_eval(schema_content, file_path)
|
318
|
+
|
319
|
+
puts "✅ Schema loaded from #{file_path}"
|
320
|
+
refresh_runner
|
321
|
+
rescue StandardError => e
|
322
|
+
puts "❌ Failed to load schema: #{e.message}"
|
323
|
+
end
|
324
|
+
|
325
|
+
def load_data_command(file_path)
|
326
|
+
file_path = file_path.strip.gsub(/^["']|["']$/, "") # Remove quotes
|
327
|
+
|
328
|
+
unless File.exist?(file_path)
|
329
|
+
puts "Data file not found: #{file_path}"
|
330
|
+
return
|
331
|
+
end
|
332
|
+
|
333
|
+
case File.extname(file_path).downcase
|
334
|
+
when ".json"
|
335
|
+
@input_data = JSON.parse(File.read(file_path), symbolize_names: true)
|
336
|
+
when ".yml", ".yaml"
|
337
|
+
@input_data = YAML.safe_load_file(file_path, symbolize_names: true)
|
338
|
+
else
|
339
|
+
puts "Unsupported file format. Use .json or .yaml"
|
340
|
+
return
|
341
|
+
end
|
342
|
+
|
343
|
+
puts "✅ Data loaded from #{file_path}"
|
344
|
+
puts "Keys: #{@input_data.keys.join(', ')}"
|
345
|
+
refresh_runner
|
346
|
+
rescue StandardError => e
|
347
|
+
puts "❌ Failed to load data: #{e.message}"
|
348
|
+
end
|
349
|
+
|
350
|
+
def set_data_command(key, value)
|
351
|
+
# Try to parse value as JSON first, then as literal
|
352
|
+
parsed_value = begin
|
353
|
+
JSON.parse(value)
|
354
|
+
rescue JSON::ParserError
|
355
|
+
# If not valid JSON, treat as string unless it looks like a number/boolean
|
356
|
+
case value
|
357
|
+
when /^\d+$/ then value.to_i
|
358
|
+
when /^\d+\.\d+$/ then value.to_f
|
359
|
+
when "true" then true
|
360
|
+
when "false" then false
|
361
|
+
else value
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
@input_data[key.to_sym] = parsed_value
|
366
|
+
puts "✅ Set #{key} = #{parsed_value.inspect}"
|
367
|
+
refresh_runner
|
368
|
+
end
|
369
|
+
|
370
|
+
def get_value_command(key)
|
371
|
+
ensure_runner_ready
|
372
|
+
|
373
|
+
key_sym = key.strip.to_sym
|
374
|
+
result = @runner[key_sym]
|
375
|
+
puts "#{key_sym}: #{format_value(result)}"
|
376
|
+
rescue StandardError => e
|
377
|
+
puts "❌ Error getting #{key}: #{e.message}"
|
378
|
+
end
|
379
|
+
|
380
|
+
def explain_command(key)
|
381
|
+
ensure_runner_ready
|
382
|
+
|
383
|
+
key_sym = key.strip.to_sym
|
384
|
+
puts @schema_module.explain(@input_data, key_sym)
|
385
|
+
rescue StandardError => e
|
386
|
+
puts "❌ Error explaining #{key}: #{e.message}"
|
387
|
+
end
|
388
|
+
|
389
|
+
def slice_command(keys_str)
|
390
|
+
ensure_runner_ready
|
391
|
+
|
392
|
+
keys = keys_str.split(",").map { |k| k.strip.to_sym }
|
393
|
+
result = @runner.slice(*keys)
|
394
|
+
|
395
|
+
result.each do |key, value|
|
396
|
+
puts "#{key}: #{format_value(value)}"
|
397
|
+
end
|
398
|
+
rescue StandardError => e
|
399
|
+
puts "❌ Error getting slice: #{e.message}"
|
400
|
+
end
|
401
|
+
|
402
|
+
def show_keys
|
403
|
+
if @schema_module
|
404
|
+
available_bindings = @schema_module.__compiled_schema__.bindings.keys
|
405
|
+
puts "Available keys: #{available_bindings.join(', ')}"
|
406
|
+
else
|
407
|
+
puts "No schema loaded. Use 'schema <file>' to load a schema."
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
def clear_data
|
412
|
+
@input_data = {}
|
413
|
+
@runner = nil
|
414
|
+
puts "✅ Input data cleared"
|
415
|
+
end
|
416
|
+
|
417
|
+
def ensure_runner_ready
|
418
|
+
raise "No schema loaded. Use 'schema <file>' to load a schema." unless @schema_module
|
419
|
+
|
420
|
+
return if @runner
|
421
|
+
|
422
|
+
raise "No runner available. Load data with 'data <file>' or set values with 'set <key> <value>'"
|
423
|
+
end
|
424
|
+
|
425
|
+
def refresh_runner
|
426
|
+
return unless @schema_module
|
427
|
+
|
428
|
+
@runner = @schema_module.from(@input_data)
|
429
|
+
puts "✅ Runner refreshed with current data"
|
430
|
+
rescue StandardError => e
|
431
|
+
puts "⚠️ Runner refresh failed: #{e.message}"
|
432
|
+
@runner = nil
|
433
|
+
end
|
434
|
+
|
435
|
+
def format_value(value)
|
436
|
+
case value
|
437
|
+
when String
|
438
|
+
value.inspect
|
439
|
+
when Numeric
|
440
|
+
value.is_a?(Float) ? value.round(2) : value
|
441
|
+
when Array, Hash
|
442
|
+
value.inspect
|
443
|
+
else
|
444
|
+
value.to_s
|
445
|
+
end
|
446
|
+
end
|
447
|
+
end
|
448
|
+
end
|
449
|
+
end
|
data/lib/kumi/compiler.rb
CHANGED
@@ -14,9 +14,27 @@ module Kumi
|
|
14
14
|
compile_field(expr)
|
15
15
|
end
|
16
16
|
|
17
|
+
def compile_element_field_reference(expr)
|
18
|
+
path = expr.path
|
19
|
+
|
20
|
+
lambda do |ctx|
|
21
|
+
# Start with the top-level collection from the context.
|
22
|
+
collection = ctx[path.first]
|
23
|
+
|
24
|
+
# Recursively map over the nested collections.
|
25
|
+
# The `dig_and_map` helper will handle any level of nesting.
|
26
|
+
dig_and_map(collection, path[1..])
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
|
17
31
|
def compile_binding_node(expr)
|
18
|
-
|
19
|
-
|
32
|
+
name = expr.name
|
33
|
+
# Handle forward references in cycles by deferring binding lookup to runtime
|
34
|
+
lambda do |ctx|
|
35
|
+
fn = @bindings[name].last
|
36
|
+
fn.call(ctx)
|
37
|
+
end
|
20
38
|
end
|
21
39
|
|
22
40
|
def compile_list(expr)
|
@@ -27,28 +45,102 @@ module Kumi
|
|
27
45
|
def compile_call(expr)
|
28
46
|
fn_name = expr.fn_name
|
29
47
|
arg_fns = expr.args.map { |a| compile_expr(a) }
|
30
|
-
|
48
|
+
|
49
|
+
# Check if this is a vectorized operation
|
50
|
+
if vectorized_operation?(expr)
|
51
|
+
->(ctx) { invoke_vectorized_function(fn_name, arg_fns, ctx, expr.loc) }
|
52
|
+
else
|
53
|
+
->(ctx) { invoke_function(fn_name, arg_fns, ctx, expr.loc) }
|
54
|
+
end
|
31
55
|
end
|
32
56
|
|
33
57
|
def compile_cascade(expr)
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
58
|
+
# Check if current declaration is vectorized
|
59
|
+
broadcast_meta = @analysis.state[:broadcast_metadata]
|
60
|
+
is_vectorized = @current_declaration && broadcast_meta&.dig(:vectorized_operations, @current_declaration)
|
61
|
+
|
62
|
+
|
63
|
+
# For vectorized cascades, we need to transform conditions that use all?
|
64
|
+
if is_vectorized
|
65
|
+
pairs = expr.cases.map do |c|
|
66
|
+
condition_fn = transform_vectorized_condition(c.condition)
|
67
|
+
result_fn = compile_expr(c.result)
|
68
|
+
[condition_fn, result_fn]
|
69
|
+
end
|
70
|
+
else
|
71
|
+
pairs = expr.cases.map { |c| [compile_expr(c.condition), compile_expr(c.result)] }
|
72
|
+
end
|
73
|
+
|
74
|
+
if is_vectorized
|
75
|
+
lambda do |ctx|
|
76
|
+
# This cascade can be vectorized - check if we actually need to at runtime
|
77
|
+
# Evaluate all conditions and results to check for arrays
|
78
|
+
cond_results = pairs.map { |cond, _res| cond.call(ctx) }
|
79
|
+
res_results = pairs.map { |_cond, res| res.call(ctx) }
|
80
|
+
|
81
|
+
# Check if any conditions or results are arrays (vectorized)
|
82
|
+
has_vectorized_data = (cond_results + res_results).any? { |v| v.is_a?(Array) }
|
83
|
+
|
84
|
+
if has_vectorized_data
|
85
|
+
# Apply element-wise cascade evaluation
|
86
|
+
array_length = cond_results.find { |v| v.is_a?(Array) }&.length ||
|
87
|
+
res_results.find { |v| v.is_a?(Array) }&.length || 1
|
88
|
+
|
89
|
+
(0...array_length).map do |i|
|
90
|
+
pairs.each_with_index do |(cond, res), pair_idx|
|
91
|
+
cond_val = cond_results[pair_idx].is_a?(Array) ? cond_results[pair_idx][i] : cond_results[pair_idx]
|
92
|
+
|
93
|
+
if cond_val
|
94
|
+
res_val = res_results[pair_idx].is_a?(Array) ? res_results[pair_idx][i] : res_results[pair_idx]
|
95
|
+
break res_val
|
96
|
+
end
|
97
|
+
end || nil
|
98
|
+
end
|
99
|
+
else
|
100
|
+
# All data is scalar - use regular cascade evaluation
|
101
|
+
pairs.each_with_index do |(cond, res), pair_idx|
|
102
|
+
return res_results[pair_idx] if cond_results[pair_idx]
|
103
|
+
end
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
end
|
107
|
+
else
|
108
|
+
lambda do |ctx|
|
109
|
+
pairs.each { |cond, res| return res.call(ctx) if cond.call(ctx) }
|
110
|
+
nil
|
111
|
+
end
|
38
112
|
end
|
39
113
|
end
|
114
|
+
|
115
|
+
def transform_vectorized_condition(condition_expr)
|
116
|
+
# If this is fn(:all?, [trait_ref]), extract the trait_ref for vectorized cascades
|
117
|
+
if condition_expr.is_a?(Kumi::Syntax::CallExpression) &&
|
118
|
+
condition_expr.fn_name == :all? &&
|
119
|
+
condition_expr.args.length == 1
|
120
|
+
|
121
|
+
arg = condition_expr.args.first
|
122
|
+
if arg.is_a?(Kumi::Syntax::ArrayExpression) && arg.elements.length == 1
|
123
|
+
trait_ref = arg.elements.first
|
124
|
+
return compile_expr(trait_ref)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Otherwise compile normally
|
129
|
+
compile_expr(condition_expr)
|
130
|
+
end
|
40
131
|
end
|
41
132
|
|
42
133
|
include ExprCompilers
|
43
134
|
|
44
135
|
# Map node classes to compiler methods
|
45
136
|
DISPATCH = {
|
46
|
-
Syntax::
|
47
|
-
Syntax::
|
48
|
-
Syntax::
|
49
|
-
Syntax::
|
50
|
-
Syntax::
|
51
|
-
Syntax::
|
137
|
+
Kumi::Syntax::Literal => :compile_literal,
|
138
|
+
Kumi::Syntax::InputReference => :compile_field_node,
|
139
|
+
Kumi::Syntax::InputElementReference => :compile_element_field_reference,
|
140
|
+
Kumi::Syntax::DeclarationReference => :compile_binding_node,
|
141
|
+
Kumi::Syntax::ArrayExpression => :compile_list,
|
142
|
+
Kumi::Syntax::CallExpression => :compile_call,
|
143
|
+
Kumi::Syntax::CascadeExpression => :compile_cascade
|
52
144
|
}.freeze
|
53
145
|
|
54
146
|
def self.compile(schema, analyzer:)
|
@@ -79,10 +171,30 @@ module Kumi
|
|
79
171
|
@schema.traits.each { |t| @index[t.name] = t }
|
80
172
|
end
|
81
173
|
|
174
|
+
def dig_and_map(collection, path_segments)
|
175
|
+
return collection unless collection.is_a?(Array)
|
176
|
+
|
177
|
+
current_segment = path_segments.first
|
178
|
+
remaining_segments = path_segments[1..]
|
179
|
+
|
180
|
+
collection.map do |element|
|
181
|
+
value = element[current_segment]
|
182
|
+
|
183
|
+
# If there are more segments, recurse. Otherwise, return the value.
|
184
|
+
if remaining_segments.empty?
|
185
|
+
value
|
186
|
+
else
|
187
|
+
dig_and_map(value, remaining_segments)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
82
192
|
def compile_declaration(decl)
|
83
|
-
|
84
|
-
|
193
|
+
@current_declaration = decl.name
|
194
|
+
kind = decl.is_a?(Kumi::Syntax::TraitDeclaration) ? :trait : :attr
|
195
|
+
fn = compile_expr(decl.expression)
|
85
196
|
@bindings[decl.name] = [kind, fn]
|
197
|
+
@current_declaration = nil
|
86
198
|
end
|
87
199
|
|
88
200
|
# Dispatch to the appropriate compile_* method
|
@@ -91,7 +203,6 @@ module Kumi
|
|
91
203
|
send(method, expr)
|
92
204
|
end
|
93
205
|
|
94
|
-
# Existing helpers unchanged
|
95
206
|
def compile_field(node)
|
96
207
|
name = node.name
|
97
208
|
loc = node.loc
|
@@ -103,6 +214,73 @@ module Kumi
|
|
103
214
|
end
|
104
215
|
end
|
105
216
|
|
217
|
+
def vectorized_operation?(expr)
|
218
|
+
# Check if this operation uses vectorized inputs
|
219
|
+
broadcast_meta = @analysis.state[:broadcast_metadata]
|
220
|
+
return false unless broadcast_meta
|
221
|
+
|
222
|
+
# Reduction functions are NOT vectorized operations - they consume arrays
|
223
|
+
if FunctionRegistry.reducer?(expr.fn_name)
|
224
|
+
return false
|
225
|
+
end
|
226
|
+
|
227
|
+
expr.args.any? do |arg|
|
228
|
+
case arg
|
229
|
+
when Kumi::Syntax::InputElementReference
|
230
|
+
broadcast_meta[:array_fields]&.key?(arg.path.first)
|
231
|
+
when Kumi::Syntax::DeclarationReference
|
232
|
+
broadcast_meta[:vectorized_operations]&.key?(arg.name)
|
233
|
+
else
|
234
|
+
false
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
|
240
|
+
def invoke_vectorized_function(name, arg_fns, ctx, loc)
|
241
|
+
# Evaluate arguments
|
242
|
+
values = arg_fns.map { |fn| fn.call(ctx) }
|
243
|
+
|
244
|
+
# Check if any argument is vectorized (array)
|
245
|
+
has_vectorized_args = values.any? { |v| v.is_a?(Array) }
|
246
|
+
|
247
|
+
if has_vectorized_args
|
248
|
+
# Apply function with broadcasting to all vectorized arguments
|
249
|
+
vectorized_function_call(name, values)
|
250
|
+
else
|
251
|
+
# All arguments are scalars - regular function call
|
252
|
+
fn = FunctionRegistry.fetch(name)
|
253
|
+
fn.call(*values)
|
254
|
+
end
|
255
|
+
rescue StandardError => e
|
256
|
+
enhanced_message = "Error calling fn(:#{name}) at #{loc}: #{e.message}"
|
257
|
+
runtime_error = Errors::RuntimeError.new(enhanced_message)
|
258
|
+
runtime_error.set_backtrace(e.backtrace)
|
259
|
+
runtime_error.define_singleton_method(:cause) { e }
|
260
|
+
raise runtime_error
|
261
|
+
end
|
262
|
+
|
263
|
+
def vectorized_function_call(fn_name, values)
|
264
|
+
# Get the function from registry
|
265
|
+
fn = FunctionRegistry.fetch(fn_name)
|
266
|
+
|
267
|
+
# Find array dimensions for broadcasting
|
268
|
+
array_values = values.select { |v| v.is_a?(Array) }
|
269
|
+
return fn.call(*values) if array_values.empty?
|
270
|
+
|
271
|
+
# All arrays should have the same length (validation could be added)
|
272
|
+
array_length = array_values.first.size
|
273
|
+
|
274
|
+
# Broadcast and apply function element-wise
|
275
|
+
(0...array_length).map do |i|
|
276
|
+
element_args = values.map do |v|
|
277
|
+
v.is_a?(Array) ? v[i] : v # Broadcast scalars
|
278
|
+
end
|
279
|
+
fn.call(*element_args)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
|
106
284
|
def invoke_function(name, arg_fns, ctx, loc)
|
107
285
|
fn = FunctionRegistry.fetch(name)
|
108
286
|
values = arg_fns.map { |fn| fn.call(ctx) }
|