kumi 0.0.20 → 0.0.22

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.
@@ -1,907 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kumi
4
- module Core
5
- module Analyzer
6
- module Passes
7
- # Lowers analyzed AST into Low-level IR for the VM
8
- #
9
- # INPUTS (from Analyzer state):
10
- # - :evaluation_order → topologically sorted declaration names
11
- # - :declarations → parsed & validated AST nodes
12
- # - :access_plans → AccessPlanner output (plan id, scope, depth, mode, operations)
13
- # - :join_reduce_plans → (optional) precomputed join/reduce strategy
14
- # - :input_metadata → normalized InputMeta tree
15
- # - :scope_plans → (optional) per-declaration scope hints
16
- #
17
- # OUTPUT:
18
- # - :ir_module (Kumi::Core::IR::Module) with IR decls
19
- #
20
- # RESPONSIBILITIES:
21
- # 1) Plan selection & LoadInput emission
22
- # - Choose the correct plan id for each input path:
23
- # * :read → scalar fetch (no element traversal)
24
- # * :ravel → leaf values, flattened over path’s array lineage
25
- # * :each_indexed → vector fetch with hierarchical indices (for lineage)
26
- # * :materialize → preserves nested structure (used when required)
27
- # - Set LoadInput attrs: scope:, is_scalar:, has_idx:
28
- # * is_scalar = (mode == :read || mode == :materialize)
29
- # * has_idx = (mode == :each_indexed)
30
- #
31
- # 2) Shape tracking (SlotShape)
32
- # - Track the kind of every slot: Scalar vs Vec(scope, has_idx)
33
- # - Used to:
34
- # * decide when to emit AlignTo (automatic vector alignment)
35
- # * decide if the declaration needs a twin (:name__vec) + Lift
36
- #
37
- # 3) Automatic alignment for elementwise maps
38
- # - For Map(fn, args...):
39
- # * Find the carrier vector (max scope length among :elem params)
40
- # * If another arg is a vector with a compatible prefix scope, insert AlignTo(to_scope: carrier_scope)
41
- # * If incompatible, raise "cross-scope map without join"
42
- #
43
- # 4) Reducers & structure functions
44
- # - Reducers (e.g., sum, min, max, avg, count_if) lower to Reduce on the first vector arg
45
- # - Structure functions (e.g., size, flatten) are executed via Map unless explicitly marked reducer
46
- #
47
- # 5) Cascades & traits
48
- # - CascadeExpression lowered to nested `if` (switch) form
49
- # - Trait algebra remains purely boolean; no VM special-casing
50
- #
51
- # 6) Declaration twins & Lift
52
- # - If the final expression shape is a Vec with indices:
53
- # * Store to :name__vec (internal twin)
54
- # * Emit Lift(to_scope: vec.scope) to regroup rows into nested arrays
55
- # * Store lifted scalar to :name
56
- # - If scalar result: store only :name
57
- # - `DeclarationReference` resolves to :name__vec when a vector is required downstream
58
- #
59
- # INVARIANTS:
60
- # - All structural intent (vectorization, grouping, alignment) is decided during lowering.
61
- # - VM is mechanical; it does not sniff types or infer structure.
62
- # - AlignTo requires prefix-compatible scopes; otherwise we error early.
63
- # - Lift consumes a Vec with indices and returns a Scalar(nested_array) by grouping rows with `group_rows`.
64
- #
65
- # DEBUGGING:
66
- # - Set DEBUG_LOWER=1 to print per-declaration IR ops and alignment decisions.
67
- SlotShape = Struct.new(:kind, :scope, :has_idx) do
68
- def self.scalar
69
- new(:scalar, [], false)
70
- end
71
-
72
- def self.vec(scope, has_idx: true)
73
- new(:vec, Array(scope), has_idx)
74
- end
75
- end
76
-
77
- class LowerToIRPass < PassBase
78
- def run(errors)
79
- @vec_names = Set.new
80
- @vec_meta = {}
81
-
82
- evaluation_order = get_state(:evaluation_order, required: true)
83
- declarations = get_state(:declarations, required: true)
84
- access_plans = get_state(:access_plans, required: true)
85
- join_reduce_plans = get_state(:join_reduce_plans, required: false) || {}
86
- input_metadata = get_state(:input_metadata, required: true)
87
- scope_plans = get_state(:scope_plans, required: false) || {}
88
-
89
- ir_decls = []
90
-
91
- @join_reduce_plans = join_reduce_plans
92
- @declarations = declarations
93
-
94
- evaluation_order.each do |name|
95
- decl = declarations[name]
96
- next unless decl
97
-
98
- begin
99
- scope_plan = scope_plans[name]
100
- @current_decl = name
101
- @lower_cache = {} # reset per declaration
102
-
103
- ir_decl = lower_declaration(name, decl, access_plans, join_reduce_plans, scope_plan)
104
- ir_decls << ir_decl
105
- rescue StandardError => e
106
- location = decl.respond_to?(:loc) ? decl.loc : nil
107
- backtrace = e.backtrace.first(5).join("\n")
108
- message = "Failed to lower declaration #{name}: #{e.message}\n#{backtrace}"
109
- add_error(errors, location, "Failed to lower declaration #{name}: #{message}")
110
- end
111
- end
112
-
113
- if ENV["DEBUG_LOWER"]
114
- puts "DEBUG eval order: #{evaluation_order.inspect}"
115
- puts "DEBUG ir decl order: #{ir_decls.map(&:name).inspect}"
116
- end
117
- order_index = evaluation_order.each_with_index.to_h
118
- ir_decls.sort_by! { |d| order_index.fetch(d.name, Float::INFINITY) }
119
-
120
- ir_module = Kumi::Core::IR::Module.new(
121
- inputs: input_metadata,
122
- decls: ir_decls
123
- )
124
-
125
- if ENV["DEBUG_LOWER"]
126
- puts "DEBUG Lowered IR Module:"
127
- ir_module.decls.each do |decl|
128
- puts " Declaration: #{decl.name} (#{decl.kind})"
129
- decl.ops.each_with_index do |op, i|
130
- puts " Op#{i}: #{op.tag} #{op.attrs.inspect} args=#{op.args.inspect}"
131
- end
132
- end
133
- end
134
-
135
- if ENV["DUMP_IR"]
136
- # Collect analysis state that this pass actually uses
137
- analysis_state = {
138
- evaluation_order: evaluation_order,
139
- declarations: declarations,
140
- access_plans: access_plans,
141
- join_reduce_plans: join_reduce_plans,
142
- scope_plans: scope_plans,
143
- input_metadata: input_metadata,
144
- vec_names: @vec_names,
145
- vec_meta: @vec_meta,
146
- inferred_types: get_state(:inferred_types, required: false) || {}
147
- }
148
-
149
- pretty_ir = Kumi::Support::IRDump.pretty_print(ir_module, analysis_state: analysis_state)
150
- File.write(ENV["DUMP_IR"], pretty_ir)
151
- puts "DEBUG IR dumped to #{ENV['DUMP_IR']}"
152
- end
153
-
154
- state.with(:ir_module, ir_module)
155
- end
156
-
157
- private
158
-
159
- def determine_slot_shape(slot, ops, access_plans)
160
- return SlotShape.scalar if slot.nil?
161
-
162
- op = ops[slot]
163
-
164
- case op.tag
165
- when :const
166
- SlotShape.scalar
167
-
168
- when :load_input
169
- if op.attrs[:is_scalar]
170
- SlotShape.scalar
171
- else
172
- plan_id = op.attrs[:plan_id]
173
- plan = access_plans.values.flatten.find { |p| p.accessor_key == plan_id }
174
- SlotShape.vec(op.attrs[:scope] || [], has_idx: plan&.mode == :each_indexed)
175
- end
176
-
177
- when :array
178
- arg_shapes = op.args.map { |i| determine_slot_shape(i, ops, access_plans) }
179
- return SlotShape.scalar if arg_shapes.all? { |s| s.kind == :scalar }
180
-
181
- carrier = arg_shapes.select { |s| s.kind == :vec }.max_by { |s| s.scope.length }
182
- SlotShape.vec(carrier.scope, has_idx: carrier.has_idx)
183
-
184
- when :map
185
- arg_shapes = op.args.map { |i| determine_slot_shape(i, ops, access_plans) }
186
- return SlotShape.scalar if arg_shapes.all? { |s| s.kind == :scalar }
187
-
188
- carrier = arg_shapes.select { |s| s.kind == :vec }.max_by { |s| s.scope.length }
189
- SlotShape.vec(carrier.scope, has_idx: carrier.has_idx)
190
-
191
- when :align_to
192
- SlotShape.vec(op.attrs[:to_scope], has_idx: true)
193
-
194
- when :reduce
195
- rs = Array(op.attrs[:result_scope] || [])
196
- rs.empty? ? SlotShape.scalar : SlotShape.vec(rs, has_idx: true)
197
- when :switch
198
- branch_shapes =
199
- op.attrs[:cases].map { |(_, v)| determine_slot_shape(v, ops, access_plans) } +
200
- [determine_slot_shape(op.attrs[:default], ops, access_plans)]
201
- if (vec = branch_shapes.find { |s| s.kind == :vec })
202
- SlotShape.vec(vec.scope, has_idx: vec.has_idx)
203
- else
204
- SlotShape.scalar
205
- end
206
-
207
- when :ref
208
- if (m = @vec_meta && @vec_meta[op.attrs[:name]])
209
- SlotShape.vec(m[:scope], has_idx: m[:has_idx])
210
- else
211
- SlotShape.scalar
212
- end
213
-
214
- else
215
- raise "Op `#{op.tag}` not supported"
216
- end
217
- end
218
-
219
- def insert_align_to_if_needed(arg_slots, ops, access_plans, on_missing: :error)
220
- shapes = arg_slots.map { |s| determine_slot_shape(s, ops, access_plans) }
221
-
222
- vec_is = arg_slots.each_index.select { |i| shapes[i].kind == :vec }
223
- return arg_slots if vec_is.size < 2
224
-
225
- carrier_i = vec_is.max_by { |i| shapes[i].scope.length }
226
- carrier_scope = shapes[carrier_i].scope
227
- carrier_slot = arg_slots[carrier_i]
228
-
229
- aligned = arg_slots.dup
230
- vec_is.each do |i|
231
- next if shapes[i].scope == carrier_scope
232
-
233
- short, long = [shapes[i].scope, carrier_scope].sort_by(&:length)
234
- unless long.first(short.length) == short
235
- raise "cross-scope map without join: #{shapes[i].scope.inspect} vs #{carrier_scope.inspect}"
236
- end
237
-
238
- src_slot = aligned[i] # <- chain on the current slot, not the original
239
- op = Kumi::Core::IR::Ops.AlignTo(
240
- carrier_slot, src_slot,
241
- to_scope: carrier_scope, require_unique: true, on_missing: on_missing
242
- )
243
- ops << op
244
- aligned[i] = ops.size - 1
245
- end
246
-
247
- aligned
248
- end
249
-
250
- def apply_scalar_to_vector_broadcast(scalar_slot, target_scope, ops, access_plans)
251
- # Create a carrier vector at the target scope by loading the appropriate input
252
- # For scope [:items], we need to load the items array to get the vector shape
253
- if target_scope.empty?
254
- puts "DEBUG: Empty target scope, returning scalar" if ENV["DEBUG_BROADCAST"]
255
- return scalar_slot # Can't broadcast to empty scope
256
- end
257
-
258
- # Create a load operation for the target scope to get vector shape
259
- # For [:items] scope, load the items array
260
- # Access plans are keyed by strings, not arrays
261
- if target_scope.length == 1
262
- input_key = target_scope.first.to_s
263
- plans = access_plans[input_key]
264
- puts "DEBUG: Looking for plans for #{input_key.inspect}, found: #{plans&.length || 0} plans" if ENV["DEBUG_BROADCAST"]
265
- else
266
- puts "DEBUG: Complex target scope #{target_scope.inspect}, not supported yet" if ENV["DEBUG_BROADCAST"]
267
- return scalar_slot
268
- end
269
-
270
- if plans&.any?
271
- # Find an indexed plan that gives us the vector shape
272
- indexed_plan = plans.find { |p| p.mode == :each_indexed }
273
- puts "DEBUG: Indexed plan found: #{indexed_plan.inspect}" if ENV["DEBUG_BROADCAST"] && indexed_plan
274
- if indexed_plan
275
- # Load the input to create a carrier vector
276
- ops << Kumi::Core::IR::Ops.LoadInput(indexed_plan.accessor_key, scope: indexed_plan.scope, has_idx: true)
277
- carrier_slot = ops.size - 1
278
- puts "DEBUG: Created carrier at slot #{carrier_slot}" if ENV["DEBUG_BROADCAST"]
279
-
280
- # Now broadcast scalar against the carrier - use first arg from carrier, rest from scalar
281
- ops << Kumi::Core::IR::Ops.Map(:if, 3, carrier_slot, scalar_slot, scalar_slot)
282
- result_slot = ops.size - 1
283
- puts "DEBUG: Created broadcast MAP at slot #{result_slot}" if ENV["DEBUG_BROADCAST"]
284
- result_slot
285
- else
286
- puts "DEBUG: No indexed plan found, returning scalar" if ENV["DEBUG_BROADCAST"]
287
- # No indexed plan available, return scalar as-is
288
- scalar_slot
289
- end
290
- else
291
- puts "DEBUG: No access plans found, returning scalar" if ENV["DEBUG_BROADCAST"]
292
- # No access plans for target scope, return scalar as-is
293
- scalar_slot
294
- end
295
- end
296
-
297
- def lower_declaration(name, decl, access_plans, _join_reduce_plans, scope_plan)
298
- ops = []
299
-
300
- plan = @join_reduce_plans[name]
301
- req_scope =
302
- if plan && plan.respond_to?(:result_scope)
303
- Array(plan.result_scope) # [] for full reduction, [:players] for per-player, etc.
304
- elsif plan && plan.respond_to?(:policy) && plan.policy == :broadcast
305
- Array(plan.target_scope) # Broadcast to target scope
306
- elsif top_level_reducer?(decl)
307
- [] # collapse all axes by default
308
- else
309
- scope_plan&.scope # fallback (vector values, arrays, etc.)
310
- end
311
-
312
- last_slot = lower_expression(decl.expression, ops, access_plans, scope_plan,
313
- true, req_scope)
314
-
315
- # Apply broadcasting for scalar-to-vector join plans
316
- if plan && plan.respond_to?(:policy) && plan.policy == :broadcast
317
- puts "DEBUG: Applying scalar broadcast for #{name} to scope #{plan.target_scope.inspect}" if ENV["DEBUG_BROADCAST"]
318
- last_slot = apply_scalar_to_vector_broadcast(last_slot, plan.target_scope, ops, access_plans)
319
- puts "DEBUG: Broadcast result slot: #{last_slot}" if ENV["DEBUG_BROADCAST"]
320
- end
321
-
322
- shape = determine_slot_shape(last_slot, ops, access_plans)
323
- puts "DEBUG: Shape after broadcast for #{name}: #{shape.inspect}" if ENV["DEBUG_BROADCAST"]
324
-
325
- if shape.kind == :vec
326
- vec_name = :"#{name}__vec"
327
- @vec_meta[vec_name] = { scope: shape.scope, has_idx: shape.has_idx }
328
-
329
- # internal twin for downstream refs
330
- ops << Kumi::Core::IR::Ops.Store(vec_name, last_slot)
331
-
332
- # public presentation
333
- ops << if shape.has_idx
334
- Kumi::Core::IR::Ops.Lift(shape.scope, last_slot)
335
- else
336
- Kumi::Core::IR::Ops.Reduce(:to_array, [], [], [], last_slot)
337
- end
338
- last_slot = ops.size - 1
339
- end
340
-
341
- # ➌ store public name (scalar or transformed vec)
342
- ops << Kumi::Core::IR::Ops.Store(name, last_slot)
343
-
344
- Kumi::Core::IR::Decl.new(
345
- name: name,
346
- kind: decl.is_a?(Syntax::ValueDeclaration) ? :value : :trait,
347
- shape: (shape.kind == :vec ? :vec : :scalar),
348
- ops: ops
349
- )
350
- end
351
-
352
- # Lowers an analyzed AST node into IR ops and returns the slot index.
353
- # - ops: mutable IR ops array (per-declaration)
354
- # - need_indices: whether to prefer :each_indexed plan for inputs
355
- # - required_scope: consumer-required scope (guides grouped reductions)
356
- # - cacheable: whether this lowering may be cached (branch bodies under guards: false)
357
- def lower_expression(expr, ops, access_plans, scope_plan, need_indices, required_scope = nil, cacheable: true)
358
- @lower_cache ||= {}
359
- key = [@current_decl, expr.object_id, Array(required_scope), !!need_indices]
360
- if cacheable && (hit = @lower_cache[key])
361
- return hit
362
- end
363
-
364
- if ENV["DEBUG_LOWER"] && expr.is_a?(Syntax::CallExpression)
365
- puts " LOWER_EXPR[#{@current_decl}] #{expr.fn_name}(#{expr.args.size} args) req_scope=#{required_scope.inspect}"
366
- end
367
-
368
- slot =
369
- case expr
370
- when Syntax::Literal
371
- ops << Kumi::Core::IR::Ops.Const(expr.value)
372
- ops.size - 1
373
-
374
- when Syntax::InputReference
375
- plan_id = pick_plan_id_for_input([expr.name], access_plans,
376
- scope_plan: scope_plan, need_indices: need_indices)
377
-
378
- plans = access_plans.fetch(expr.name.to_s, [])
379
- selected = plans.find { |p| p.accessor_key == plan_id }
380
- scope = selected ? selected.scope : []
381
- is_scalar = selected && %i[read materialize].include?(selected.mode)
382
- has_idx = selected && selected.mode == :each_indexed
383
- ops << Kumi::Core::IR::Ops.LoadInput(plan_id, scope: scope, is_scalar: is_scalar, has_idx: has_idx)
384
- ops.size - 1
385
-
386
- when Syntax::InputElementReference
387
- plan_id = pick_plan_id_for_input(expr.path, access_plans,
388
- scope_plan: scope_plan, need_indices: need_indices)
389
- path_str = expr.path.join(".")
390
- plans = access_plans.fetch(path_str, [])
391
- selected = plans.find { |p| p.accessor_key == plan_id }
392
- scope = selected ? selected.scope : []
393
- is_scalar = selected && %i[read materialize].include?(selected.mode)
394
- has_idx = selected && selected.mode == :each_indexed
395
- ops << Kumi::Core::IR::Ops.LoadInput(plan_id, scope: scope, is_scalar: is_scalar, has_idx: has_idx)
396
- ops.size - 1
397
-
398
- when Syntax::DeclarationReference
399
- # Check if this declaration has a vectorized twin at the required scope
400
- twin = :"#{expr.name}__vec"
401
- twin_meta = @vec_meta && @vec_meta[twin]
402
-
403
- if required_scope && !Array(required_scope).empty?
404
- # Consumer needs a grouped view of this declaration.
405
- if twin_meta && twin_meta[:scope] == Array(required_scope)
406
- # We have a vectorized twin at exactly the required scope - use it!
407
- ops << Kumi::Core::IR::Ops.Ref(twin)
408
- return ops.size - 1
409
- else
410
- # Need to inline re-lower the referenced declaration's *expression*,
411
- # forcing indices, and grouping to the requested scope.
412
- decl = @declarations.fetch(expr.name) { raise "unknown decl #{expr.name}" }
413
- slot = lower_expression(decl.expression, ops, access_plans, scope_plan,
414
- true, # need_indices (grouping requires indexed source)
415
- required_scope, # group-to scope
416
- cacheable: true) # per-decl slot cache will dedupe
417
- return slot
418
- end
419
- else
420
- # Plain (scalar) use, or already-materialized vec twin
421
- ref = twin_meta ? twin : expr.name
422
- ops << Kumi::Core::IR::Ops.Ref(ref)
423
- return ops.size - 1
424
- end
425
-
426
- when Syntax::CallExpression
427
- entry = Kumi::Registry.entry(expr.fn_name)
428
-
429
- # Constant folding optimization: evaluate expressions with all literal arguments
430
- if can_constant_fold?(expr, entry)
431
- folded_value = constant_fold(expr, entry)
432
- ops << Kumi::Core::IR::Ops.Const(folded_value)
433
- return ops.size - 1
434
- end
435
-
436
- if ENV["DEBUG_LOWER"] && has_nested_reducer?(expr)
437
- puts " NESTED_REDUCER_DETECTED in #{expr.fn_name} with req_scope=#{required_scope.inspect}"
438
- end
439
-
440
- # Special handling for comparison operations containing nested reductions
441
- if !entry&.reducer && has_nested_reducer?(expr)
442
- puts " SPECIAL_NESTED_REDUCTION_HANDLING for #{expr.fn_name}" if ENV["DEBUG_LOWER"]
443
-
444
- # For comparison ops with nested reducers, we need to ensure
445
- # the nested reducer gets the right required_scope (per-player)
446
- # instead of the full dimensional scope from infer_expr_scope
447
- plan = @join_reduce_plans[@current_decl]
448
- target_scope = if plan.is_a?(Kumi::Core::Analyzer::Plans::Reduce) && plan.result_scope && !plan.result_scope.empty?
449
- plan.result_scope
450
- elsif required_scope && !required_scope.empty?
451
- required_scope
452
- else
453
- []
454
- end
455
-
456
- puts " NESTED_REDUCTION target_scope=#{target_scope.inspect}" if ENV["DEBUG_LOWER"]
457
-
458
- # Lower arguments with the correct scope for nested reducers
459
- arg_slots = expr.args.map do |a|
460
- lower_expression(a, ops, access_plans, scope_plan,
461
- need_indices, target_scope, cacheable: cacheable)
462
- end
463
-
464
- aligned = target_scope.empty? ? arg_slots : insert_align_to_if_needed(arg_slots, ops, access_plans, on_missing: :error)
465
- ops << Kumi::Core::IR::Ops.Map(expr.fn_name, expr.args.size, *aligned)
466
- return ops.size - 1
467
-
468
- elsif entry&.reducer
469
- # Need indices iff grouping is requested
470
- child_need_idx = !Array(required_scope).empty?
471
-
472
- arg_slots = expr.args.map do |a|
473
- lower_expression(a, ops, access_plans, scope_plan,
474
- child_need_idx, # <<< important
475
- nil, # children of reducer don't inherit grouping
476
- cacheable: true)
477
- end
478
- vec_i = arg_slots.index { |s| determine_slot_shape(s, ops, access_plans).kind == :vec }
479
- if vec_i
480
- src_slot = arg_slots[vec_i]
481
- src_shape = determine_slot_shape(src_slot, ops, access_plans)
482
-
483
- # If grouping requested but source lacks indices (e.g. cached ravel), reload it with indices
484
- if !Array(required_scope).empty? && !src_shape.has_idx
485
- src_slot = lower_expression(expr.args[vec_i], ops, access_plans, scope_plan,
486
- true, # force indices
487
- nil,
488
- cacheable: true)
489
- src_shape = determine_slot_shape(src_slot, ops, access_plans)
490
- end
491
-
492
- if ENV["DEBUG_LOWER"]
493
- puts " emit_reduce(#{expr.fn_name}, #{src_slot}, #{src_shape.scope.inspect}, #{Array(required_scope).inspect})"
494
- end
495
- return emit_reduce(ops, expr.fn_name, src_slot, src_shape.scope, required_scope)
496
- else
497
- ops << Kumi::Core::IR::Ops.Map(expr.fn_name, arg_slots.size, *arg_slots)
498
- return ops.size - 1
499
- end
500
- end
501
-
502
- # non-reducer path unchanged…
503
-
504
- # Non-reducer: pointwise. Choose carrier = deepest vec among args.
505
- target = infer_expr_scope(expr, access_plans) # static, no ops emitted
506
- arg_slots = expr.args.map do |a|
507
- lower_expression(a, ops, access_plans, scope_plan,
508
- need_indices, target, cacheable: cacheable)
509
- end
510
- aligned = insert_align_to_if_needed(arg_slots, ops, access_plans, on_missing: :error)
511
- ops << Kumi::Core::IR::Ops.Map(expr.fn_name, expr.args.size, *aligned)
512
- ops.size - 1
513
-
514
- when Syntax::ArrayExpression
515
- target = infer_expr_scope(expr, access_plans) # LUB across children
516
- puts "DEBUG array target scope=#{target.inspect}" if ENV["DEBUG_LOWER"]
517
- elem_slots = expr.elements.map do |e|
518
- lower_expression(e, ops, access_plans, scope_plan,
519
- need_indices, # pass-through
520
- target, # <<< required_scope = target
521
- cacheable: true)
522
- end
523
- elem_slots = insert_align_to_if_needed(elem_slots, ops, access_plans, on_missing: :error) unless target.empty?
524
- ops << Kumi::Core::IR::Ops.Array(elem_slots.size, *elem_slots)
525
- return ops.size - 1
526
- when Syntax::HashExpression
527
- # Skip
528
-
529
- when Syntax::CascadeExpression
530
- # Find a base (true) case, if present
531
- base_case = expr.cases.find { |c| c.condition.is_a?(Syntax::Literal) && c.condition.value == true }
532
- default_expr = base_case ? base_case.result : Kumi::Syntax::Literal.new(nil)
533
- branches = expr.cases.reject { |c| c.equal?(base_case) }
534
-
535
- # Lower each condition once to probe shapes (cacheable)
536
- precond_slots = branches.map do |c|
537
- lower_expression(c.condition, ops, access_plans, scope_plan,
538
- need_indices, nil, cacheable: cacheable)
539
- end
540
- precond_shapes = precond_slots.map { |s| determine_slot_shape(s, ops, access_plans) }
541
- vec_cond_is = precond_shapes.each_index.select { |i| precond_shapes[i].kind == :vec }
542
-
543
- # Tiny helpers for boolean maps
544
- map1 = lambda { |fn, a|
545
- ops << Kumi::Core::IR::Ops.Map(fn, 1, a)
546
- ops.size - 1
547
- }
548
- map2 = lambda { |fn, a, b|
549
- ops << Kumi::Core::IR::Ops.Map(fn, 2, a, b)
550
- ops.size - 1
551
- }
552
-
553
- if vec_cond_is.empty?
554
- # ------------------------------------------------------------------
555
- # SCALAR CASCADE (lazy): evaluate branch bodies under guards
556
- # ------------------------------------------------------------------
557
- any_prev_true = lower_expression(Kumi::Syntax::Literal.new(false), ops, access_plans,
558
- scope_plan, need_indices, nil, cacheable: false)
559
-
560
- cases_attr = [] # [[cond_slot, value_slot], ...]
561
-
562
- branches.each_with_index do |c, i|
563
- not_any = map1.call(:not, any_prev_true)
564
- guard_i = map2.call(:and, not_any, precond_slots[i])
565
-
566
- ops << Kumi::Core::IR::Ops.GuardPush(guard_i)
567
- val_slot = lower_expression(c.result, ops, access_plans, scope_plan,
568
- need_indices, nil, cacheable: false)
569
- ops << Kumi::Core::IR::Ops.GuardPop
570
-
571
- cases_attr << [precond_slots[i], val_slot]
572
- any_prev_true = map2.call(:or, any_prev_true, precond_slots[i])
573
- end
574
-
575
- not_any = map1.call(:not, any_prev_true)
576
- ops << Kumi::Core::IR::Ops.GuardPush(not_any)
577
- default_slot = lower_expression(default_expr, ops, access_plans, scope_plan,
578
- need_indices, nil, cacheable: false)
579
- ops << Kumi::Core::IR::Ops.GuardPop
580
-
581
- ops << Kumi::Core::IR::Ops.Switch(cases_attr, default_slot)
582
- ops.size - 1
583
- else
584
- # -------------------------
585
- # VECTOR CASCADE (per-row lazy)
586
- # -------------------------
587
-
588
- # First lower raw conditions to peek at shapes.
589
- raw_cond_slots = branches.map do |c|
590
- lower_expression(c.condition, ops, access_plans, scope_plan,
591
- need_indices, nil, cacheable: true)
592
- end
593
- raw_shapes = raw_cond_slots.map { |s| determine_slot_shape(s, ops, access_plans) }
594
- vec_is = raw_shapes.each_index.select { |i| raw_shapes[i].kind == :vec }
595
-
596
- # Choose cascade_scope: prefer scope_plan (from scope resolution),
597
- # fallback to LUB of vector condition scopes.
598
- if scope_plan && !scope_plan.scope.nil? && !scope_plan.scope.empty?
599
- cascade_scope = Array(scope_plan.scope)
600
- else
601
- candidate_scopes = vec_is.map { |i| raw_shapes[i].scope }
602
- cascade_scope = lub_scopes(candidate_scopes)
603
- cascade_scope = [] if cascade_scope.nil?
604
- end
605
-
606
- # Re-lower each condition *properly* at cascade_scope (reproject deeper ones).
607
- conds_at_scope = branches.map do |c|
608
- lower_cascade_pred(c.condition, cascade_scope, ops, access_plans, scope_plan)
609
- end
610
-
611
- # Booleans utilities
612
- map1 = lambda { |fn, a|
613
- ops << Kumi::Core::IR::Ops.Map(fn, 1, a)
614
- ops.size - 1
615
- }
616
- map2 = lambda { |fn, a, b|
617
- ops << Kumi::Core::IR::Ops.Map(fn, 2, a, b)
618
- ops.size - 1
619
- }
620
-
621
- # Build lazy guards per branch at cascade_scope
622
- any_prev = lower_expression(Kumi::Syntax::Literal.new(false), ops, access_plans,
623
- scope_plan, need_indices, nil, cacheable: false)
624
- val_slots = []
625
-
626
- branches.each_with_index do |c, i|
627
- not_prev = map1.call(:not, any_prev)
628
- need_i = map2.call(:and, not_prev, conds_at_scope[i]) # @ cascade_scope
629
-
630
- ops << Kumi::Core::IR::Ops.GuardPush(need_i)
631
- vslot = lower_expression(c.result, ops, access_plans, scope_plan,
632
- need_indices, cascade_scope, cacheable: false)
633
- # ensure vector results live at cascade_scope
634
- vslot = align_to_cascade_if_vec(vslot, cascade_scope, ops, access_plans)
635
- ops << Kumi::Core::IR::Ops.GuardPop
636
-
637
- val_slots << vslot
638
- any_prev = map2.call(:or, any_prev, conds_at_scope[i]) # still @ cascade_scope
639
- end
640
-
641
- # Default branch
642
- not_prev = map1.call(:not, any_prev)
643
- ops << Kumi::Core::IR::Ops.GuardPush(not_prev)
644
- default_slot = lower_expression(default_expr, ops, access_plans, scope_plan,
645
- need_indices, cascade_scope, cacheable: false)
646
- default_slot = align_to_cascade_if_vec(default_slot, cascade_scope, ops, access_plans)
647
- ops << Kumi::Core::IR::Ops.GuardPop
648
-
649
- # Assemble via nested element-wise selection
650
- nested = default_slot
651
- (branches.length - 1).downto(0) do |i|
652
- ops << Kumi::Core::IR::Ops.Map(:if, 3, conds_at_scope[i], val_slots[i], nested)
653
- nested = ops.size - 1
654
- end
655
- nested
656
-
657
- end
658
-
659
- else
660
- raise "Unsupported expression type: #{expr.class.name}"
661
- end
662
-
663
- @lower_cache[key] = slot if cacheable
664
- slot
665
- end
666
-
667
- def pick_plan_id_for_input(path, access_plans, scope_plan:, need_indices:)
668
- path_str = path.join(".")
669
- plans = access_plans.fetch(path_str) { raise "No access plan for #{path_str}" }
670
- depth = plans.first.depth
671
- if depth > 0
672
- mode = need_indices ? :each_indexed : :ravel
673
- plans.find { |p| p.mode == mode }&.accessor_key or
674
- raise("No #{mode.inspect} plan for #{path_str}")
675
- else
676
- plans.find { |p| p.mode == :read }&.accessor_key or
677
- raise("No :read plan for #{path_str}")
678
- end
679
- end
680
-
681
- def align_to_cascade_if_vec(slot, cascade_scope, ops, access_plans)
682
- sh = determine_slot_shape(slot, ops, access_plans)
683
- return slot if sh.kind == :scalar && cascade_scope.empty? # scalar cascade, keep scalar
684
- return slot if sh.scope == cascade_scope
685
-
686
- # Handle scalar-to-vector broadcasting for vectorized cascades
687
- if sh.kind == :scalar && !cascade_scope.empty?
688
- # Find a carrier vector at cascade scope to broadcast scalar against
689
- target_slot = nil
690
- ops.each_with_index do |op, i|
691
- next unless %i[load_input map].include?(op.tag)
692
-
693
- shape = determine_slot_shape(i, ops, access_plans)
694
- if shape.kind == :vec && shape.scope == cascade_scope && shape.has_idx
695
- target_slot = i
696
- break
697
- end
698
- end
699
-
700
- raise "Cannot broadcast scalar to cascade scope #{cascade_scope.inspect} - no carrier vector found" unless target_slot
701
-
702
- # Use MAP with a special broadcast function - but first I need to create one
703
- # For now, let's try using the 'if' function to broadcast: if(true, scalar, carrier) -> broadcasts scalar
704
- const_true = ops.size
705
- ops << Kumi::Core::IR::Ops.Const(true)
706
-
707
- ops << Kumi::Core::IR::Ops.Map(:if, 3, const_true, slot, target_slot)
708
- return ops.size - 1
709
-
710
- # No carrier found, can't broadcast
711
-
712
- end
713
-
714
- short, long = [sh.scope, cascade_scope].sort_by(&:length)
715
- unless long.first(short.length) == short
716
- raise "cascade branch result scope #{sh.scope.inspect} not compatible with cascade scope #{cascade_scope.inspect}"
717
- end
718
-
719
- raise "unsupported cascade scope #{cascade_scope.inspect} for slot #{slot}" if cascade_scope.empty?
720
- end
721
-
722
- def prefix?(short, long)
723
- long.first(short.length) == short
724
- end
725
-
726
- def infer_expr_scope(expr, access_plans)
727
- case expr
728
- when Syntax::DeclarationReference
729
- meta = @vec_meta && @vec_meta[:"#{expr.name}__vec"]
730
- meta ? Array(meta[:scope]) : []
731
- when Syntax::InputElementReference
732
- key = expr.path.join(".")
733
- plan = access_plans.fetch(key, []).find { |p| p.mode == :each_indexed }
734
- plan ? Array(plan.scope) : []
735
- when Syntax::InputReference
736
- plans = access_plans.fetch(expr.name.to_s, [])
737
- plan = plans.find { |p| p.mode == :each_indexed }
738
- plan ? Array(plan.scope) : []
739
- when Syntax::CallExpression
740
- # reducers: use source vec scope; non-reducers: deepest carrier among args
741
- scopes = expr.args.map { |a| infer_expr_scope(a, access_plans) }
742
- scopes.max_by(&:length) || []
743
- when Syntax::ArrayExpression
744
- scopes = expr.elements.map { |e| infer_expr_scope(e, access_plans) }
745
- lub_scopes(scopes) # <-- important
746
- else
747
- []
748
- end
749
- end
750
-
751
- def lower_cascade_pred(cond, cascade_scope, ops, access_plans, scope_plan)
752
- case cond
753
- when Syntax::DeclarationReference
754
- # Check if this declaration has a vectorized twin at the required scope
755
- twin = :"#{cond.name}__vec"
756
- twin_meta = @vec_meta && @vec_meta[twin]
757
-
758
- raise "Missing cascade_scope" unless cascade_scope && !Array(cascade_scope).empty?
759
-
760
- # Consumer needs a grouped view of this declaration.
761
- if twin_meta && twin_meta[:scope] == Array(cascade_scope)
762
- # We have a vectorized twin at exactly the required scope - use it!
763
- ops << Kumi::Core::IR::Ops.Ref(twin)
764
- ops.size - 1
765
- else
766
- # Need to inline re-lower the referenced declaration's *expression*
767
- decl = @declarations.fetch(cond.name) { raise "unknown decl #{cond.name}" }
768
- slot = lower_expression(decl.expression, ops, access_plans, scope_plan,
769
- true, Array(cascade_scope), cacheable: true)
770
- project_mask_to_scope(slot, cascade_scope, ops, access_plans)
771
- end
772
- when Syntax::CallExpression
773
- parts = cond.args.map { |a| lower_cascade_pred(a, cascade_scope, ops, access_plans, scope_plan) }
774
- # They’re all @ cascade_scope (or scalar) now; align scalars broadcast, vecs already match.
775
- parts.reduce do |acc, s|
776
- ops << Kumi::Core::IR::Ops.Map(:and, 2, acc, s)
777
- ops.size - 1
778
- end
779
- else
780
- raise "Unexpected Expression #{cond.class} in Cascade"
781
- end
782
- end
783
-
784
- def common_prefix(a, b)
785
- a = Array(a)
786
- b = Array(b)
787
- i = 0
788
- i += 1 while i < a.length && i < b.length && a[i] == b[i]
789
- a.first(i)
790
- end
791
-
792
- def lub_scopes(scopes)
793
- scopes = scopes.reject { |s| s.nil? || s.empty? }
794
- return [] if scopes.empty?
795
-
796
- scopes.reduce(scopes.first) { |acc, s| common_prefix(acc, s) }
797
- end
798
-
799
- def emit_reduce(ops, fn_name, src_slot, src_scope, required_scope)
800
- rs = Array(required_scope || [])
801
- ss = Array(src_scope)
802
-
803
- # No-op: grouping to full source scope
804
- return src_slot if !rs.empty? && rs == ss
805
-
806
- axis = rs.empty? ? ss : (ss - rs)
807
- puts " emit_reduce #{fn_name} on #{src_slot} with axis #{axis.inspect} and result scope #{rs.inspect}" if ENV["DEBUG_LOWER"]
808
- ops << Kumi::Core::IR::Ops.Reduce(fn_name, axis, rs, [], src_slot)
809
- ops.size - 1
810
- end
811
-
812
- def vec_twin_name(base, scope)
813
- scope_tag = Array(scope).map(&:to_s).join("_") # e.g. "players"
814
- :"#{base}__vec__#{scope_tag}"
815
- end
816
-
817
- def find_vec_twin(name, scope)
818
- t = vec_twin_name(name, scope)
819
- @vec_meta[t] ? t : nil
820
- end
821
-
822
- def top_level_reducer?(decl)
823
- ce = decl.expression
824
- return false unless ce.is_a?(Kumi::Syntax::CallExpression)
825
-
826
- entry = Kumi::Registry.entry(ce.fn_name)
827
- entry&.reducer && !entry&.structure_function
828
- end
829
-
830
- def has_nested_reducer?(expr)
831
- return false unless expr.is_a?(Kumi::Syntax::CallExpression)
832
-
833
- expr.args.any? do |arg|
834
- case arg
835
- when Kumi::Syntax::CallExpression
836
- entry = Kumi::Registry.entry(arg.fn_name)
837
- return true if entry&.reducer
838
-
839
- has_nested_reducer?(arg) # recursive check
840
- else
841
- false
842
- end
843
- end
844
- end
845
-
846
- # Make sure a boolean mask lives at exactly cascade_scope.
847
- def project_mask_to_scope(slot, cascade_scope, ops, access_plans)
848
- sh = determine_slot_shape(slot, ops, access_plans)
849
- return slot if sh.scope == cascade_scope
850
- return slot if sh.kind == :scalar
851
-
852
- cascade_scope = Array(cascade_scope)
853
- slot_scope = Array(sh.scope)
854
-
855
- # Check prefix compatibility
856
- short, long = [cascade_scope, slot_scope].sort_by(&:length)
857
- unless long.first(short.length) == short
858
- raise "cascade condition scope #{slot_scope.inspect} is not prefix-compatible with #{cascade_scope.inspect}"
859
- end
860
-
861
- return unless slot_scope.length < cascade_scope.length
862
-
863
- # Need to broadcast UP: slot scope is shorter, needs to be aligned to cascade scope
864
- # Find a target vector that already has the cascade scope
865
- target_slot = nil
866
- ops.each_with_index do |op, i|
867
- next unless %i[load_input map].include?(op.tag)
868
-
869
- shape = determine_slot_shape(i, ops, access_plans)
870
- if shape.kind == :vec && shape.scope == cascade_scope && shape.has_idx
871
- target_slot = i
872
- break
873
- end
874
- end
875
-
876
- ops << Kumi::Core::IR::Ops.AlignTo(target_slot, slot, to_scope: cascade_scope, on_missing: :error, require_unique: true)
877
- ops.size - 1
878
- end
879
-
880
- # Constant folding optimization helpers
881
- def can_constant_fold?(expr, entry)
882
- return false unless entry&.fn # Skip if function not found
883
- return false if entry.reducer # Skip reducer functions for now
884
- return false if expr.args.empty? # Need at least one argument
885
-
886
- # Check if all arguments are literals
887
- expr.args.all? { |arg| arg.is_a?(Syntax::Literal) }
888
- end
889
-
890
- def constant_fold(expr, entry)
891
- literal_values = expr.args.map(&:value)
892
-
893
- begin
894
- # Call the function with literal values at compile time
895
- entry.fn.call(*literal_values)
896
- rescue StandardError => e
897
- # If constant folding fails, fall back to runtime evaluation
898
- # This shouldn't happen with pure functions, but be defensive
899
- puts "Constant folding failed for #{expr.fn_name}: #{e.message}" if ENV["DEBUG_LOWER"]
900
- raise "Cannot constant fold #{expr.fn_name}: #{e.message}"
901
- end
902
- end
903
- end
904
- end
905
- end
906
- end
907
- end