kumi 0.0.13 → 0.0.15

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/BACKLOG.md +34 -0
  4. data/CHANGELOG.md +33 -0
  5. data/CLAUDE.md +4 -6
  6. data/README.md +0 -45
  7. data/config/functions.yaml +352 -0
  8. data/docs/dev/analyzer-debug.md +52 -0
  9. data/docs/dev/parse-command.md +64 -0
  10. data/docs/dev/vm-profiling.md +95 -0
  11. data/docs/features/README.md +0 -7
  12. data/docs/functions/analyzer_integration.md +199 -0
  13. data/docs/functions/signatures.md +171 -0
  14. data/examples/hash_objects_demo.rb +138 -0
  15. data/golden/array_operations/schema.kumi +17 -0
  16. data/golden/cascade_logic/schema.kumi +16 -0
  17. data/golden/mixed_nesting/schema.kumi +42 -0
  18. data/golden/simple_math/schema.kumi +10 -0
  19. data/lib/kumi/analyzer.rb +76 -22
  20. data/lib/kumi/compiler.rb +6 -5
  21. data/lib/kumi/core/analyzer/checkpoint.rb +72 -0
  22. data/lib/kumi/core/analyzer/debug.rb +167 -0
  23. data/lib/kumi/core/analyzer/passes/broadcast_detector.rb +1 -3
  24. data/lib/kumi/core/analyzer/passes/function_signature_pass.rb +199 -0
  25. data/lib/kumi/core/analyzer/passes/ir_dependency_pass.rb +67 -0
  26. data/lib/kumi/core/analyzer/passes/load_input_cse.rb +120 -0
  27. data/lib/kumi/core/analyzer/passes/lower_to_ir_pass.rb +72 -157
  28. data/lib/kumi/core/analyzer/passes/toposorter.rb +40 -36
  29. data/lib/kumi/core/analyzer/state_serde.rb +64 -0
  30. data/lib/kumi/core/analyzer/structs/access_plan.rb +12 -10
  31. data/lib/kumi/core/compiler/access_planner.rb +3 -2
  32. data/lib/kumi/core/function_registry/collection_functions.rb +3 -1
  33. data/lib/kumi/core/functions/dimension.rb +98 -0
  34. data/lib/kumi/core/functions/dtypes.rb +20 -0
  35. data/lib/kumi/core/functions/errors.rb +11 -0
  36. data/lib/kumi/core/functions/kernel_adapter.rb +45 -0
  37. data/lib/kumi/core/functions/loader.rb +119 -0
  38. data/lib/kumi/core/functions/registry_v2.rb +68 -0
  39. data/lib/kumi/core/functions/shape.rb +70 -0
  40. data/lib/kumi/core/functions/signature.rb +122 -0
  41. data/lib/kumi/core/functions/signature_parser.rb +86 -0
  42. data/lib/kumi/core/functions/signature_resolver.rb +272 -0
  43. data/lib/kumi/core/ir/execution_engine/interpreter.rb +110 -7
  44. data/lib/kumi/core/ir/execution_engine/profiler.rb +330 -0
  45. data/lib/kumi/core/ir/execution_engine.rb +6 -15
  46. data/lib/kumi/dev/ir.rb +75 -0
  47. data/lib/kumi/dev/parse.rb +105 -0
  48. data/lib/kumi/dev/profile_aggregator.rb +301 -0
  49. data/lib/kumi/dev/profile_runner.rb +199 -0
  50. data/lib/kumi/dev/runner.rb +85 -0
  51. data/lib/kumi/dev.rb +14 -0
  52. data/lib/kumi/frontends/ruby.rb +28 -0
  53. data/lib/kumi/frontends/text.rb +46 -0
  54. data/lib/kumi/frontends.rb +29 -0
  55. data/lib/kumi/kernels/ruby/aggregate_core.rb +105 -0
  56. data/lib/kumi/kernels/ruby/datetime_scalar.rb +21 -0
  57. data/lib/kumi/kernels/ruby/mask_scalar.rb +15 -0
  58. data/lib/kumi/kernels/ruby/scalar_core.rb +63 -0
  59. data/lib/kumi/kernels/ruby/string_scalar.rb +19 -0
  60. data/lib/kumi/kernels/ruby/vector_struct.rb +39 -0
  61. data/lib/kumi/runtime/executable.rb +108 -45
  62. data/lib/kumi/schema.rb +12 -6
  63. data/lib/kumi/support/diff.rb +22 -0
  64. data/lib/kumi/support/ir_render.rb +61 -0
  65. data/lib/kumi/version.rb +1 -1
  66. data/lib/kumi.rb +3 -0
  67. data/performance_results.txt +63 -0
  68. data/scripts/test_mixed_nesting_performance.rb +206 -0
  69. metadata +50 -6
  70. data/docs/features/analysis-cascade-mutual-exclusion.md +0 -89
  71. data/docs/features/javascript-transpiler.md +0 -148
  72. data/lib/kumi/js.rb +0 -23
  73. data/lib/kumi/support/ir_dump.rb +0 -491
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "shape"
5
+ require_relative "signature"
6
+
7
+ module Kumi
8
+ module Core
9
+ module Functions
10
+ # Given a set of signatures and actual argument shapes, pick the best match.
11
+ # Supports NEP 20 extensions: fixed-size, flexible, and broadcastable dimensions.
12
+ #
13
+ # Inputs:
14
+ # signatures : Array<Signature> (with Dimension objects)
15
+ # arg_shapes : Array<Array<Symbol|Integer>> e.g., [[:i], [:i]] or [[], [3]] or [[2, :i]]
16
+ #
17
+ # Returns:
18
+ # { signature:, result_axes:, join_policy:, dropped_axes:, effective_signature: }
19
+ #
20
+ # NEP 20 Matching rules:
21
+ # - Arity must match exactly (before flexible dimension resolution).
22
+ # - Fixed-size dimensions (integers) must match exactly.
23
+ # - Flexible dimensions (?) can be omitted if not present in all operands.
24
+ # - Broadcastable dimensions (|1) can match scalar or size-1 dimensions.
25
+ # - For each param position, shapes are checked according to NEP 20 rules.
26
+ # - We prefer exact matches, then flexible matches, then broadcast matches.
27
+ class SignatureResolver
28
+ class << self
29
+ def choose(signatures:, arg_shapes:)
30
+ # Handle empty arg_shapes for zero-arity functions
31
+ arg_shapes = [] if arg_shapes.nil?
32
+ sanity_check_args!(arg_shapes)
33
+
34
+ candidates = signatures.map do |sig|
35
+ score = match_score(sig, arg_shapes)
36
+ next if score.nil?
37
+
38
+ # Convert arg_shapes to normalized Dimension arrays for environment building
39
+ normalized_args = arg_shapes.map { |shape| normalize_shape(shape) }
40
+ env = build_dimension_environment(sig, normalized_args)
41
+ next if env.nil? # Skip candidates with dimension conflicts
42
+
43
+ {
44
+ signature: sig,
45
+ score: score,
46
+ result_axes: sig.out_shape.map(&:name), # Convert Dimension objects to names for backward compatibility
47
+ join_policy: sig.join_policy,
48
+ dropped_axes: sig.dropped_axes.map { |name| name.is_a?(Symbol) ? name : name.to_sym }, # Convert to symbols
49
+ env: env
50
+ }
51
+ end.compact
52
+
53
+ raise SignatureMatchError, mismatch_message(signatures, arg_shapes) if candidates.empty?
54
+
55
+ # Lower score is better: 0 = exact-everywhere, then number of broadcasts
56
+ best = candidates.min_by { |c| c[:score] }
57
+
58
+ # Add effective signature and environment for analyzer/lowering
59
+ best[:effective_signature] = {
60
+ in_shapes: best[:signature].in_shapes.map { |dims| dims.map(&:name) },
61
+ out_shape: best[:signature].out_shape.map(&:name),
62
+ join_policy: best[:signature].join_policy
63
+ }
64
+ # env is already included from candidate building
65
+
66
+ best
67
+ end
68
+
69
+ private
70
+
71
+ def sanity_check_args!(arg_shapes)
72
+ unless arg_shapes.is_a?(Array) &&
73
+ arg_shapes.all? { |s| s.is_a?(Array) && s.all? { |a| a.is_a?(Symbol) || a.is_a?(Integer) } }
74
+ raise SignatureMatchError, "arg_shapes must be an array of dimension arrays (symbols or integers), got: #{arg_shapes.inspect}"
75
+ end
76
+ end
77
+
78
+ # Returns an integer "broadcast cost" or nil if not matchable.
79
+ # Lower score = better match: 0 = exact, then increasing cost for broadcasts/flexibility
80
+ def match_score(sig, arg_shapes)
81
+ return nil unless sig.arity == arg_shapes.length
82
+
83
+ # Convert arg_shapes to normalized Dimension arrays for comparison
84
+ normalized_args = arg_shapes.map { |shape| normalize_shape(shape) }
85
+
86
+ # Try to match each argument against its expected signature shape
87
+ cost = 0
88
+ sig.in_shapes.each_with_index do |expected_dims, idx|
89
+ got_dims = normalized_args[idx]
90
+ arg_cost = match_argument_cost(got: got_dims, expected: expected_dims)
91
+ return nil if arg_cost.nil?
92
+
93
+ cost += arg_cost
94
+ end
95
+
96
+ # Additional checks for join_policy constraints
97
+ return nil unless valid_join_policy?(sig, normalized_args)
98
+
99
+ cost
100
+ end
101
+
102
+ private
103
+
104
+ # Convert a shape array (symbols/integers) to normalized Dimension array
105
+ def normalize_shape(shape)
106
+ shape.map do |dim|
107
+ case dim
108
+ when Symbol
109
+ Dimension.new(dim)
110
+ when Integer
111
+ Dimension.new(dim)
112
+ else
113
+ raise SignatureMatchError, "Invalid dimension type: #{dim.class}"
114
+ end
115
+ end
116
+ end
117
+
118
+ # Calculate cost of matching one argument against expected dimensions
119
+ def match_argument_cost(got:, expected:)
120
+ # Handle scalar first
121
+ if got.empty?
122
+ return expected.empty? ? 0 : (expected.any?(&:flexible?) ? 10 : 1) # scalar broadcast or flexible-tail
123
+ end
124
+
125
+ # Try strict matching first if no flexible dimensions
126
+ if !expected.any?(&:flexible?) && got.length == expected.length
127
+ total = 0
128
+ got.zip(expected).each do |g, e|
129
+ c = match_dimension_cost(got: g, expected: e)
130
+ return nil if c.nil?
131
+ total += c
132
+ end
133
+ return total
134
+ end
135
+
136
+ # Use right-aligned flexible matching
137
+ right_align_match(got: got, expected: expected)
138
+ end
139
+
140
+ # Right-aligned matching for flexible dimensions (NEP 20 ? modifier)
141
+ def right_align_match(got:, expected:)
142
+ gi = got.length - 1
143
+ ei = expected.length - 1
144
+ cost = 0
145
+
146
+ while ei >= 0
147
+ exp = expected[ei]
148
+
149
+ if exp.flexible? && gi < 0
150
+ # optional tail dimension that we don't have → ok, consume expected only
151
+ ei -= 1
152
+ cost += 10
153
+ next
154
+ end
155
+
156
+ return nil if gi < 0 # ran out of got dims and exp wasn't flexible
157
+
158
+ got_dim = got[gi]
159
+ dim_cost = match_dimension_cost(got: got_dim, expected: exp)
160
+ if dim_cost.nil?
161
+ # if exp is flexible, we can try to drop it
162
+ if exp.flexible?
163
+ ei -= 1
164
+ cost += 10
165
+ next
166
+ else
167
+ return nil
168
+ end
169
+ else
170
+ cost += dim_cost
171
+ gi -= 1
172
+ ei -= 1
173
+ end
174
+ end
175
+
176
+ # if we still have leftover got dims, argument is longer than expected → not a match
177
+ return nil if gi >= 0
178
+
179
+ cost
180
+ end
181
+
182
+ # Calculate cost of matching one dimension against another
183
+ def match_dimension_cost(got:, expected:)
184
+ return 0 if got == expected # Exact match
185
+
186
+ # Fixed-size equality
187
+ if got.fixed_size? && expected.fixed_size?
188
+ return got.size == expected.size ? 0 : nil
189
+ end
190
+
191
+ # Same symbolic name (ignoring modifiers) → ok unless one is fixed and the other isn't (penalize)
192
+ if got.named? && expected.named? && got.name == expected.name
193
+ return (got.fixed_size? || expected.fixed_size?) ? 2 : 0
194
+ end
195
+
196
+ # Broadcastable expected dim accepts scalar or size-1
197
+ if expected.broadcastable?
198
+ # scalar at argument level would have been handled in match_argument_cost
199
+ # so here we check for size-1 fixed dimensions
200
+ return 3 if got.fixed_size? && got.size == 1
201
+ # Named dimensions that could be size-1 at runtime also get broadcast cost
202
+ return 3 if got.named?
203
+ end
204
+
205
+ nil # No match possible
206
+ end
207
+
208
+ # Check if join_policy constraints are satisfied
209
+ def valid_join_policy?(sig, normalized_args)
210
+ return true if sig.join_policy # :zip or :product allows different axes
211
+
212
+ # nil join_policy: check if dimension names are consistent
213
+ non_scalar_args = normalized_args.reject { |a| Shape.scalar?(a) }
214
+ return true if non_scalar_args.empty?
215
+
216
+ # For nil join_policy, we allow different dimension names if:
217
+ # 1. All args have same dimension names (element-wise operations), OR
218
+ # 2. The constraint solver can validate cross-dimensional consistency (like matmul)
219
+ first_names = non_scalar_args.first.map(&:name)
220
+ same_names = non_scalar_args.all? { |arg| arg.map(&:name) == first_names }
221
+
222
+ return true if same_names
223
+
224
+ # If dimension names differ, check if constraint solver can handle it
225
+ # This allows operations like matmul where dimensions are linked across arguments
226
+ env = build_dimension_environment(sig, normalized_args)
227
+ !env.nil?
228
+ end
229
+
230
+ def mismatch_message(signatures, arg_shapes)
231
+ sigs = signatures.map(&:inspect).join(", ")
232
+ "no matching signature for shapes #{pp_shapes(arg_shapes)} among [#{sigs}]"
233
+ end
234
+
235
+ def pp_shapes(shapes)
236
+ shapes.map { |ax| "(#{ax.join(',')})" }.join(", ")
237
+ end
238
+
239
+ # Build dimension environment by checking consistency of named dimensions across arguments
240
+ def build_dimension_environment(sig, normalized_args)
241
+ env = {}
242
+
243
+ # Walk all expected dimensions across all arguments
244
+ sig.in_shapes.each_with_index do |expected_shape, arg_idx|
245
+ got_shape = normalized_args[arg_idx] || []
246
+
247
+ expected_shape.each_with_index do |exp_dim, dim_idx|
248
+ next unless exp_dim.named? && dim_idx < got_shape.length
249
+
250
+ got_dim = got_shape[dim_idx]
251
+ dim_name = exp_dim.name
252
+
253
+ # Check for consistency: same dimension name must map to same concrete value
254
+ if env.key?(dim_name)
255
+ # If we've seen this dimension name before, it must match
256
+ if env[dim_name] != got_dim
257
+ return nil # Inconsistent binding - signature doesn't match
258
+ end
259
+ else
260
+ # First time seeing this dimension name - record the binding
261
+ env[dim_name] = got_dim
262
+ end
263
+ end
264
+ end
265
+
266
+ env
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -9,20 +9,70 @@ module Kumi
9
9
  PRODUCES_SLOT = %i[const load_input ref array map reduce lift align_to switch].freeze
10
10
  NON_PRODUCERS = %i[guard_push guard_pop assign store].freeze
11
11
 
12
+ def self.build_name_index(ir_module)
13
+ index = {}
14
+ ir_module.decls.each do |decl|
15
+ decl.ops.each do |op|
16
+ next unless op.tag == :store
17
+
18
+ name = op.attrs[:name]
19
+ index[name] = decl if name
20
+ end
21
+ end
22
+ index
23
+ end
24
+
12
25
  def self.run(ir_module, ctx, accessors:, registry:)
13
26
  # Validate registry is properly initialized
14
27
  raise ArgumentError, "Registry cannot be nil" if registry.nil?
15
28
  raise ArgumentError, "Registry must be a Hash, got #{registry.class}" unless registry.is_a?(Hash)
16
29
 
30
+ # --- PROFILER: init per run (but not in persistent mode) ---
31
+ if Profiler.enabled?
32
+ schema_name = ctx[:schema_name] || "UnknownSchema"
33
+ if Profiler.persistent?
34
+ # In persistent mode, just update schema name without full reset
35
+ Profiler.set_schema_name(schema_name)
36
+ else
37
+ # Normal mode: full reset with schema name
38
+ Profiler.reset!(meta: { decls: ir_module.decls&.size || 0, schema_name: schema_name })
39
+ end
40
+ end
41
+
17
42
  outputs = {}
18
43
  target = ctx[:target]
19
44
  guard_stack = [true]
20
45
 
21
- ir_module.decls.each do |decl|
46
+ # Always ensure we have a declaration cache - either from caller or new for this VM run
47
+ declaration_cache = ctx[:declaration_cache] || {}
48
+
49
+ # Build name index for targeting by stored names
50
+ name_index = ctx[:name_index] || (target ? build_name_index(ir_module) : nil)
51
+
52
+ # Choose declarations to execute - prefer explicit schedule if present
53
+ decls_to_run =
54
+ if ctx[:decls_to_run]
55
+ ctx[:decls_to_run] # array of decl objects
56
+ elsif target
57
+ # Prefer a decl that STORES the target (covers __vec twins)
58
+ d = name_index && name_index[target]
59
+ # Fallback: allow targeting by decl name (legacy behavior)
60
+ d ||= ir_module.decls.find { |dd| dd.name == target }
61
+ raise "Unknown target: #{target}" unless d
62
+
63
+ [d]
64
+ else
65
+ ir_module.decls
66
+ end
67
+
68
+ decls_to_run.each do |decl|
22
69
  slots = []
23
70
  guard_stack = [true] # reset per decl
24
71
 
25
72
  decl.ops.each_with_index do |op, op_index|
73
+ t0 = Profiler.enabled? ? Profiler.t0 : nil
74
+ cpu_t0 = Profiler.enabled? ? Profiler.cpu_t0 : nil
75
+ rows_touched = nil
26
76
  if ENV["ASSERT_VM_SLOTS"] == "1"
27
77
  expected = op_index
28
78
  unless slots.length == expected
@@ -47,17 +97,26 @@ module Kumi
47
97
  false
48
98
  end
49
99
  slots << nil # keep slot_id == op_index
100
+ if t0
101
+ Profiler.record!(decl: decl.name, idx: op_index, tag: op.tag, op: op, t0: t0, cpu_t0: cpu_t0, rows: 0,
102
+ note: "enter")
103
+ end
50
104
  next
51
105
 
52
106
  when :guard_pop
53
107
  guard_stack.pop
54
108
  slots << nil
109
+ Profiler.record!(decl: decl.name, idx: op_index, tag: op.tag, op: op, t0: t0, cpu_t0: cpu_t0, rows: 0, note: "exit") if t0
55
110
  next
56
111
  end
57
112
 
58
113
  # Skip body when guarded off, but keep indices aligned
59
114
  unless guard_stack.last
60
115
  slots << nil if PRODUCES_SLOT.include?(op.tag) || NON_PRODUCERS.include?(op.tag)
116
+ if t0
117
+ Profiler.record!(decl: decl.name, idx: op_index, tag: op.tag, op: op, t0: t0, cpu_t0: cpu_t0, rows: 0,
118
+ note: "skipped")
119
+ end
61
120
  next
62
121
  end
63
122
 
@@ -69,35 +128,74 @@ module Kumi
69
128
  raise "assign: dst/src OOB" if dst >= slots.length || src >= slots.length
70
129
 
71
130
  slots[dst] = slots[src]
131
+ Profiler.record!(decl: decl.name, idx: op_index, tag: :assign, op: op, t0: t0, cpu_t0: cpu_t0, rows: 1) if t0
72
132
 
73
133
  when :const
74
134
  result = Values.scalar(op.attrs[:value])
75
135
  puts "DEBUG Const #{op.attrs[:value].inspect}: result=#{result}" if ENV["DEBUG_VM_ARGS"]
76
136
  slots << result
137
+ Profiler.record!(decl: decl.name, idx: op_index, tag: :const, op: op, t0: t0, cpu_t0: cpu_t0, rows: 1) if t0
77
138
 
78
139
  when :load_input
79
140
  plan_id = op.attrs[:plan_id]
80
141
  scope = op.attrs[:scope] || []
81
142
  scalar = op.attrs[:is_scalar]
82
143
  indexed = op.attrs[:has_idx]
83
- raw = accessors.fetch(plan_id).call(ctx[:input] || ctx["input"])
84
144
 
85
- puts "DEBUG LoadInput plan_id: #{plan_id} raw_values: #{raw.inspect}" if ENV["DEBUG_VM_ARGS"]
145
+ # NEW: consult runtime accessor cache
146
+ acc_cache = ctx[:accessor_cache] || {}
147
+ input_obj = ctx[:input] || ctx["input"]
148
+ cache_key = [plan_id, input_obj.object_id]
149
+
150
+ if acc_cache.key?(cache_key)
151
+ raw = acc_cache[cache_key]
152
+ hit = true
153
+ else
154
+ raw = accessors.fetch(plan_id).call(input_obj)
155
+ acc_cache[cache_key] = raw
156
+ hit = false
157
+ end
158
+
159
+ puts "DEBUG LoadInput plan_id: #{plan_id} raw_values: #{raw.inspect} cache_hit: #{hit}" if ENV["DEBUG_VM_ARGS"]
86
160
  slots << if scalar
87
161
  Values.scalar(raw)
88
162
  elsif indexed
163
+ rows_touched = raw.respond_to?(:size) ? raw.size : raw.count
89
164
  Values.vec(scope, raw.map { |v, idx| { v: v, idx: Array(idx) } }, true)
90
165
  else
166
+ rows_touched = raw.respond_to?(:size) ? raw.size : raw.count
91
167
  Values.vec(scope, raw.map { |v| { v: v } }, false)
92
168
  end
169
+ rows_touched ||= 1
170
+ cache_note = hit ? "hit:#{plan_id}" : "miss:#{plan_id}"
171
+ if t0
172
+ Profiler.record!(decl: decl.name, idx: op_index, tag: :load_input, op: op, t0: t0, cpu_t0: cpu_t0,
173
+ rows: rows_touched, note: cache_note)
174
+ end
93
175
 
94
176
  when :ref
95
177
  name = op.attrs[:name]
96
- referenced_value = outputs.fetch(name) { raise "Missing output for reference: #{name}" }
178
+
179
+ if outputs.key?(name)
180
+ referenced = outputs[name]
181
+ hit = :outputs
182
+ elsif declaration_cache.key?(name)
183
+ referenced = declaration_cache[name]
184
+ hit = :cache
185
+ else
186
+ raise "unscheduled ref #{name}: producer not executed or dependency analysis failed"
187
+ end
188
+
97
189
  if ENV["DEBUG_VM_ARGS"]
98
- puts "DEBUG Ref #{name}: #{referenced_value[:k] == :scalar ? "scalar(#{referenced_value[:v].inspect})" : "#{referenced_value[:k]}(#{referenced_value[:rows]&.size || 0} rows)"}"
190
+ puts "DEBUG Ref #{name}: #{referenced[:k] == :scalar ? "scalar(#{referenced[:v].inspect})" : "#{referenced[:k]}(#{referenced[:rows]&.size || 0} rows)"}"
191
+ end
192
+
193
+ slots << referenced
194
+ rows_touched = referenced[:k] == :vec ? (referenced[:rows]&.size || 0) : 1
195
+ if t0
196
+ Profiler.record!(decl: decl.name, idx: op_index, tag: :ref, op: op, t0: t0, cpu_t0: cpu_t0,
197
+ rows: rows_touched, note: hit)
99
198
  end
100
- slots << referenced_value
101
199
 
102
200
  when :array
103
201
  # Validate slot indices before accessing
@@ -216,7 +314,10 @@ module Kumi
216
314
  raise "Store operation '#{name}': source slot #{src} is nil (available slots: #{slots.length}, non-nil slots: #{slots.compact.length})"
217
315
  end
218
316
 
219
- outputs[name] = slots[src]
317
+ result = slots[src]
318
+ outputs[name] = result
319
+ # Also store in declaration cache for future ref operations
320
+ declaration_cache[name] = result
220
321
 
221
322
  # keep slot_id == op_index invariant
222
323
  slots << nil
@@ -327,6 +428,8 @@ module Kumi
327
428
  end
328
429
  end
329
430
 
431
+ # --- end-of-run summary ---
432
+ Profiler.emit_summary! if Profiler.enabled?
330
433
  outputs
331
434
  end
332
435
  end