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
@@ -5,7 +5,7 @@ module Kumi
5
5
  module Analyzer
6
6
  module Passes
7
7
  # RESPONSIBILITY: Detect unsatisfiable constraints and analyze cascade mutual exclusion
8
- # DEPENDENCIES: :declarations from NameIndexer, :inputs from InputCollector
8
+ # DEPENDENCIES: :declarations from NameIndexer, :input_metadata from InputCollector
9
9
  # PRODUCES: :cascades - Hash of cascade mutual exclusion analysis results
10
10
  # INTERFACE: new(schema, state).run(errors)
11
11
  class UnsatDetector < VisitorPass
@@ -16,7 +16,7 @@ module Kumi
16
16
 
17
17
  def run(errors)
18
18
  definitions = get_state(:declarations)
19
- @input_meta = get_state(:inputs) || {}
19
+ @input_meta = get_state(:input_metadata) || {}
20
20
  @definitions = definitions
21
21
  @evaluator = ConstantEvaluator.new(definitions)
22
22
 
@@ -40,12 +40,31 @@ module Kumi
40
40
  atoms = gather_atoms(decl.expression, definitions, Set.new)
41
41
  next if atoms.empty?
42
42
 
43
+ # DEBUG: Add detailed logging for hierarchical broadcasting debugging
44
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
45
+ puts "DEBUG UNSAT: Checking declaration '#{decl.name}' at #{decl.loc}"
46
+ puts " Expression: #{decl.expression.inspect}"
47
+ puts " Gathered atoms: #{atoms.map(&:inspect)}"
48
+ puts " Input meta: #{@input_meta.keys.inspect}" if @input_meta
49
+ end
50
+
43
51
  # Use enhanced solver that can detect cross-variable mathematical constraints
44
- impossible = if definitions && !definitions.empty?
45
- Kumi::Core::ConstraintRelationshipSolver.unsat?(atoms, definitions, input_meta: @input_meta)
46
- else
47
- Kumi::Core::AtomUnsatSolver.unsat?(atoms)
48
- end
52
+ if definitions && !definitions.empty?
53
+ result = Kumi::Core::ConstraintRelationshipSolver.unsat?(atoms, definitions, input_meta: @input_meta)
54
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
55
+ puts " Enhanced solver result: #{result}"
56
+ end
57
+ else
58
+ result = Kumi::Core::AtomUnsatSolver.unsat?(atoms)
59
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
60
+ puts " Basic solver result: #{result}"
61
+ end
62
+ end
63
+ impossible = result
64
+
65
+ if impossible && (ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257"))
66
+ puts " -> FLAGGING AS IMPOSSIBLE: #{decl.name}"
67
+ end
49
68
 
50
69
  report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc) if impossible
51
70
  end
@@ -63,15 +82,24 @@ module Kumi
63
82
  decl.expression.cases[0...-1].each do |when_case|
64
83
  next unless when_case.condition
65
84
 
66
- next unless when_case.condition.fn_name == :all?
85
+ next unless when_case.condition.fn_name == :cascade_and
67
86
 
68
87
  when_case.condition.args.each do |arg|
69
- next unless arg.is_a?(ArrayExpression)
70
-
71
- arg.elements.each do |element|
72
- next unless element.is_a?(DeclarationReference)
73
-
74
- trait_name = element.name
88
+ if arg.is_a?(ArrayExpression)
89
+ # Handle array elements (for array broadcasting)
90
+ arg.elements.each do |element|
91
+ next unless element.is_a?(DeclarationReference)
92
+
93
+ trait_name = element.name
94
+ trait = definitions[trait_name]
95
+ if trait
96
+ conditions << trait.expression
97
+ condition_traits << trait_name
98
+ end
99
+ end
100
+ elsif arg.is_a?(DeclarationReference)
101
+ # Handle direct trait references (simple case)
102
+ trait_name = arg.name
75
103
  trait = definitions[trait_name]
76
104
  if trait
77
105
  conditions << trait.expression
@@ -183,8 +211,8 @@ module Kumi
183
211
  # We should NOT add OR children to the stack as they would be treated as AND
184
212
  # OR expressions need separate analysis in the main run() method
185
213
  next
186
- elsif current.is_a?(CallExpression) && current.fn_name == :all?
187
- # For all? function, add all trait arguments to the stack
214
+ elsif current.is_a?(CallExpression) && current.fn_name == :cascade_and
215
+ # cascade_and takes individual arguments (not wrapped in array)
188
216
  current.args.each { |arg| stack << arg }
189
217
  elsif current.is_a?(ArrayExpression)
190
218
  # For ArrayExpression, add all elements to the stack
@@ -212,7 +240,18 @@ module Kumi
212
240
  # This is the correct behavior: each 'on' condition should be checked separately
213
241
  # since only ONE will be evaluated at runtime (they're mutually exclusive by design)
214
242
 
215
- decl.expression.cases.each_with_index do |when_case, _index|
243
+ # DEBUG: Add detailed logging for hierarchical broadcasting debugging
244
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
245
+ puts "DEBUG UNSAT CASCADE: Checking cascade '#{decl.name}' at #{decl.loc}"
246
+ puts " Total cases: #{decl.expression.cases.length}"
247
+ end
248
+
249
+ decl.expression.cases.each_with_index do |when_case, index|
250
+ # DEBUG: Log each case
251
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
252
+ puts " Case #{index}: condition=#{when_case.condition.inspect}"
253
+ end
254
+
216
255
  # Skip the base case (it's typically a literal true condition)
217
256
  next if when_case.condition.is_a?(Literal) && when_case.condition.value == true
218
257
 
@@ -220,52 +259,51 @@ module Kumi
220
259
  next if when_case.condition.is_a?(CallExpression) && %i[any? none?].include?(when_case.condition.fn_name)
221
260
 
222
261
  # Skip single-trait 'on' branches: trait-level unsat detection covers these
223
- if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
224
- # Handle both ArrayExpression (old format) and multiple args (new format)
225
- if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ArrayExpression)
226
- list = when_case.condition.args.first
227
- next if list.elements.size == 1
228
- elsif when_case.condition.args.size == 1
229
- # Multiple args format
230
- next
231
- end
262
+ if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :cascade_and && (when_case.condition.args.size == 1)
263
+ # cascade_and uses individual arguments - skip if only one trait
264
+ next
232
265
  end
266
+
233
267
  # Gather atoms from this individual condition only
234
268
  condition_atoms = gather_atoms(when_case.condition, definitions, Set.new, [])
235
- # DEBUG
236
- # if when_case.condition.is_a?(CallExpression) && [:all?, :any?, :none?].include?(when_case.condition.fn_name)
237
- # puts " Args: #{when_case.condition.args.inspect}"
238
- # puts " Atoms found: #{condition_atoms.inspect}"
239
- # end
240
269
 
241
- # Only flag if this individual condition is impossible
242
- # if !condition_atoms.empty?
243
- # is_unsat = Kumi::Core::AtomUnsatSolver.unsat?(condition_atoms)
244
- # puts " Is unsat? #{is_unsat}"
245
- # end
270
+ # DEBUG: Add detailed logging for hierarchical broadcasting debugging
271
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
272
+ puts " Condition atoms: #{condition_atoms.map(&:inspect)}"
273
+ end
274
+
246
275
  # Use enhanced solver for cascade conditions too
247
- impossible = if definitions && !definitions.empty?
248
- Kumi::Core::ConstraintRelationshipSolver.unsat?(condition_atoms, definitions, input_meta: @input_meta)
249
- else
250
- Kumi::Core::AtomUnsatSolver.unsat?(condition_atoms)
251
- end
276
+ if definitions && !definitions.empty?
277
+ result = Kumi::Core::ConstraintRelationshipSolver.unsat?(condition_atoms, definitions, input_meta: @input_meta)
278
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
279
+ puts " Enhanced solver result: #{result}"
280
+ end
281
+ else
282
+ result = Kumi::Core::AtomUnsatSolver.unsat?(condition_atoms)
283
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
284
+ puts " Basic solver result: #{result}"
285
+ end
286
+ end
287
+ impossible = result
252
288
  next unless !condition_atoms.empty? && impossible
253
289
 
254
290
  # For multi-trait on-clauses, report the trait names rather than the value name
255
- if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
256
- # Handle both ArrayExpression (old format) and multiple args (new format)
257
- trait_bindings = if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ArrayExpression)
258
- when_case.condition.args.first.elements
259
- else
260
- when_case.condition.args
261
- end
291
+ if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :cascade_and
292
+ # cascade_and uses individual arguments
293
+ trait_bindings = when_case.condition.args
262
294
 
263
295
  if trait_bindings.all?(DeclarationReference)
264
296
  traits = trait_bindings.map(&:name).join(" AND ")
297
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
298
+ puts " -> FLAGGING AS IMPOSSIBLE CASCADE CONDITION: #{traits}"
299
+ end
265
300
  report_error(errors, "conjunction `#{traits}` is impossible", location: decl.loc)
266
301
  next
267
302
  end
268
303
  end
304
+ if ENV["DEBUG_UNSAT"] || decl.loc&.to_s&.include?("hierarchical_broadcasting_spec.rb:257")
305
+ puts " -> FLAGGING AS IMPOSSIBLE CASCADE: #{decl.name}"
306
+ end
269
307
  report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc)
270
308
  end
271
309
  end
@@ -275,6 +313,12 @@ module Kumi
275
313
  when InputReference, DeclarationReference
276
314
  val = @evaluator.evaluate(node)
277
315
  val == :unknown ? node.name : val
316
+ when InputElementReference
317
+ # For hierarchical paths like input.companies.regions.offices.teams.department,
318
+ # create a unique identifier that represents the specific path
319
+ # This prevents false positives where different paths are treated as the same :unknown
320
+ path_identifier = node.path.join(".").to_s
321
+ path_identifier.to_sym
278
322
  when Literal
279
323
  node.value
280
324
  else
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Core
5
+ module Analyzer
6
+ # Typed plan structures for HIR (High-level Intermediate Representation)
7
+ # These plans are produced by analyzer passes and consumed by LowerToIRPass
8
+ # to generate LIR (Low-level IR) operations.
9
+ module Plans
10
+ # Scope plan: defines the dimensional execution context for a declaration
11
+ Scope = Struct.new(:scope, :lifts, :join_hint, :arg_shapes, keyword_init: true) do
12
+ def initialize(scope: [], lifts: [], join_hint: nil, arg_shapes: {})
13
+ super
14
+ freeze
15
+ end
16
+
17
+ def depth
18
+ scope.size
19
+ end
20
+
21
+ def scalar?
22
+ scope.empty?
23
+ end
24
+ end
25
+
26
+ # Join plan: defines how to align multiple arguments at a target scope
27
+ Join = Struct.new(:policy, :target_scope, keyword_init: true) do
28
+ def initialize(policy: :zip, target_scope: [])
29
+ super
30
+ freeze
31
+ end
32
+ end
33
+
34
+ # Reduce plan: defines how to reduce dimensions in array operations
35
+ Reduce = Struct.new(:function, :axis, :source_scope, :result_scope, :flatten_args, keyword_init: true) do
36
+ def initialize(function:, axis: [], source_scope: [], result_scope: [], flatten_args: [])
37
+ super
38
+ freeze
39
+ end
40
+
41
+ def total_reduction?
42
+ axis == :all || result_scope.empty?
43
+ end
44
+
45
+ def partial_reduction?
46
+ !total_reduction?
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Core
5
+ module Analyzer
6
+ # One plan for a specific path and mode (path:mode)
7
+ AccessPlan = Struct.new(:path, :containers, :leaf, :scope, :depth, :mode,
8
+ :on_missing, :key_policy, :operations, keyword_init: true) do
9
+ def initialize(path:, containers:, leaf:, scope:, depth:, mode:, on_missing:, key_policy:, operations:)
10
+ super
11
+ freeze
12
+ end
13
+
14
+ def accessor_key = "#{path}:#{mode}"
15
+ def ndims = depth
16
+ def scalar? = depth.zero?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Core
5
+ module Analyzer
6
+ module Structs
7
+ # Represents metadata for a single input field produced by InputCollector
8
+ InputMeta = Struct.new(
9
+ :type,
10
+ :domain,
11
+ :container,
12
+ :access_mode,
13
+ :enter_via,
14
+ :consume_alias,
15
+ :children,
16
+ keyword_init: true
17
+ ) do
18
+ def deep_freeze!
19
+ if children
20
+ children.each_value(&:deep_freeze!)
21
+ children.freeze
22
+ end
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ module Kumi
2
+ module Core
3
+ module Compiler
4
+ class AccessBuilder
5
+ def self.build(plans)
6
+ accessors = {}
7
+ plans.each_value do |variants|
8
+ variants.each do |plan|
9
+ key = plan.respond_to?(:accessor_key) ? plan.accessor_key : "#{plan.path}:#{mode}"
10
+ accessors[key] = build_proc_for(
11
+ mode: plan.mode,
12
+ path_key: plan.path,
13
+ missing: (plan.on_missing || :error).to_sym,
14
+ key_policy: (plan.key_policy || :indifferent).to_sym,
15
+ operations: plan.operations
16
+ )
17
+ end
18
+ end
19
+ accessors.freeze
20
+ end
21
+
22
+ def self.build_proc_for(mode:, path_key:, missing:, key_policy:, operations:)
23
+ case mode
24
+ when :read then Accessors::ReadAccessor.build(operations, path_key, missing, key_policy)
25
+ when :materialize then Accessors::MaterializeAccessor.build(operations, path_key, missing, key_policy)
26
+ when :ravel then Accessors::RavelAccessor.build(operations, path_key, missing, key_policy)
27
+ when :each_indexed then Accessors::EachIndexedAccessor.build(operations, path_key, missing, key_policy, true)
28
+ when :each then Accessors::EachAccessor.build(operations, path_key, missing, key_policy)
29
+ else
30
+ raise "Unknown accessor mode: #{mode.inspect}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer/structs/input_meta"
4
+ require_relative "../analyzer/structs/access_plan"
5
+
6
+ module Kumi
7
+ module Core
8
+ module Compiler
9
+ # Generates deterministic access plans from normalized input metadata.
10
+ #
11
+ # Metadata expectations (produced by InputCollector):
12
+ # - Each node has:
13
+ # :container => :scalar | :read | :array
14
+ # :children => { name => meta } (optional)
15
+ # - Each non-root node (i.e., any child) carries edge hints from its parent:
16
+ # :enter_via => :field | :array # how the parent reaches THIS node
17
+ # :consume_alias => true|false # inline array edge; planner does not need this to emit ops
18
+ #
19
+ # Planning rules (single source of truth):
20
+ # - Root is an implicit object.
21
+ # - If parent is :array, always emit :enter_array before stepping to the child.
22
+ # - If child.enter_via == :field → also emit :enter_hash(child_name).
23
+ # - If child.enter_via == :array → inline edge, do NOT emit :enter_hash for the alias.
24
+ # - If parent is :read (or root), emit :enter_hash(child_name).
25
+ #
26
+ # Modes (one plan per mode):
27
+ # - Scalar paths (no array in lineage) → [:read]
28
+ # - Vector paths (≥1 array in lineage) → [:each_indexed, :materialize, :ravel]
29
+ # - If @defaults[:mode] is set, emit only that mode (alias :read → :read).
30
+ class AccessPlanner
31
+ def self.plan(meta, options = {}) = new(meta, options).plan
32
+ def self.plan_for(meta, path, options = {}) = new(meta, options).plan_for(path)
33
+
34
+ def initialize(meta, options = {})
35
+ @meta = meta
36
+ @defaults = { on_missing: :error, key_policy: :indifferent, mode: nil }.merge(options)
37
+ @plans = {}
38
+ end
39
+
40
+ def plan
41
+ @meta.each_key { |root| walk_and_emit([root.to_s]) }
42
+ @plans
43
+ end
44
+
45
+ def plan_for(path)
46
+ segs = path.split(".")
47
+ ensure_path!(segs)
48
+ emit_for_segments(segs, explicit_mode: @defaults[:mode])
49
+ @plans
50
+ end
51
+
52
+ private
53
+
54
+ def walk_and_emit(path)
55
+ emit_for_segments(path)
56
+ node = meta_node_for(path)
57
+ return if node[:children].nil?
58
+
59
+ node[:children].each_key do |c|
60
+ walk_and_emit(path + [c.to_s])
61
+ end
62
+ end
63
+
64
+ def emit_for_segments(path, explicit_mode: nil)
65
+ lineage = container_lineage(path)
66
+ base = build_base_plan(path, lineage)
67
+ node = meta_node_for(path)
68
+
69
+ modes = explicit_mode || infer_modes(lineage, node)
70
+ modes = [modes] unless modes.is_a?(Array)
71
+
72
+ list = (@plans[base[:path]] ||= [])
73
+ modes.each do |mode|
74
+ operations = build_operations(path, mode)
75
+
76
+ list << Kumi::Core::Analyzer::AccessPlan.new(
77
+ path: base[:path],
78
+ containers: base[:containers],
79
+ leaf: base[:leaf],
80
+ scope: base[:scope],
81
+ depth: base[:depth],
82
+ mode: mode, # :read | :each_indexed | :materialize | :ravel
83
+ on_missing: base[:on_missing],
84
+ key_policy: base[:key_policy],
85
+ operations: operations
86
+ )
87
+ end
88
+ end
89
+
90
+ def build_base_plan(path, lineage)
91
+ {
92
+ path: path.join("."),
93
+ containers: lineage, # symbols of array segments in the path
94
+ leaf: path.last.to_sym,
95
+ scope: lineage.dup, # alias kept for analyzer symmetry
96
+ depth: lineage.length, # rank
97
+ on_missing: @defaults[:on_missing],
98
+ key_policy: @defaults[:key_policy]
99
+
100
+ }.freeze
101
+ end
102
+
103
+ def infer_modes(lineage, _node)
104
+ lineage.empty? ? [:read] : %i[each_indexed materialize ravel]
105
+ end
106
+
107
+ # Core op builder: apply the parent→child edge rule per segment.
108
+ def build_operations(path, mode)
109
+ ops = []
110
+ parent_meta = nil
111
+ cur = @meta
112
+
113
+ puts "\n🔨 Building operations for path: #{path.join('.')}:#{mode}" if ENV["DEBUG_ACCESSOR_OPS"]
114
+
115
+ path.each_with_index do |seg, idx|
116
+ node = ig(cur, seg) or raise ArgumentError, "Unknown segment '#{seg}' in '#{path.join('.')}'"
117
+
118
+ puts " Segment #{idx}: '#{seg}'" if ENV["DEBUG_ACCESSOR_OPS"]
119
+
120
+ # Validate required fields before using them
121
+ container = parent_meta&.[](:container)
122
+ enter_via = if is_root_segment?(idx)
123
+ nil
124
+ else
125
+ node[:enter_via] do
126
+ raise ArgumentError,
127
+ "Missing :enter_via for non-root segment '#{seg}' at '#{path.join('.')}'. Contract violation."
128
+ end
129
+ end
130
+
131
+ if container == :array
132
+ # Array parent: always step into elements first
133
+ ops << enter_array
134
+ puts " Added: enter_array" if ENV["DEBUG_ACCESSOR_OPS"]
135
+
136
+ # Then either inline (no hash) or field hop to named member
137
+ if enter_via == :hash
138
+ ops << enter_hash(seg)
139
+ puts " Added: enter_hash('#{seg}')" if ENV["DEBUG_ACCESSOR_OPS"]
140
+ elsif enter_via == :array
141
+ # Inline alias, no hash operation needed
142
+ puts " Skipped enter_hash (inline alias)" if ENV["DEBUG_ACCESSOR_OPS"]
143
+ else
144
+ raise ArgumentError, "Invalid :enter_via '#{enter_via}' for array child '#{seg}'. Must be :hash or :array"
145
+ end
146
+ elsif container.nil? || container == :object
147
+ # Root or object parent - always emit enter_hash
148
+ ops << enter_hash(seg)
149
+ puts " Added: enter_hash('#{seg}')" if ENV["DEBUG_ACCESSOR_OPS"]
150
+ else
151
+ raise ArgumentError, "Invalid parent :container '#{container}' for segment '#{seg}'. Expected :array, :object, or nil (root)"
152
+ end
153
+
154
+ parent_meta = node
155
+ cur = node[:children] || {}
156
+ end
157
+
158
+ terminal = parent_meta
159
+
160
+ if terminal && terminal[:container] == :array && %i[each_indexed ravel].include?(mode)
161
+ ops << enter_array
162
+ # :materialize and :read do not step into elements
163
+ end
164
+
165
+ # # If we land on an array and this mode iterates elements, step into it.
166
+ puts " Final operations: #{ops.inspect}" if ENV["DEBUG_ACCESSOR_OPS"]
167
+
168
+ ops
169
+ end
170
+
171
+ def container_lineage(path)
172
+ lineage = []
173
+ cur = @meta
174
+ path.each do |seg|
175
+ m = ig(cur, seg)
176
+ container = m[:container] do
177
+ raise ArgumentError, "Missing :container for '#{seg}' in path '#{path.join('.')}'. Contract violation."
178
+ end
179
+ lineage << seg.to_sym if container == :array
180
+ cur = m[:children] || {}
181
+ end
182
+ lineage
183
+ end
184
+
185
+ def meta_node_for(path)
186
+ cur = @meta
187
+ last = nil
188
+ path.each do |seg|
189
+ m = ig(cur, seg)
190
+ last = m
191
+ cur = m[:children] || {}
192
+ end
193
+ last
194
+ end
195
+
196
+ def ensure_path!(path)
197
+ raise ArgumentError, "Unknown path: #{path.join('.')}" unless meta_node_for(path)
198
+ end
199
+
200
+ def ig(h, k)
201
+ h[k.to_sym] or raise ArgumentError, "Missing required field '#{k}' in metadata. Available keys: #{h.keys.inspect}"
202
+ end
203
+
204
+ def enter_hash(key)
205
+ { type: :enter_hash, key: key.to_s,
206
+ on_missing: @defaults[:on_missing], key_policy: @defaults[:key_policy] }
207
+ end
208
+
209
+ def enter_array
210
+ { type: :enter_array, on_missing: @defaults[:on_missing] }
211
+ end
212
+
213
+ def is_root_segment?(idx)
214
+ idx == 0
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Core
5
+ module Compiler
6
+ module Accessors
7
+ module Base
8
+ MISSING = :__missing__
9
+
10
+ # -------- assertions --------
11
+ def assert_hash!(node, path_key, mode)
12
+ raise TypeError, "Expected Hash at '#{path_key}' (#{mode})" unless node.is_a?(Hash)
13
+ end
14
+
15
+ def assert_array!(node, path_key, mode)
16
+ return if node.is_a?(Array)
17
+
18
+ warn_mismatch(node, path_key) if ENV["DEBUG_ACCESS_BUILDER"]
19
+ raise TypeError, "Expected Array at '#{path_key}' (#{mode}); got #{node.class}"
20
+ end
21
+
22
+ def warn_mismatch(node, path_key)
23
+ puts "DEBUG AccessBuilder error at #{path_key}: got #{node.class}, value=#{node.inspect}"
24
+ end
25
+
26
+ # -------- key fetch with policy --------
27
+ def fetch_key(hash, key, policy)
28
+ case policy
29
+ when :indifferent
30
+ return hash[key] if hash.key?(key)
31
+ return hash[key.to_sym] if hash.key?(key.to_sym)
32
+ return hash[key.to_s] if hash.key?(key.to_s)
33
+
34
+ MISSING
35
+ when :string
36
+ hash.key?(key.to_s) ? hash[key.to_s] : MISSING
37
+ when :symbol
38
+ hash.key?(key.to_sym) ? hash[key.to_sym] : MISSING
39
+ else
40
+ hash.key?(key) ? hash[key] : MISSING
41
+ end
42
+ end
43
+
44
+ # -------- op helpers --------
45
+ def next_enters_array?(operations, pc)
46
+ nxt = operations[pc + 1]
47
+ nxt && nxt[:type] == :enter_array
48
+ end
49
+
50
+ def missing_key_action(policy)
51
+ if policy == :nil
52
+ :yield_nil
53
+ else
54
+ (policy == :skip ? :skip : :raise)
55
+ end
56
+ end
57
+
58
+ def missing_array_action(policy)
59
+ if policy == :nil
60
+ :yield_nil
61
+ else
62
+ (policy == :skip ? :skip : :raise)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end