kumi 0.0.9 → 0.0.11

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