kumi 0.0.10 → 0.0.12

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/CHANGELOG.md +23 -0
  4. data/CLAUDE.md +7 -231
  5. data/README.md +5 -5
  6. data/docs/SYNTAX.md +66 -0
  7. data/docs/VECTOR_SEMANTICS.md +286 -0
  8. data/docs/features/hierarchical-broadcasting.md +67 -1
  9. data/docs/features/input-declaration-system.md +16 -0
  10. data/docs/features/s-expression-printer.md +2 -2
  11. data/lib/kumi/analyzer.rb +34 -12
  12. data/lib/kumi/compiler.rb +2 -12
  13. data/lib/kumi/core/analyzer/passes/broadcast_detector.rb +157 -64
  14. data/lib/kumi/core/analyzer/passes/dependency_resolver.rb +1 -1
  15. data/lib/kumi/core/analyzer/passes/input_access_planner_pass.rb +47 -0
  16. data/lib/kumi/core/analyzer/passes/input_collector.rb +123 -101
  17. data/lib/kumi/core/analyzer/passes/join_reduce_planning_pass.rb +293 -0
  18. data/lib/kumi/core/analyzer/passes/lower_to_ir_pass.rb +993 -0
  19. data/lib/kumi/core/analyzer/passes/pass_base.rb +2 -2
  20. data/lib/kumi/core/analyzer/passes/scope_resolution_pass.rb +346 -0
  21. data/lib/kumi/core/analyzer/passes/semantic_constraint_validator.rb +2 -1
  22. data/lib/kumi/core/analyzer/passes/toposorter.rb +9 -3
  23. data/lib/kumi/core/analyzer/passes/type_checker.rb +3 -3
  24. data/lib/kumi/core/analyzer/passes/type_consistency_checker.rb +2 -2
  25. data/lib/kumi/core/analyzer/passes/{type_inferencer.rb → type_inferencer_pass.rb} +4 -4
  26. data/lib/kumi/core/analyzer/passes/unsat_detector.rb +2 -2
  27. data/lib/kumi/core/analyzer/plans.rb +52 -0
  28. data/lib/kumi/core/analyzer/structs/access_plan.rb +20 -0
  29. data/lib/kumi/core/analyzer/structs/input_meta.rb +29 -0
  30. data/lib/kumi/core/compiler/access_builder.rb +36 -0
  31. data/lib/kumi/core/compiler/access_planner.rb +219 -0
  32. data/lib/kumi/core/compiler/accessors/base.rb +69 -0
  33. data/lib/kumi/core/compiler/accessors/each_indexed_accessor.rb +84 -0
  34. data/lib/kumi/core/compiler/accessors/materialize_accessor.rb +55 -0
  35. data/lib/kumi/core/compiler/accessors/ravel_accessor.rb +73 -0
  36. data/lib/kumi/core/compiler/accessors/read_accessor.rb +41 -0
  37. data/lib/kumi/core/compiler_base.rb +2 -2
  38. data/lib/kumi/core/error_reporter.rb +6 -5
  39. data/lib/kumi/core/errors.rb +4 -0
  40. data/lib/kumi/core/explain.rb +157 -205
  41. data/lib/kumi/core/export/node_builders.rb +2 -2
  42. data/lib/kumi/core/export/node_serializers.rb +1 -1
  43. data/lib/kumi/core/function_registry/collection_functions.rb +21 -10
  44. data/lib/kumi/core/function_registry/conditional_functions.rb +14 -4
  45. data/lib/kumi/core/function_registry/function_builder.rb +142 -55
  46. data/lib/kumi/core/function_registry/logical_functions.rb +5 -5
  47. data/lib/kumi/core/function_registry/stat_functions.rb +2 -2
  48. data/lib/kumi/core/function_registry.rb +126 -108
  49. data/lib/kumi/core/input/validator.rb +1 -1
  50. data/lib/kumi/core/ir/execution_engine/combinators.rb +117 -0
  51. data/lib/kumi/core/ir/execution_engine/interpreter.rb +336 -0
  52. data/lib/kumi/core/ir/execution_engine/values.rb +46 -0
  53. data/lib/kumi/core/ir/execution_engine.rb +50 -0
  54. data/lib/kumi/core/ir.rb +58 -0
  55. data/lib/kumi/core/ruby_parser/build_context.rb +2 -2
  56. data/lib/kumi/core/ruby_parser/declaration_reference_proxy.rb +0 -12
  57. data/lib/kumi/core/ruby_parser/dsl_cascade_builder.rb +36 -15
  58. data/lib/kumi/core/ruby_parser/input_builder.rb +30 -9
  59. data/lib/kumi/core/ruby_parser/parser.rb +1 -1
  60. data/lib/kumi/core/ruby_parser/schema_builder.rb +2 -2
  61. data/lib/kumi/core/ruby_parser/sugar.rb +7 -0
  62. data/lib/kumi/core/types/validator.rb +1 -1
  63. data/lib/kumi/registry.rb +14 -79
  64. data/lib/kumi/runtime/executable.rb +213 -0
  65. data/lib/kumi/schema.rb +14 -3
  66. data/lib/kumi/schema_metadata.rb +2 -2
  67. data/lib/kumi/support/ir_dump.rb +491 -0
  68. data/lib/kumi/support/s_expression_printer.rb +1 -1
  69. data/lib/kumi/syntax/location.rb +5 -0
  70. data/lib/kumi/syntax/node.rb +0 -1
  71. data/lib/kumi/syntax/root.rb +2 -2
  72. data/lib/kumi/version.rb +1 -1
  73. data/lib/kumi.rb +6 -15
  74. metadata +37 -19
  75. data/lib/kumi/core/cascade_executor_builder.rb +0 -132
  76. data/lib/kumi/core/compiled_schema.rb +0 -43
  77. data/lib/kumi/core/compiler/expression_compiler.rb +0 -146
  78. data/lib/kumi/core/compiler/function_invoker.rb +0 -55
  79. data/lib/kumi/core/compiler/path_traversal_compiler.rb +0 -158
  80. data/lib/kumi/core/compiler/reference_compiler.rb +0 -46
  81. data/lib/kumi/core/evaluation_wrapper.rb +0 -40
  82. data/lib/kumi/core/nested_structure_utils.rb +0 -78
  83. data/lib/kumi/core/schema_instance.rb +0 -115
  84. data/lib/kumi/core/vectorized_function_builder.rb +0 -88
  85. data/lib/kumi/js/compiler.rb +0 -878
  86. data/lib/kumi/js/function_registry.rb +0 -333
  87. data/migrate_to_core_iterative.rb +0 -938
@@ -5,11 +5,11 @@ module Kumi
5
5
  module Analyzer
6
6
  module Passes
7
7
  # Detects which operations should be broadcast over arrays
8
- # DEPENDENCIES: :inputs, :declarations
8
+ # DEPENDENCIES: :input_metadata, :declarations
9
9
  # PRODUCES: :broadcasts
10
10
  class BroadcastDetector < PassBase
11
11
  def run(errors)
12
- input_meta = get_state(:inputs) || {}
12
+ input_meta = get_state(:input_metadata) || {}
13
13
  definitions = get_state(:declarations) || {}
14
14
 
15
15
  # Find array fields with their element types
@@ -40,6 +40,10 @@ module Kumi
40
40
  result = analyze_value_vectorization(name, decl.expression, array_fields, nested_paths, vectorized_values, errors,
41
41
  definitions)
42
42
 
43
+ if ENV["DEBUG_BROADCAST_CLEAN"]
44
+ puts "#{name}: #{result[:type]} #{format_broadcast_info(result)}"
45
+ end
46
+
43
47
  case result[:type]
44
48
  when :vectorized
45
49
  compiler_metadata[:vectorized_operations][name] = result[:info]
@@ -70,6 +74,43 @@ module Kumi
70
74
 
71
75
  private
72
76
 
77
+ def infer_argument_scope(arg, array_fields, nested_paths)
78
+ case arg
79
+ when Kumi::Syntax::InputElementReference
80
+ if nested_paths.key?(arg.path)
81
+ # Extract scope from path - each array dimension in the path
82
+ arg.path.select.with_index { |_seg, i| nested_paths[arg.path[0..i]] }
83
+ else
84
+ arg.path.select { |seg| array_fields.key?(seg) }
85
+ end
86
+ when Kumi::Syntax::CallExpression
87
+ # For nested calls, find the deepest input reference
88
+ deepest_scope = []
89
+ arg.args.each do |nested_arg|
90
+ scope = infer_argument_scope(nested_arg, array_fields, nested_paths)
91
+ deepest_scope = scope if scope.length > deepest_scope.length
92
+ end
93
+ deepest_scope
94
+ else
95
+ []
96
+ end
97
+ end
98
+
99
+ def format_broadcast_info(result)
100
+ case result[:type]
101
+ when :vectorized
102
+ info = result[:info]
103
+ "→ #{info[:source]} (path: #{info[:path]&.join('.')})"
104
+ when :reduction
105
+ info = result[:info]
106
+ "→ fn:#{info[:function]} (arg: #{info[:argument]&.class&.name&.split('::')&.last})"
107
+ when :scalar
108
+ "→ scalar"
109
+ else
110
+ "→ #{result[:info]}"
111
+ end
112
+ end
113
+
73
114
  def compute_compilation_metadata(name, _decl, compiler_metadata, _vectorized_values, _array_fields)
74
115
  metadata = {
75
116
  operation_mode: :broadcast, # Default mode
@@ -169,7 +210,7 @@ module Kumi
169
210
  current_access_mode = parent_access_mode
170
211
  if current_meta[:type] == :array
171
212
  array_depth += 1
172
- current_access_mode = current_meta[:access_mode] || :object # Default to :object if not specified
213
+ current_access_mode = current_meta[:access_mode] || :field # Default to :field if not specified
173
214
  end
174
215
 
175
216
  # If this field has children, recurse into them
@@ -208,7 +249,6 @@ module Kumi
208
249
  # Check if this path exists in nested_paths metadata (supports nested arrays)
209
250
  if nested_paths.key?(expr.path)
210
251
  { type: :vectorized, info: { source: :nested_array_access, path: expr.path, nested_metadata: nested_paths[expr.path] } }
211
- # Fallback to old array_fields detection for backward compatibility
212
252
  elsif array_fields.key?(expr.path.first)
213
253
  { type: :vectorized, info: { source: :array_field_access, path: expr.path } }
214
254
  else
@@ -236,76 +276,106 @@ module Kumi
236
276
  end
237
277
 
238
278
  def analyze_call_vectorization(_name, expr, array_fields, nested_paths, vectorized_values, errors, definitions = nil)
239
- # Check if this is a reduction function using function registry metadata
240
- if Kumi::Registry.reducer?(expr.fn_name)
241
- # Only treat as reduction if the argument is actually vectorized
242
- arg_info = analyze_argument_vectorization(expr.args.first, array_fields, nested_paths, vectorized_values, definitions)
243
- if arg_info[:vectorized]
244
- # Pre-compute which argument indices need flattening
245
- flatten_indices = []
246
- expr.args.each_with_index do |arg, index|
247
- arg_vectorization = analyze_argument_vectorization(arg, array_fields, nested_paths, vectorized_values, definitions)
248
- flatten_indices << index if arg_vectorization[:vectorized]
249
- end
250
-
251
- { type: :reduction, info: {
252
- function: expr.fn_name,
253
- source: arg_info[:source],
254
- argument: expr.args.first,
255
- flatten_argument_indices: flatten_indices
256
- } }
257
- else
258
- # Not a vectorized reduction - just a regular function call
259
- { type: :scalar }
260
- end
279
+ entry = Kumi::Registry.entry(expr.fn_name)
280
+ is_reducer = entry&.reducer
281
+ is_structure = entry&.structure_function
261
282
 
262
- else
283
+ # 1) Analyze all args once
284
+ arg_infos = expr.args.map do |arg|
285
+ analyze_argument_vectorization(arg, array_fields, nested_paths, vectorized_values, definitions)
286
+ end
287
+ vec_idx = arg_infos.each_index.select { |i| arg_infos[i][:vectorized] }
288
+ vec_any = !vec_idx.empty?
263
289
 
264
- # Special case: cascade_and takes individual trait arguments
265
- if expr.fn_name == :cascade_and
266
- # Check if any of the individual arguments are vectorized traits
267
- vectorized_trait = expr.args.find do |arg|
268
- arg.is_a?(Kumi::Syntax::DeclarationReference) && vectorized_values[arg.name]&.[](:vectorized)
269
- end
270
- if vectorized_trait
271
- return { type: :vectorized, info: { source: :cascade_condition_with_vectorized_trait, trait: vectorized_trait.name } }
272
- end
290
+ # 2) Special form: cascade_and (vectorized if any trait arg is vectorized)
291
+ if expr.fn_name == :cascade_and
292
+ vectorized_trait = expr.args.find do |arg|
293
+ arg.is_a?(Kumi::Syntax::DeclarationReference) && vectorized_values[arg.name]&.[](:vectorized)
273
294
  end
274
-
275
- # Analyze arguments to determine function behavior
276
- arg_infos = expr.args.map do |arg|
277
- analyze_argument_vectorization(arg, array_fields, nested_paths, vectorized_values, definitions)
295
+ if vectorized_trait
296
+ return { type: :vectorized,
297
+ info: { source: :cascade_condition_with_vectorized_trait, trait: vectorized_trait&.name } }
278
298
  end
279
299
 
280
- if arg_infos.any? { |info| info[:vectorized] }
281
- # Check for dimension mismatches when multiple arguments are vectorized
282
- vectorized_sources = arg_infos.select { |info| info[:vectorized] }.filter_map { |info| info[:array_source] }.uniq
300
+ return { type: :scalar }
301
+ end
283
302
 
284
- if vectorized_sources.length > 1
285
- # Multiple different array sources - this is a dimension mismatch
286
- # Generate enhanced error message with type information
287
- enhanced_message = build_dimension_mismatch_error(expr, arg_infos, array_fields, vectorized_sources)
303
+ # 3) Reducers: only reduce when the input is actually vectorized
304
+ if is_reducer
305
+ return { type: :scalar } unless vec_any
288
306
 
289
- report_error(errors, enhanced_message, location: expr.loc, type: :semantic)
290
- return { type: :scalar } # Treat as scalar to prevent further errors
291
- end
307
+ # which args were vectorized?
308
+ flatten_indices = vec_idx.dup
309
+ vectorized_arg_index = vec_idx.first
310
+ argument_ast = expr.args[vectorized_arg_index]
311
+
312
+ src_info = arg_infos[vectorized_arg_index]
313
+
314
+ return {
315
+ type: :reduction,
316
+ info: {
317
+ function: expr.fn_name,
318
+ source: src_info[:source],
319
+ argument: argument_ast, # << keep AST of the vectorized argument
320
+ flatten_argument_indices: flatten_indices
321
+ }
322
+ }
323
+ end
292
324
 
293
- # Check if this is a structure function that should work on the array as-is
294
- if structure_function?(expr.fn_name)
295
- # Structure functions like size should work on structure as-is (scalar)
296
- { type: :scalar }
325
+ # 4) Structure (non-reducer) functions like `size`
326
+ if is_structure
327
+ # If any arg is itself a PURE reducer call (e.g., size(sum(x))), the inner collapses first ⇒ outer is scalar
328
+ # But dual-nature functions (both reducer AND structure) should be treated as structure functions when nested
329
+ return { type: :scalar } if expr.args.any? do |a|
330
+ if a.is_a?(Kumi::Syntax::CallExpression)
331
+ arg_entry = Kumi::Registry.entry(a.fn_name)
332
+ arg_entry&.reducer && !arg_entry&.structure_function # Pure reducer only
297
333
  else
298
- # This is a vectorized operation - broadcast over elements
299
- { type: :vectorized, info: {
300
- operation: expr.fn_name,
301
- vectorized_args: arg_infos.map.with_index { |info, i| [i, info[:vectorized]] }.to_h
302
- } }
334
+ false
303
335
  end
304
- else
305
- # No vectorized arguments - regular scalar function
306
- { type: :scalar }
307
336
  end
337
+
338
+ # Structure fn over a vectorized element path ⇒ per-parent vectorization
339
+ return { type: :scalar } unless vec_any
340
+
341
+ src_info = arg_infos[vec_idx.first]
342
+ parent_scope = src_info[:parent_scope] || src_info[:source] # fallback if analyzer encodes parent separately
343
+ return {
344
+ type: :vectorized,
345
+ info: {
346
+ operation: expr.fn_name,
347
+ source: src_info[:source],
348
+ parent_scope: parent_scope,
349
+ vectorized_args: vec_idx.to_h { |i| [i, true] }
350
+ }
351
+ }
352
+
353
+ # Structure fn over a scalar/materialized container ⇒ scalar
354
+
308
355
  end
356
+
357
+ # 5) Generic vectorized map (non-structure, non-reducer)
358
+ if vec_any
359
+ # Dimension / source compatibility check
360
+ sources = vec_idx.map { |i| arg_infos[i][:array_source] }.compact.uniq
361
+ if sources.size > 1
362
+ enhanced_message = build_dimension_mismatch_error(expr, arg_infos, array_fields, sources)
363
+ report_error(errors, enhanced_message, location: expr.loc, type: :semantic)
364
+ return { type: :scalar } # fail safe to prevent cascading errors
365
+ end
366
+
367
+ return {
368
+ type: :vectorized,
369
+ info: {
370
+ operation: expr.fn_name,
371
+ source: arg_infos[vec_idx.first][:source],
372
+ vectorized_args: vec_idx.to_h { |i| [i, true] }
373
+ }
374
+ }
375
+ end
376
+
377
+ # 6) Pure scalar
378
+ { type: :scalar }
309
379
  end
310
380
 
311
381
  def structure_function?(fn_name)
@@ -337,9 +407,32 @@ module Kumi
337
407
  end
338
408
 
339
409
  when Kumi::Syntax::CallExpression
340
- # Recursively check
410
+ # Recursively check nested call
341
411
  result = analyze_value_vectorization(nil, arg, array_fields, nested_paths, vectorized_values, [], definitions)
342
- { vectorized: result[:type] == :vectorized, source: :expression }
412
+ # Handle different result types appropriately
413
+ case result[:type]
414
+ when :reduction
415
+ # Reductions can produce vectors if they preserve some dimensions
416
+ # This aligns with lower_to_ir logic for grouped reductions
417
+ info = result[:info]
418
+ if info && info[:argument]
419
+ # Check if the reduction argument has array scope that would be preserved
420
+ arg_scope = infer_argument_scope(info[:argument], array_fields, nested_paths)
421
+ if arg_scope.length > 1
422
+ # Multi-dimensional reduction - likely preserves outer dimension (per-player)
423
+ { vectorized: true, source: :grouped_reduction, array_source: arg_scope.first }
424
+ else
425
+ # Single dimension or scalar reduction
426
+ { vectorized: false, source: :scalar_from_reduction }
427
+ end
428
+ else
429
+ { vectorized: false, source: :scalar_from_reduction }
430
+ end
431
+ when :vectorized
432
+ { vectorized: true, source: :expression }
433
+ else
434
+ { vectorized: false, source: :scalar }
435
+ end
343
436
 
344
437
  else
345
438
  { vectorized: false }
@@ -26,7 +26,7 @@ module Kumi
26
26
 
27
27
  def run(errors)
28
28
  definitions = get_state(:declarations)
29
- input_meta = get_state(:inputs)
29
+ input_meta = get_state(:input_metadata)
30
30
 
31
31
  dependency_graph = Hash.new { |h, k| h[k] = [] }
32
32
  reverse_dependencies = Hash.new { |h, k| h[k] = [] }
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Core
5
+ module Analyzer
6
+ module Passes
7
+ class InputAccessPlannerPass < PassBase
8
+ def run(errors)
9
+ input_metadata = get_state(:input_metadata)
10
+
11
+ options = {
12
+ on_missing: :error,
13
+ key_policy: :indifferent
14
+ }
15
+
16
+ # TODO : Allow by input definition on policies or at least general policy definition
17
+ plans = Kumi::Core::Compiler::AccessPlanner.plan(input_metadata, options)
18
+
19
+ # Quick validation
20
+ validate_plans!(plans, errors)
21
+
22
+ # Create new state with access plans
23
+ state.with(:access_plans, plans.freeze)
24
+ end
25
+
26
+ private
27
+
28
+ def validate_plans!(plans, errors)
29
+ plans.each do |path, plan_list|
30
+ add_error(errors, nil, "No access plans generated for path: #{path}") if plan_list.nil? || plan_list.empty?
31
+
32
+ plan_list&.each do |plan|
33
+ unless plan[:operations].is_a?(Array)
34
+ add_error(errors, nil, "Invalid operations for path #{path}: expected Array, got #{plan[:operations].class}")
35
+ end
36
+
37
+ unless plan[:mode].is_a?(Symbol)
38
+ add_error(errors, nil, "Invalid mode for path #{path}: expected Symbol, got #{plan[:mode].class}")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -4,135 +4,157 @@ module Kumi
4
4
  module Core
5
5
  module Analyzer
6
6
  module Passes
7
- # RESPONSIBILITY: Collect field metadata from input declarations and validate consistency
8
- # DEPENDENCIES: :definitions
9
- # PRODUCES: :inputs - Hash mapping field names to {type:, domain:} metadata
10
- # INTERFACE: new(schema, state).run(errors)
7
+ # Emits per-node metadata:
8
+ # :type, :domain
9
+ # :container => :scalar | :field | :array
10
+ # :access_mode => :field | :element # how THIS node is read once reached
11
+ # :enter_via => :hash | :array # how we HOP from parent to THIS node
12
+ # :consume_alias => true|false # inline array hop (alias is not a hash key)
13
+ # :children => { name => node_meta } # optional
14
+ #
15
+ # Invariants:
16
+ # - Any nested array (child depth ≥ 1) must declare its element (i.e., have children).
17
+ # - Depth-0 inputs always: enter_via :hash, consume_alias false, access_mode :field.
11
18
  class InputCollector < PassBase
12
19
  def run(errors)
13
20
  input_meta = {}
14
21
 
15
- schema.inputs.each do |field_decl|
16
- unless field_decl.is_a?(Kumi::Syntax::InputDeclaration)
17
- report_error(errors, "Expected InputDeclaration node, got #{field_decl.class}", location: field_decl.loc)
18
- next
19
- end
20
-
21
- name = field_decl.name
22
- existing = input_meta[name]
23
-
24
- if existing
25
- # Check for compatibility and merge
26
- merged_meta = merge_field_metadata(existing, field_decl, errors)
27
- input_meta[name] = merged_meta if merged_meta
28
- else
29
- # New field - collect its metadata
30
- input_meta[name] = collect_field_metadata(field_decl, errors)
31
- end
22
+ schema.inputs.each do |decl|
23
+ name = decl.name
24
+ input_meta[name] = collect_field_metadata(decl, errors, depth: 0, name: name)
32
25
  end
33
26
 
34
- state.with(:inputs, freeze_nested_hash(input_meta))
27
+ input_meta.each_value(&:deep_freeze!)
28
+ state.with(:input_metadata, input_meta.freeze)
35
29
  end
36
30
 
37
31
  private
38
32
 
39
- def collect_field_metadata(field_decl, errors)
40
- validate_domain_type(field_decl, errors) if field_decl.domain
41
-
42
- metadata = {
43
- type: field_decl.type,
44
- domain: field_decl.domain,
45
- access_mode: field_decl.access_mode
46
- }
47
-
48
- # Process children if present
49
- if field_decl.children && !field_decl.children.empty?
50
- children_meta = {}
51
- field_decl.children.each do |child_decl|
52
- unless child_decl.is_a?(Kumi::Syntax::InputDeclaration)
53
- report_error(errors, "Expected InputDeclaration node in children, got #{child_decl.class}", location: child_decl.loc)
54
- next
55
- end
56
- children_meta[child_decl.name] = collect_field_metadata(child_decl, errors)
33
+ # ---------- builders ----------
34
+
35
+ def collect_field_metadata(decl, errors, depth:, name:)
36
+ children = nil
37
+ if decl.children&.any?
38
+ children = {}
39
+ decl.children.each do |child|
40
+ children[child.name] = collect_field_metadata(child, errors, depth: depth + 1, name: child.name)
57
41
  end
58
- metadata[:children] = children_meta
59
42
  end
60
43
 
61
- metadata
44
+ access_mode = decl.access_mode || :field
45
+
46
+ meta = Structs::InputMeta.new(
47
+ type: decl.type,
48
+ domain: decl.domain,
49
+ container: kind_from_type(decl.type),
50
+ access_mode: access_mode,
51
+ enter_via: :hash,
52
+ consume_alias: false,
53
+ children: children
54
+ )
55
+ stamp_edges_from!(meta, errors, parent_depth: depth)
56
+ validate_access_modes!(meta, errors, parent_depth: depth)
57
+ meta
62
58
  end
63
59
 
64
- def merge_field_metadata(existing, field_decl, errors)
65
- name = field_decl.name
66
-
67
- # Check for type compatibility
68
- if existing[:type] != field_decl.type && field_decl.type && existing[:type]
69
- report_error(errors,
70
- "Field :#{name} declared with conflicting types: #{existing[:type]} vs #{field_decl.type}",
71
- location: field_decl.loc)
60
+ # ---------- edge stamping + validation ----------
61
+ #
62
+ # Sets child[:enter_via], child[:consume_alias], child[:access_mode] defaults,
63
+ # and validates nested arrays declare their element.
64
+ #
65
+ # Rules:
66
+ # - Common: any ARRAY child at child-depth ≥ 1 must have children (no bare nested array).
67
+ # - Parent :object → any child:
68
+ # child.enter_via = :hash; child.consume_alias = false; child.access_mode ||= :field
69
+ # - Parent :array:
70
+ # * If exactly one child:
71
+ # - child.container ∈ {:scalar, :array} → via :array, consume_alias true, access_mode :element
72
+ # - child.container == :field → via :hash, consume_alias false, access_mode :field
73
+ # * Else (element object): every child → via :hash, consume_alias false, access_mode :field
74
+ def stamp_edges_from!(parent_meta, errors, parent_depth:)
75
+ kids = parent_meta.children || {}
76
+ return if kids.empty?
77
+
78
+ # Validate nested arrays anywhere below root
79
+ kids.each do |kname, child|
80
+ next unless child.container == :array
81
+
82
+ if !child.children || child.children.empty?
83
+ report_error(errors, "Nested array at :#{kname} must declare its element", location: nil)
84
+ end
72
85
  end
73
86
 
74
- # Check for domain compatibility
75
- if existing[:domain] != field_decl.domain && field_decl.domain && existing[:domain]
76
- report_error(errors,
77
- "Field :#{name} declared with conflicting domains: #{existing[:domain].inspect} vs #{field_decl.domain.inspect}",
78
- location: field_decl.loc)
79
- end
87
+ case parent_meta.container
88
+ when :object, :hash
89
+ kids.each_value do |child|
90
+ child.enter_via = :hash
91
+ child.consume_alias = false
92
+ child.access_mode ||= :field # Only set if not explicitly specified
93
+ end
80
94
 
81
- # Validate domain type if provided
82
- validate_domain_type(field_decl, errors) if field_decl.domain
83
-
84
- # Merge metadata (later declarations override nil values)
85
- merged = {
86
- type: field_decl.type || existing[:type],
87
- domain: field_decl.domain || existing[:domain],
88
- access_mode: field_decl.access_mode || existing[:access_mode]
89
- }
90
-
91
- # Merge children if present
92
- if field_decl.children && !field_decl.children.empty?
93
- existing_children = existing[:children] || {}
94
- new_children = {}
95
-
96
- field_decl.children.each do |child_decl|
97
- unless child_decl.is_a?(Kumi::Syntax::InputDeclaration)
98
- report_error(errors, "Expected InputDeclaration node in children, got #{child_decl.class}", location: child_decl.loc)
99
- next
95
+ when :array
96
+ # Array parents MUST explicitly declare their access mode
97
+ access_mode = parent_meta.access_mode
98
+ raise "Array must explicitly declare access_mode (:field or :element)" unless access_mode
99
+
100
+ case access_mode
101
+ when :field
102
+ # Array of objects: all children are fields accessed via hash
103
+ kids.each_value do |child|
104
+ child.enter_via = :hash
105
+ child.consume_alias = false
106
+ child.access_mode = :field
100
107
  end
101
108
 
102
- child_name = child_decl.name
103
- new_children[child_name] = if existing_children[child_name]
104
- merge_field_metadata(existing_children[child_name], child_decl, errors)
105
- else
106
- collect_field_metadata(child_decl, errors)
107
- end
108
- end
109
+ when :element
110
+ _name, only = kids.first
111
+ only.enter_via = :array
112
+ only.consume_alias = true
113
+ only.access_mode = :element
109
114
 
110
- merged[:children] = new_children
111
- elsif existing[:children]
112
- merged[:children] = existing[:children]
115
+ else
116
+ raise "Invalid access_mode :#{access_mode} for array (must be :field or :element)"
117
+ end
113
118
  end
114
-
115
- merged
116
119
  end
117
120
 
118
- def freeze_nested_hash(hash)
119
- hash.each_value do |value|
120
- freeze_nested_hash(value) if value.is_a?(Hash)
121
- end
122
- hash.freeze
123
- end
121
+ # Enforce access_mode semantics are only used in valid contexts.
122
+ def validate_access_modes!(parent_meta, errors, parent_depth:)
123
+ kids = parent_meta.children || {}
124
+ return if kids.empty?
125
+
126
+ kids.each do |kname, child|
127
+ mode = child.access_mode
128
+ next unless mode
124
129
 
125
- def validate_domain_type(field_decl, errors)
126
- domain = field_decl.domain
127
- return if valid_domain_type?(domain)
130
+ unless %i[field element].include?(mode)
131
+ report_error(errors, "Invalid access_mode for :#{kname}: #{mode.inspect}", location: nil)
132
+ next
133
+ end
128
134
 
129
- report_error(errors,
130
- "Field :#{field_decl.name} has invalid domain constraint: #{domain.inspect}. Domain must be a Range, Array, or Proc",
131
- location: field_decl.loc)
135
+ if mode == :element
136
+ if parent_meta.container == :array
137
+ single = (kids.size == 1)
138
+ unless single && %i[scalar array].include?(child.container)
139
+ report_error(errors, "access_mode :element only valid for single scalar/array element (at :#{kname})", location: nil)
140
+ end
141
+ else
142
+ # Only scalar children under non-array parents are invalid with :element mode
143
+ # Arrays under hash/object parents can have :element mode (for arrays of scalars)
144
+ if child.container == :scalar
145
+ report_error(errors, "access_mode :element only valid under array parent (at :#{kname})", location: nil)
146
+ end
147
+ end
148
+ end
149
+ end
132
150
  end
133
151
 
134
- def valid_domain_type?(domain)
135
- domain.is_a?(Range) || domain.is_a?(Array) || domain.is_a?(Proc)
152
+ def kind_from_type(t)
153
+ return :array if t == :array
154
+ return :hash if t == :hash
155
+ return :object if t == :field
156
+
157
+ :scalar
136
158
  end
137
159
  end
138
160
  end