kumi 0.0.3 → 0.0.5

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +109 -2
  3. data/README.md +174 -205
  4. data/documents/DSL.md +3 -3
  5. data/documents/SYNTAX.md +17 -26
  6. data/examples/federal_tax_calculator_2024.rb +36 -38
  7. data/examples/game_of_life.rb +97 -0
  8. data/examples/simple_rpg_game.rb +1000 -0
  9. data/examples/static_analysis_errors.rb +178 -0
  10. data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +1 -1
  11. data/lib/kumi/analyzer/analysis_state.rb +37 -0
  12. data/lib/kumi/analyzer/constant_evaluator.rb +22 -16
  13. data/lib/kumi/analyzer/passes/definition_validator.rb +4 -3
  14. data/lib/kumi/analyzer/passes/dependency_resolver.rb +50 -10
  15. data/lib/kumi/analyzer/passes/input_collector.rb +28 -7
  16. data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
  17. data/lib/kumi/analyzer/passes/pass_base.rb +10 -27
  18. data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
  19. data/lib/kumi/analyzer/passes/toposorter.rb +3 -3
  20. data/lib/kumi/analyzer/passes/type_checker.rb +2 -1
  21. data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
  22. data/lib/kumi/analyzer/passes/type_inferencer.rb +2 -4
  23. data/lib/kumi/analyzer/passes/unsat_detector.rb +233 -14
  24. data/lib/kumi/analyzer/passes/visitor_pass.rb +2 -1
  25. data/lib/kumi/analyzer.rb +42 -24
  26. data/lib/kumi/atom_unsat_solver.rb +45 -0
  27. data/lib/kumi/cli.rb +449 -0
  28. data/lib/kumi/constraint_relationship_solver.rb +638 -0
  29. data/lib/kumi/error_reporter.rb +6 -6
  30. data/lib/kumi/evaluation_wrapper.rb +22 -4
  31. data/lib/kumi/explain.rb +9 -10
  32. data/lib/kumi/function_registry/collection_functions.rb +103 -0
  33. data/lib/kumi/function_registry/string_functions.rb +1 -1
  34. data/lib/kumi/parser/dsl_cascade_builder.rb +17 -6
  35. data/lib/kumi/parser/expression_converter.rb +80 -12
  36. data/lib/kumi/parser/guard_rails.rb +2 -2
  37. data/lib/kumi/parser/parser.rb +2 -0
  38. data/lib/kumi/parser/schema_builder.rb +1 -1
  39. data/lib/kumi/parser/sugar.rb +117 -16
  40. data/lib/kumi/schema.rb +3 -1
  41. data/lib/kumi/schema_instance.rb +69 -3
  42. data/lib/kumi/syntax/declarations.rb +3 -0
  43. data/lib/kumi/syntax/expressions.rb +4 -0
  44. data/lib/kumi/syntax/root.rb +1 -0
  45. data/lib/kumi/syntax/terminal_expressions.rb +3 -0
  46. data/lib/kumi/types/compatibility.rb +8 -0
  47. data/lib/kumi/types/validator.rb +1 -1
  48. data/lib/kumi/version.rb +1 -1
  49. data/scripts/generate_function_docs.rb +22 -10
  50. metadata +10 -6
  51. data/CHANGELOG.md +0 -25
  52. data/test_impossible_cascade.rb +0 -51
@@ -13,7 +13,7 @@ module Kumi
13
13
  definitions = get_state(:definitions, required: false) || {}
14
14
 
15
15
  order = compute_topological_order(dependency_graph, definitions, errors)
16
- set_state(:topo_order, order)
16
+ state.with(:topo_order, order)
17
17
  end
18
18
 
19
19
  private
@@ -47,7 +47,7 @@ module Kumi
47
47
  # (i.e., declarations with no dependencies)
48
48
  definitions.each_key { |node| visit_node.call(node) }
49
49
 
50
- order
50
+ order.freeze
51
51
  end
52
52
 
53
53
  def report_unexpected_cycle(temp_marks, current_node, errors)
@@ -57,7 +57,7 @@ module Kumi
57
57
  first_decl = find_declaration_by_name(temp_marks.first || current_node)
58
58
  location = first_decl&.loc
59
59
 
60
- add_error(errors, location, "cycle detected: #{cycle_path}")
60
+ report_error(errors, "cycle detected: #{cycle_path}", location: location)
61
61
  end
62
62
 
63
63
  def find_declaration_by_name(name)
@@ -4,7 +4,7 @@ module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
6
  # RESPONSIBILITY: Validate function call arity and argument types against FunctionRegistry
7
- # DEPENDENCIES: None (can run independently)
7
+ # DEPENDENCIES: :decl_types from TypeInferencer
8
8
  # PRODUCES: None (validation only)
9
9
  # INTERFACE: new(schema, state).run(errors)
10
10
  class TypeChecker < VisitorPass
@@ -12,6 +12,7 @@ module Kumi
12
12
  visit_nodes_of_type(Expressions::CallExpression, errors: errors) do |node, _decl, errs|
13
13
  validate_function_call(node, errs)
14
14
  end
15
+ state
15
16
  end
16
17
 
17
18
  private
@@ -16,6 +16,7 @@ module Kumi
16
16
 
17
17
  # Then check basic consistency (placeholder for now)
18
18
  # In a full implementation, this would do sophisticated usage analysis
19
+ state
19
20
  end
20
21
 
21
22
  private
@@ -30,7 +31,7 @@ module Kumi
30
31
  field_decl = find_input_field_declaration(field_name)
31
32
  location = field_decl&.loc
32
33
 
33
- add_error(errors, location, "Invalid type declaration for field :#{field_name}: #{declared_type.inspect}")
34
+ report_type_error(errors, "Invalid type declaration for field :#{field_name}: #{declared_type.inspect}", location: location)
34
35
  end
35
36
  end
36
37
 
@@ -9,8 +9,6 @@ module Kumi
9
9
  # INTERFACE: new(schema, state).run(errors)
10
10
  class TypeInferencer < PassBase
11
11
  def run(errors)
12
- return if state[:decl_types] # Already run
13
-
14
12
  types = {}
15
13
  topo_order = get_state(:topo_order)
16
14
  definitions = get_state(:definitions)
@@ -24,11 +22,11 @@ module Kumi
24
22
  inferred_type = infer_expression_type(decl.expression, types)
25
23
  types[name] = inferred_type
26
24
  rescue StandardError => e
27
- add_error(errors, decl&.loc, "Type inference failed: #{e.message}")
25
+ report_type_error(errors, "Type inference failed: #{e.message}", location: decl&.loc)
28
26
  end
29
27
  end
30
28
 
31
- set_state(:decl_types, types)
29
+ state.with(:decl_types, types)
32
30
  end
33
31
 
34
32
  private
@@ -11,6 +11,8 @@ module Kumi
11
11
 
12
12
  def run(errors)
13
13
  definitions = get_state(:definitions)
14
+ @input_meta = get_state(:input_meta) || {}
15
+ @definitions = definitions
14
16
  @evaluator = ConstantEvaluator.new(definitions)
15
17
 
16
18
  each_decl do |decl|
@@ -18,17 +20,64 @@ module Kumi
18
20
  # Special handling for cascade expressions
19
21
  check_cascade_expression(decl, definitions, errors)
20
22
  else
21
- # Normal handling for non-cascade expressions
22
- atoms = gather_atoms(decl.expression, definitions, Set.new)
23
- next if atoms.empty?
23
+ # Check for OR expressions which need special disjunctive handling
24
+ if decl.expression.is_a?(CallExpression) && decl.expression.fn_name == :or
25
+ impossible = check_or_expression(decl.expression, definitions, errors)
26
+ report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc) if impossible
27
+ else
28
+ # Normal handling for non-cascade expressions
29
+ atoms = gather_atoms(decl.expression, definitions, Set.new)
30
+ next if atoms.empty?
24
31
 
25
- add_error(errors, decl.loc, "conjunction `#{decl.name}` is impossible") if Kumi::AtomUnsatSolver.unsat?(atoms)
32
+ # Use enhanced solver that can detect cross-variable mathematical constraints
33
+ impossible = if definitions && !definitions.empty?
34
+ Kumi::ConstraintRelationshipSolver.unsat?(atoms, definitions, input_meta: @input_meta)
35
+ else
36
+ Kumi::AtomUnsatSolver.unsat?(atoms)
37
+ end
38
+
39
+ report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc) if impossible
40
+ end
26
41
  end
27
42
  end
43
+ state
28
44
  end
29
45
 
30
46
  private
31
47
 
48
+ def check_or_expression(or_expr, definitions, errors)
49
+ # For OR expressions: A | B is impossible only if BOTH A AND B are impossible
50
+ # If either side is satisfiable, the OR is satisfiable
51
+ left_side, right_side = or_expr.args
52
+
53
+ # Check if left side is impossible
54
+ left_atoms = gather_atoms(left_side, definitions, Set.new)
55
+ left_impossible = if !left_atoms.empty?
56
+ if definitions && !definitions.empty?
57
+ Kumi::ConstraintRelationshipSolver.unsat?(left_atoms, definitions, input_meta: @input_meta)
58
+ else
59
+ Kumi::AtomUnsatSolver.unsat?(left_atoms)
60
+ end
61
+ else
62
+ false
63
+ end
64
+
65
+ # Check if right side is impossible
66
+ right_atoms = gather_atoms(right_side, definitions, Set.new)
67
+ right_impossible = if !right_atoms.empty?
68
+ if definitions && !definitions.empty?
69
+ Kumi::ConstraintRelationshipSolver.unsat?(right_atoms, definitions, input_meta: @input_meta)
70
+ else
71
+ Kumi::AtomUnsatSolver.unsat?(right_atoms)
72
+ end
73
+ else
74
+ false
75
+ end
76
+
77
+ # OR is impossible only if BOTH sides are impossible
78
+ left_impossible && right_impossible
79
+ end
80
+
32
81
  def gather_atoms(node, defs, visited, list = [])
33
82
  return list unless node
34
83
 
@@ -41,7 +90,25 @@ module Kumi
41
90
 
42
91
  if current.is_a?(CallExpression) && COMPARATORS.include?(current.fn_name)
43
92
  lhs, rhs = current.args
44
- list << Atom.new(current.fn_name, term(lhs, defs), term(rhs, defs))
93
+
94
+ # Check for domain constraint violations before creating atom
95
+ list << if impossible_constraint?(lhs, rhs, current.fn_name)
96
+ # Create a special impossible atom that will always trigger unsat
97
+ Atom.new(:==, :__impossible__, true)
98
+ else
99
+ Atom.new(current.fn_name, term(lhs, defs), term(rhs, defs))
100
+ end
101
+ elsif current.is_a?(CallExpression) && current.fn_name == :or
102
+ # Special handling for OR expressions - they are disjunctive, not conjunctive
103
+ # We should NOT add OR children to the stack as they would be treated as AND
104
+ # OR expressions need separate analysis in the main run() method
105
+ next
106
+ elsif current.is_a?(CallExpression) && current.fn_name == :all?
107
+ # For all? function, add all trait arguments to the stack
108
+ current.args.each { |arg| stack << arg }
109
+ elsif current.is_a?(ListExpression)
110
+ # For ListExpression, add all elements to the stack
111
+ current.elements.each { |elem| stack << elem }
45
112
  elsif current.is_a?(Binding)
46
113
  name = current.name
47
114
  unless visited.include?(name)
@@ -51,7 +118,10 @@ module Kumi
51
118
  end
52
119
 
53
120
  # Add children to stack for processing
54
- current.children.each { |child| stack << child } if current.respond_to?(:children)
121
+ # IMPORTANT: Skip CascadeExpression children to avoid false positives
122
+ # Cascades are handled separately by check_cascade_expression() and are disjunctive,
123
+ # but gather_atoms() treats all collected atoms as conjunctive
124
+ current.children.each { |child| stack << child } if current.respond_to?(:children) && !current.is_a?(CascadeExpression)
55
125
  end
56
126
 
57
127
  list
@@ -66,27 +136,58 @@ module Kumi
66
136
  # Skip the base case (it's typically a literal true condition)
67
137
  next if when_case.condition.is_a?(Literal) && when_case.condition.value == true
68
138
 
139
+ # Skip non-conjunctive conditions (any?, none?) as they are disjunctive
140
+ next if when_case.condition.is_a?(CallExpression) && %i[any? none?].include?(when_case.condition.fn_name)
141
+
69
142
  # Skip single-trait 'on' branches: trait-level unsat detection covers these
70
143
  if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
71
- list = when_case.condition.args.first
72
- next if list.is_a?(ListExpression) && list.elements.size < 2
144
+ # Handle both ListExpression (old format) and multiple args (new format)
145
+ if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ListExpression)
146
+ list = when_case.condition.args.first
147
+ next if list.elements.size == 1
148
+ elsif when_case.condition.args.size == 1
149
+ # Multiple args format
150
+ next
151
+ end
73
152
  end
74
153
  # Gather atoms from this individual condition only
75
154
  condition_atoms = gather_atoms(when_case.condition, definitions, Set.new, [])
155
+ # DEBUG
156
+ # if when_case.condition.is_a?(CallExpression) && [:all?, :any?, :none?].include?(when_case.condition.fn_name)
157
+ # puts "DEBUG: Processing #{when_case.condition.fn_name} condition"
158
+ # puts " Args: #{when_case.condition.args.inspect}"
159
+ # puts " Atoms found: #{condition_atoms.inspect}"
160
+ # end
76
161
 
77
162
  # Only flag if this individual condition is impossible
78
- next unless !condition_atoms.empty? && Kumi::AtomUnsatSolver.unsat?(condition_atoms)
163
+ # if !condition_atoms.empty?
164
+ # is_unsat = Kumi::AtomUnsatSolver.unsat?(condition_atoms)
165
+ # puts " Is unsat? #{is_unsat}"
166
+ # end
167
+ # Use enhanced solver for cascade conditions too
168
+ impossible = if definitions && !definitions.empty?
169
+ Kumi::ConstraintRelationshipSolver.unsat?(condition_atoms, definitions, input_meta: @input_meta)
170
+ else
171
+ Kumi::AtomUnsatSolver.unsat?(condition_atoms)
172
+ end
173
+ next unless !condition_atoms.empty? && impossible
79
174
 
80
175
  # For multi-trait on-clauses, report the trait names rather than the value name
81
176
  if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
82
- list = when_case.condition.args.first
83
- if list.is_a?(ListExpression) && list.elements.all?(Binding)
84
- traits = list.elements.map(&:name).join(" AND ")
85
- add_error(errors, decl.loc, "conjunction `#{traits}` is impossible")
177
+ # Handle both ListExpression (old format) and multiple args (new format)
178
+ trait_bindings = if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ListExpression)
179
+ when_case.condition.args.first.elements
180
+ else
181
+ when_case.condition.args
182
+ end
183
+
184
+ if trait_bindings.all?(Binding)
185
+ traits = trait_bindings.map(&:name).join(" AND ")
186
+ report_error(errors, "conjunction `#{traits}` is impossible", location: decl.loc)
86
187
  next
87
188
  end
88
189
  end
89
- add_error(errors, decl.loc, "conjunction `#{decl.name}` is impossible")
190
+ report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc)
90
191
  end
91
192
  end
92
193
 
@@ -101,6 +202,124 @@ module Kumi
101
202
  :unknown
102
203
  end
103
204
  end
205
+
206
+ def check_domain_constraints(node, definitions, errors)
207
+ case node
208
+ when FieldRef
209
+ # Check if FieldRef points to a field with domain constraints
210
+ field_meta = @input_meta[node.name]
211
+ nil unless field_meta&.dig(:domain)
212
+
213
+ # For FieldRef, the constraint comes from trait conditions
214
+ # We don't flag here since the FieldRef itself is valid
215
+ when Binding
216
+ # Check if this binding evaluates to a value that violates domain constraints
217
+ definition = definitions[node.name]
218
+ return unless definition
219
+
220
+ if definition.expression.is_a?(Literal)
221
+ literal_value = definition.expression.value
222
+ check_value_against_domains(node.name, literal_value, errors, definition.loc)
223
+ end
224
+ end
225
+ end
226
+
227
+ def check_value_against_domains(_var_name, value, _errors, _location)
228
+ # Check if this value violates any input domain constraints
229
+ @input_meta.each_value do |field_meta|
230
+ domain = field_meta[:domain]
231
+ next unless domain
232
+
233
+ if violates_domain?(value, domain)
234
+ # This indicates a constraint that can never be satisfied
235
+ # Rather than flagging the cascade, flag the impossible condition
236
+ return true
237
+ end
238
+ end
239
+ false
240
+ end
241
+
242
+ def violates_domain?(value, domain)
243
+ case domain
244
+ when Range
245
+ !domain.include?(value)
246
+ when Array
247
+ !domain.include?(value)
248
+ when Proc
249
+ # For Proc domains, we can't statically analyze
250
+ false
251
+ else
252
+ false
253
+ end
254
+ end
255
+
256
+ def impossible_constraint?(lhs, rhs, operator)
257
+ # Case 1: FieldRef compared against value outside its domain
258
+ if lhs.is_a?(FieldRef) && rhs.is_a?(Literal)
259
+ return field_literal_impossible?(lhs, rhs, operator)
260
+ elsif rhs.is_a?(FieldRef) && lhs.is_a?(Literal)
261
+ # Reverse case: literal compared to field
262
+ return field_literal_impossible?(rhs, lhs, flip_operator(operator))
263
+ end
264
+
265
+ # Case 2: Binding that evaluates to literal compared against impossible value
266
+ if lhs.is_a?(Binding) && rhs.is_a?(Literal)
267
+ return binding_literal_impossible?(lhs, rhs, operator)
268
+ elsif rhs.is_a?(Binding) && lhs.is_a?(Literal)
269
+ return binding_literal_impossible?(rhs, lhs, flip_operator(operator))
270
+ end
271
+
272
+ false
273
+ end
274
+
275
+ def field_literal_impossible?(field_ref, literal, operator)
276
+ field_meta = @input_meta[field_ref.name]
277
+ return false unless field_meta&.dig(:domain)
278
+
279
+ domain = field_meta[:domain]
280
+ literal_value = literal.value
281
+
282
+ case operator
283
+ when :==
284
+ # field == value where value is not in domain
285
+ violates_domain?(literal_value, domain)
286
+ when :!=
287
+ # field != value where value is not in domain is always true (not impossible)
288
+ false
289
+ else
290
+ # For other operators, we'd need more sophisticated analysis
291
+ false
292
+ end
293
+ end
294
+
295
+ def binding_literal_impossible?(binding, literal, operator)
296
+ # Check if binding evaluates to a literal that conflicts with the comparison
297
+ evaluated_value = @evaluator.evaluate(binding)
298
+ return false if evaluated_value == :unknown
299
+
300
+ literal_value = literal.value
301
+
302
+ case operator
303
+ when :==
304
+ # binding == value where binding evaluates to different value
305
+ evaluated_value != literal_value
306
+ else
307
+ # For other operators, we could add more sophisticated checking
308
+ false
309
+ end
310
+ end
311
+
312
+ def flip_operator(operator)
313
+ case operator
314
+ when :> then :<
315
+ when :>= then :<=
316
+ when :< then :>
317
+ when :<= then :>=
318
+ when :== then :==
319
+ when :!= then :!=
320
+ else operator
321
+ end
322
+ end
104
323
  end
105
324
  end
106
325
  end
@@ -3,7 +3,8 @@
3
3
  module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
- # Base class for analyzer passes that need to traverse the AST using the visitor pattern
6
+ # Base class for analyzer passes that need to traverse the AST using the visitor pattern.
7
+ # Inherits the new immutable state interface from PassBase.
7
8
  class VisitorPass < PassBase
8
9
  # Visit a node and all its children using depth-first traversal
9
10
  # @param node [Syntax::Node] The node to visit
data/lib/kumi/analyzer.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "analyzer/analysis_state"
4
+
3
5
  module Kumi
4
6
  module Analyzer
5
7
  Result = Struct.new(:definitions, :dependency_graph, :leaf_map, :topo_order, :decl_types, :state, keyword_init: true)
@@ -7,40 +9,56 @@ module Kumi
7
9
  module_function
8
10
 
9
11
  DEFAULT_PASSES = [
10
- Passes::NameIndexer, # 1. Finds all names and checks for duplicates.
11
- Passes::InputCollector, # 2. Collects field metadata from input declarations.
12
- Passes::DefinitionValidator, # 3. Checks the basic structure of each rule.
13
- Passes::DependencyResolver, # 4. Builds the dependency graph.
14
- Passes::UnsatDetector, # 5. Detects unsatisfiable constraints in rules.
15
- Passes::Toposorter, # 6. Creates the final evaluation order.
16
- Passes::TypeInferencer, # 7. Infers types for all declarations (pure annotation).
17
- Passes::TypeConsistencyChecker, # 8. Validates declared vs inferred type consistency.
18
- Passes::TypeChecker # 9. Validates types using inferred information.
12
+ Passes::NameIndexer, # 1. Finds all names and checks for duplicates.
13
+ Passes::InputCollector, # 2. Collects field metadata from input declarations.
14
+ Passes::DefinitionValidator, # 3. Checks the basic structure of each rule.
15
+ Passes::SemanticConstraintValidator, # 4. Validates DSL semantic constraints at AST level.
16
+ Passes::DependencyResolver, # 5. Builds the dependency graph.
17
+ Passes::UnsatDetector, # 6. Detects unsatisfiable constraints in rules.
18
+ Passes::Toposorter, # 7. Creates the final evaluation order.
19
+ Passes::TypeInferencer, # 8. Infers types for all declarations (pure annotation).
20
+ Passes::TypeConsistencyChecker, # 9. Validates declared vs inferred type consistency.
21
+ Passes::TypeChecker # 10. Validates types using inferred information.
19
22
  ].freeze
20
23
 
21
24
  def analyze!(schema, passes: DEFAULT_PASSES, **opts)
22
- analysis_state = { opts: opts }
25
+ state = AnalysisState.new(opts)
23
26
  errors = []
24
27
 
25
- passes.each { |klass| klass.new(schema, analysis_state).run(errors) }
28
+ state = run_analysis_passes(schema, passes, state, errors)
29
+ handle_analysis_errors(errors) unless errors.empty?
30
+ create_analysis_result(state)
31
+ end
26
32
 
27
- unless errors.empty?
28
- # Check if we have type-specific errors to raise more specific exception
29
- type_errors = errors.select { |e| e.type == :type }
30
- first_error_location = errors.first.location
33
+ def self.run_analysis_passes(schema, passes, state, errors)
34
+ passes.each do |pass_class|
35
+ pass_instance = pass_class.new(schema, state)
36
+ begin
37
+ state = pass_instance.run(errors)
38
+ rescue StandardError => e
39
+ errors << ErrorReporter.create_error(e.message, location: nil, type: :semantic)
40
+ end
41
+ end
42
+ state
43
+ end
31
44
 
32
- raise Errors::TypeError.new(format_errors(errors), first_error_location) if type_errors.any?
45
+ def self.handle_analysis_errors(errors)
46
+ type_errors = errors.select { |e| e.type == :type }
47
+ first_error_location = errors.first.location
33
48
 
34
- raise Errors::SemanticError.new(format_errors(errors), first_error_location)
35
- end
49
+ raise Errors::TypeError.new(format_errors(errors), first_error_location) if type_errors.any?
50
+
51
+ raise Errors::SemanticError.new(format_errors(errors), first_error_location)
52
+ end
36
53
 
54
+ def self.create_analysis_result(state)
37
55
  Result.new(
38
- definitions: analysis_state[:definitions].freeze,
39
- dependency_graph: analysis_state[:dependency_graph].freeze,
40
- leaf_map: analysis_state[:leaf_map].freeze,
41
- topo_order: analysis_state[:topo_order].freeze,
42
- decl_types: analysis_state[:decl_types].freeze,
43
- state: analysis_state.freeze
56
+ definitions: state[:definitions],
57
+ dependency_graph: state[:dependency_graph],
58
+ leaf_map: state[:leaf_map],
59
+ topo_order: state[:topo_order],
60
+ decl_types: state[:decl_types],
61
+ state: state.to_h
44
62
  )
45
63
  end
46
64
 
@@ -38,6 +38,9 @@ module Kumi
38
38
  # @param debug [Boolean] enable debug output
39
39
  # @return [Boolean] true if constraints are unsatisfiable
40
40
  def unsat?(atoms, debug: false)
41
+ # Pass 0: Check for special impossible atoms (domain violations, etc.)
42
+ return true if impossible_atoms_exist?(atoms, debug: debug)
43
+
41
44
  # Pass 1: Check numerical bound contradictions (symbol vs numeric)
42
45
  return true if numerical_contradiction?(atoms, debug: debug)
43
46
 
@@ -51,6 +54,21 @@ module Kumi
51
54
  StrictInequalitySolver.cycle?(edges, debug: debug)
52
55
  end
53
56
 
57
+ # Pass 0: Detects special impossible atoms (domain violations, etc.)
58
+ # These atoms are created by UnsatDetector when it finds statically impossible constraints
59
+ #
60
+ # @param atoms [Array<Atom>] constraint atoms
61
+ # @param debug [Boolean] enable debug output
62
+ # @return [Boolean] true if impossible atoms exist
63
+ def impossible_atoms_exist?(atoms, debug: false)
64
+ impossible_found = atoms.any? do |atom|
65
+ atom.lhs == :__impossible__ || atom.rhs == :__impossible__
66
+ end
67
+
68
+ puts "impossible atom detected (domain violation or static impossibility)" if impossible_found && debug
69
+ impossible_found
70
+ end
71
+
54
72
  # Pass 1: Detects numerical bound contradictions using interval analysis
55
73
  # Handles cases like x > 5 AND x < 3 (contradictory bounds)
56
74
  # Also detects always-false comparisons like 100 < 100 or 5 > 5
@@ -99,6 +117,7 @@ module Kumi
99
117
  equal_pairs, strict_pairs = collect_equality_pairs(atoms)
100
118
 
101
119
  return true if direct_equality_contradiction?(equal_pairs, strict_pairs, debug)
120
+ return true if conflicting_equalities?(atoms, debug: debug)
102
121
 
103
122
  transitive_equality_contradiction?(equal_pairs, strict_pairs, debug)
104
123
  end
@@ -210,6 +229,32 @@ module Kumi
210
229
  true
211
230
  end
212
231
 
232
+ # Checks for conflicting equalities (x == a AND x == b where a != b)
233
+ # @param atoms [Array<Atom>] constraint atoms
234
+ # @param debug [Boolean] enable debug output
235
+ # @return [Boolean] true if conflicting equalities exist
236
+ def conflicting_equalities?(atoms, debug: false)
237
+ equalities = atoms.select { |atom| atom.op == :== }
238
+
239
+ # Group equalities by their left-hand side
240
+ by_lhs = equalities.group_by(&:lhs)
241
+
242
+ # Check each variable for conflicting equality constraints
243
+ by_lhs.each do |lhs, atoms_for_lhs|
244
+ next if atoms_for_lhs.size < 2
245
+
246
+ # Get all values this variable is constrained to equal
247
+ values = atoms_for_lhs.map(&:rhs).uniq
248
+
249
+ if values.size > 1
250
+ puts "conflicting equalities detected: #{lhs} == #{values.join(" AND #{lhs} == ")}" if debug
251
+ return true
252
+ end
253
+ end
254
+
255
+ false
256
+ end
257
+
213
258
  # Checks for transitive equality contradictions using union-find
214
259
  # @param equal_pairs [Set] equality constraint pairs
215
260
  # @param strict_pairs [Set] strict inequality pairs