kumi 0.0.4 → 0.0.6
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.
- checksums.yaml +4 -4
- data/CLAUDE.md +160 -8
- data/README.md +278 -200
- data/{documents → docs}/AST.md +29 -29
- data/{documents → docs}/DSL.md +3 -3
- data/{documents → docs}/SYNTAX.md +107 -24
- data/docs/features/README.md +45 -0
- data/docs/features/analysis-cascade-mutual-exclusion.md +89 -0
- data/docs/features/analysis-type-inference.md +42 -0
- data/docs/features/analysis-unsat-detection.md +71 -0
- data/docs/features/array-broadcasting.md +170 -0
- data/docs/features/input-declaration-system.md +42 -0
- data/docs/features/performance.md +16 -0
- data/examples/federal_tax_calculator_2024.rb +43 -40
- data/examples/game_of_life.rb +97 -0
- data/examples/simple_rpg_game.rb +1000 -0
- data/examples/static_analysis_errors.rb +178 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +1 -1
- data/lib/kumi/analyzer/analysis_state.rb +37 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +22 -16
- data/lib/kumi/analyzer/passes/broadcast_detector.rb +251 -0
- data/lib/kumi/analyzer/passes/{definition_validator.rb → declaration_validator.rb} +8 -7
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +106 -26
- data/lib/kumi/analyzer/passes/input_collector.rb +105 -23
- data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
- data/lib/kumi/analyzer/passes/pass_base.rb +11 -28
- data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +45 -9
- data/lib/kumi/analyzer/passes/type_checker.rb +34 -11
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
- data/lib/kumi/analyzer/passes/type_inferencer.rb +128 -21
- data/lib/kumi/analyzer/passes/unsat_detector.rb +312 -13
- data/lib/kumi/analyzer/passes/visitor_pass.rb +4 -3
- data/lib/kumi/analyzer.rb +41 -24
- data/lib/kumi/atom_unsat_solver.rb +45 -0
- data/lib/kumi/cli.rb +449 -0
- data/lib/kumi/compiler.rb +194 -16
- data/lib/kumi/constraint_relationship_solver.rb +638 -0
- data/lib/kumi/domain/validator.rb +0 -4
- data/lib/kumi/error_reporter.rb +6 -6
- data/lib/kumi/evaluation_wrapper.rb +20 -4
- data/lib/kumi/explain.rb +28 -28
- data/lib/kumi/export/node_registry.rb +26 -12
- data/lib/kumi/export/node_serializers.rb +1 -1
- data/lib/kumi/function_registry/collection_functions.rb +117 -9
- data/lib/kumi/function_registry/function_builder.rb +4 -3
- data/lib/kumi/function_registry.rb +8 -2
- data/lib/kumi/input/type_matcher.rb +3 -0
- data/lib/kumi/input/validator.rb +0 -3
- data/lib/kumi/parser/declaration_reference_proxy.rb +36 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +19 -8
- data/lib/kumi/parser/expression_converter.rb +80 -12
- data/lib/kumi/parser/input_builder.rb +40 -9
- data/lib/kumi/parser/input_field_proxy.rb +46 -0
- data/lib/kumi/parser/input_proxy.rb +3 -3
- data/lib/kumi/parser/nested_input.rb +15 -0
- data/lib/kumi/parser/parser.rb +2 -0
- data/lib/kumi/parser/schema_builder.rb +10 -9
- data/lib/kumi/parser/sugar.rb +171 -18
- data/lib/kumi/schema.rb +3 -1
- data/lib/kumi/schema_instance.rb +69 -3
- data/lib/kumi/syntax/array_expression.rb +15 -0
- data/lib/kumi/syntax/call_expression.rb +11 -0
- data/lib/kumi/syntax/cascade_expression.rb +11 -0
- data/lib/kumi/syntax/case_expression.rb +11 -0
- data/lib/kumi/syntax/declaration_reference.rb +11 -0
- data/lib/kumi/syntax/hash_expression.rb +11 -0
- data/lib/kumi/syntax/input_declaration.rb +12 -0
- data/lib/kumi/syntax/input_element_reference.rb +12 -0
- data/lib/kumi/syntax/input_reference.rb +12 -0
- data/lib/kumi/syntax/literal.rb +11 -0
- data/lib/kumi/syntax/root.rb +1 -0
- data/lib/kumi/syntax/trait_declaration.rb +11 -0
- data/lib/kumi/syntax/value_declaration.rb +11 -0
- data/lib/kumi/types/compatibility.rb +8 -0
- data/lib/kumi/types/validator.rb +1 -1
- data/lib/kumi/vectorization_metadata.rb +108 -0
- data/lib/kumi/version.rb +1 -1
- data/scripts/generate_function_docs.rb +22 -10
- metadata +38 -17
- data/CHANGELOG.md +0 -25
- data/lib/kumi/domain.rb +0 -8
- data/lib/kumi/input.rb +0 -8
- data/lib/kumi/syntax/declarations.rb +0 -23
- data/lib/kumi/syntax/expressions.rb +0 -30
- data/lib/kumi/syntax/terminal_expressions.rb +0 -27
- data/lib/kumi/syntax.rb +0 -9
- data/test_impossible_cascade.rb +0 -51
- /data/{documents → docs}/FUNCTIONS.md +0 -0
@@ -3,6 +3,10 @@
|
|
3
3
|
module Kumi
|
4
4
|
module Analyzer
|
5
5
|
module Passes
|
6
|
+
# RESPONSIBILITY: Detect unsatisfiable constraints and analyze cascade mutual exclusion
|
7
|
+
# DEPENDENCIES: :definitions from NameIndexer, :input_meta from InputCollector
|
8
|
+
# PRODUCES: :cascade_metadata - Hash of cascade mutual exclusion analysis results
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
6
10
|
class UnsatDetector < VisitorPass
|
7
11
|
include Syntax
|
8
12
|
|
@@ -11,24 +15,150 @@ module Kumi
|
|
11
15
|
|
12
16
|
def run(errors)
|
13
17
|
definitions = get_state(:definitions)
|
18
|
+
@input_meta = get_state(:input_meta) || {}
|
19
|
+
@definitions = definitions
|
14
20
|
@evaluator = ConstantEvaluator.new(definitions)
|
15
21
|
|
22
|
+
# First pass: analyze cascade conditions for mutual exclusion
|
23
|
+
cascade_metadata = {}
|
24
|
+
each_decl do |decl|
|
25
|
+
cascade_metadata[decl.name] = analyze_cascade_mutual_exclusion(decl, definitions) if decl.expression.is_a?(CascadeExpression)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Store cascade metadata for later passes
|
29
|
+
|
30
|
+
# Second pass: check for unsatisfiable constraints
|
16
31
|
each_decl do |decl|
|
17
32
|
if decl.expression.is_a?(CascadeExpression)
|
18
33
|
# Special handling for cascade expressions
|
19
34
|
check_cascade_expression(decl, definitions, errors)
|
35
|
+
elsif decl.expression.is_a?(CallExpression) && decl.expression.fn_name == :or
|
36
|
+
# Check for OR expressions which need special disjunctive handling
|
37
|
+
impossible = check_or_expression(decl.expression, definitions, errors)
|
38
|
+
report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc) if impossible
|
20
39
|
else
|
21
40
|
# Normal handling for non-cascade expressions
|
22
41
|
atoms = gather_atoms(decl.expression, definitions, Set.new)
|
23
42
|
next if atoms.empty?
|
24
43
|
|
25
|
-
|
44
|
+
# Use enhanced solver that can detect cross-variable mathematical constraints
|
45
|
+
impossible = if definitions && !definitions.empty?
|
46
|
+
Kumi::ConstraintRelationshipSolver.unsat?(atoms, definitions, input_meta: @input_meta)
|
47
|
+
else
|
48
|
+
Kumi::AtomUnsatSolver.unsat?(atoms)
|
49
|
+
end
|
50
|
+
|
51
|
+
report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc) if impossible
|
26
52
|
end
|
27
53
|
end
|
54
|
+
state.with(:cascade_metadata, cascade_metadata)
|
28
55
|
end
|
29
56
|
|
30
57
|
private
|
31
58
|
|
59
|
+
def analyze_cascade_mutual_exclusion(decl, definitions)
|
60
|
+
conditions = []
|
61
|
+
condition_traits = []
|
62
|
+
|
63
|
+
# Extract all cascade conditions (except base case)
|
64
|
+
decl.expression.cases[0...-1].each do |when_case|
|
65
|
+
next unless when_case.condition
|
66
|
+
|
67
|
+
next unless when_case.condition.fn_name == :all?
|
68
|
+
|
69
|
+
when_case.condition.args.each do |arg|
|
70
|
+
next unless arg.is_a?(ArrayExpression)
|
71
|
+
|
72
|
+
arg.elements.each do |element|
|
73
|
+
next unless element.is_a?(DeclarationReference)
|
74
|
+
|
75
|
+
trait_name = element.name
|
76
|
+
trait = definitions[trait_name]
|
77
|
+
if trait
|
78
|
+
conditions << trait.expression
|
79
|
+
condition_traits << trait_name
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
# end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Check mutual exclusion for all pairs
|
87
|
+
total_pairs = conditions.size * (conditions.size - 1) / 2
|
88
|
+
exclusive_pairs = 0
|
89
|
+
|
90
|
+
if conditions.size >= 2
|
91
|
+
conditions.combination(2).each do |cond1, cond2|
|
92
|
+
exclusive_pairs += 1 if conditions_mutually_exclusive?(cond1, cond2)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
all_mutually_exclusive = (total_pairs > 0) && (exclusive_pairs == total_pairs)
|
97
|
+
|
98
|
+
{
|
99
|
+
condition_traits: condition_traits,
|
100
|
+
condition_count: conditions.size,
|
101
|
+
all_mutually_exclusive: all_mutually_exclusive,
|
102
|
+
exclusive_pairs: exclusive_pairs,
|
103
|
+
total_pairs: total_pairs
|
104
|
+
}
|
105
|
+
end
|
106
|
+
|
107
|
+
def conditions_mutually_exclusive?(cond1, cond2)
|
108
|
+
if cond1.is_a?(CallExpression) && cond1.fn_name == :== &&
|
109
|
+
cond2.is_a?(CallExpression) && cond2.fn_name == :==
|
110
|
+
|
111
|
+
c1_field, c1_value = cond1.args
|
112
|
+
c2_field, c2_value = cond2.args
|
113
|
+
|
114
|
+
# Same field, different values = mutually exclusive
|
115
|
+
return true if same_field?(c1_field, c2_field) && different_values?(c1_value, c2_value)
|
116
|
+
end
|
117
|
+
|
118
|
+
false
|
119
|
+
end
|
120
|
+
|
121
|
+
def same_field?(field1, field2)
|
122
|
+
return false unless field1.is_a?(InputReference) && field2.is_a?(InputReference)
|
123
|
+
|
124
|
+
field1.name == field2.name
|
125
|
+
end
|
126
|
+
|
127
|
+
def different_values?(val1, val2)
|
128
|
+
return false unless val1.is_a?(Literal) && val2.is_a?(Literal)
|
129
|
+
|
130
|
+
val1.value != val2.value
|
131
|
+
end
|
132
|
+
|
133
|
+
def check_or_expression(or_expr, definitions, errors)
|
134
|
+
# For OR expressions: A | B is impossible only if BOTH A AND B are impossible
|
135
|
+
# If either side is satisfiable, the OR is satisfiable
|
136
|
+
left_side, right_side = or_expr.args
|
137
|
+
|
138
|
+
# Check if left side is impossible
|
139
|
+
left_atoms = gather_atoms(left_side, definitions, Set.new)
|
140
|
+
left_impossible = if left_atoms.empty?
|
141
|
+
false
|
142
|
+
elsif definitions && !definitions.empty?
|
143
|
+
Kumi::ConstraintRelationshipSolver.unsat?(left_atoms, definitions, input_meta: @input_meta)
|
144
|
+
else
|
145
|
+
Kumi::AtomUnsatSolver.unsat?(left_atoms)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Check if right side is impossible
|
149
|
+
right_atoms = gather_atoms(right_side, definitions, Set.new)
|
150
|
+
right_impossible = if right_atoms.empty?
|
151
|
+
false
|
152
|
+
elsif definitions && !definitions.empty?
|
153
|
+
Kumi::ConstraintRelationshipSolver.unsat?(right_atoms, definitions, input_meta: @input_meta)
|
154
|
+
else
|
155
|
+
Kumi::AtomUnsatSolver.unsat?(right_atoms)
|
156
|
+
end
|
157
|
+
|
158
|
+
# OR is impossible only if BOTH sides are impossible
|
159
|
+
left_impossible && right_impossible
|
160
|
+
end
|
161
|
+
|
32
162
|
def gather_atoms(node, defs, visited, list = [])
|
33
163
|
return list unless node
|
34
164
|
|
@@ -41,8 +171,26 @@ module Kumi
|
|
41
171
|
|
42
172
|
if current.is_a?(CallExpression) && COMPARATORS.include?(current.fn_name)
|
43
173
|
lhs, rhs = current.args
|
44
|
-
|
45
|
-
|
174
|
+
|
175
|
+
# Check for domain constraint violations before creating atom
|
176
|
+
list << if impossible_constraint?(lhs, rhs, current.fn_name)
|
177
|
+
# Create a special impossible atom that will always trigger unsat
|
178
|
+
Atom.new(:==, :__impossible__, true)
|
179
|
+
else
|
180
|
+
Atom.new(current.fn_name, term(lhs, defs), term(rhs, defs))
|
181
|
+
end
|
182
|
+
elsif current.is_a?(CallExpression) && current.fn_name == :or
|
183
|
+
# Special handling for OR expressions - they are disjunctive, not conjunctive
|
184
|
+
# We should NOT add OR children to the stack as they would be treated as AND
|
185
|
+
# OR expressions need separate analysis in the main run() method
|
186
|
+
next
|
187
|
+
elsif current.is_a?(CallExpression) && current.fn_name == :all?
|
188
|
+
# For all? function, add all trait arguments to the stack
|
189
|
+
current.args.each { |arg| stack << arg }
|
190
|
+
elsif current.is_a?(ArrayExpression)
|
191
|
+
# For ArrayExpression, add all elements to the stack
|
192
|
+
current.elements.each { |elem| stack << elem }
|
193
|
+
elsif current.is_a?(DeclarationReference)
|
46
194
|
name = current.name
|
47
195
|
unless visited.include?(name)
|
48
196
|
visited << name
|
@@ -51,7 +199,10 @@ module Kumi
|
|
51
199
|
end
|
52
200
|
|
53
201
|
# Add children to stack for processing
|
54
|
-
|
202
|
+
# IMPORTANT: Skip CascadeExpression children to avoid false positives
|
203
|
+
# Cascades are handled separately by check_cascade_expression() and are disjunctive,
|
204
|
+
# but gather_atoms() treats all collected atoms as conjunctive
|
205
|
+
current.children.each { |child| stack << child } if current.respond_to?(:children) && !current.is_a?(CascadeExpression)
|
55
206
|
end
|
56
207
|
|
57
208
|
list
|
@@ -66,33 +217,63 @@ module Kumi
|
|
66
217
|
# Skip the base case (it's typically a literal true condition)
|
67
218
|
next if when_case.condition.is_a?(Literal) && when_case.condition.value == true
|
68
219
|
|
220
|
+
# Skip non-conjunctive conditions (any?, none?) as they are disjunctive
|
221
|
+
next if when_case.condition.is_a?(CallExpression) && %i[any? none?].include?(when_case.condition.fn_name)
|
222
|
+
|
69
223
|
# Skip single-trait 'on' branches: trait-level unsat detection covers these
|
70
224
|
if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
|
71
|
-
|
72
|
-
|
225
|
+
# Handle both ArrayExpression (old format) and multiple args (new format)
|
226
|
+
if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ArrayExpression)
|
227
|
+
list = when_case.condition.args.first
|
228
|
+
next if list.elements.size == 1
|
229
|
+
elsif when_case.condition.args.size == 1
|
230
|
+
# Multiple args format
|
231
|
+
next
|
232
|
+
end
|
73
233
|
end
|
74
234
|
# Gather atoms from this individual condition only
|
75
235
|
condition_atoms = gather_atoms(when_case.condition, definitions, Set.new, [])
|
236
|
+
# DEBUG
|
237
|
+
# if when_case.condition.is_a?(CallExpression) && [:all?, :any?, :none?].include?(when_case.condition.fn_name)
|
238
|
+
# puts " Args: #{when_case.condition.args.inspect}"
|
239
|
+
# puts " Atoms found: #{condition_atoms.inspect}"
|
240
|
+
# end
|
76
241
|
|
77
242
|
# Only flag if this individual condition is impossible
|
78
|
-
|
243
|
+
# if !condition_atoms.empty?
|
244
|
+
# is_unsat = Kumi::AtomUnsatSolver.unsat?(condition_atoms)
|
245
|
+
# puts " Is unsat? #{is_unsat}"
|
246
|
+
# end
|
247
|
+
# Use enhanced solver for cascade conditions too
|
248
|
+
impossible = if definitions && !definitions.empty?
|
249
|
+
Kumi::ConstraintRelationshipSolver.unsat?(condition_atoms, definitions, input_meta: @input_meta)
|
250
|
+
else
|
251
|
+
Kumi::AtomUnsatSolver.unsat?(condition_atoms)
|
252
|
+
end
|
253
|
+
next unless !condition_atoms.empty? && impossible
|
79
254
|
|
80
255
|
# For multi-trait on-clauses, report the trait names rather than the value name
|
81
256
|
if when_case.condition.is_a?(CallExpression) && when_case.condition.fn_name == :all?
|
82
|
-
|
83
|
-
if
|
84
|
-
|
85
|
-
|
257
|
+
# Handle both ArrayExpression (old format) and multiple args (new format)
|
258
|
+
trait_bindings = if when_case.condition.args.size == 1 && when_case.condition.args.first.is_a?(ArrayExpression)
|
259
|
+
when_case.condition.args.first.elements
|
260
|
+
else
|
261
|
+
when_case.condition.args
|
262
|
+
end
|
263
|
+
|
264
|
+
if trait_bindings.all?(DeclarationReference)
|
265
|
+
traits = trait_bindings.map(&:name).join(" AND ")
|
266
|
+
report_error(errors, "conjunction `#{traits}` is impossible", location: decl.loc)
|
86
267
|
next
|
87
268
|
end
|
88
269
|
end
|
89
|
-
|
270
|
+
report_error(errors, "conjunction `#{decl.name}` is impossible", location: decl.loc)
|
90
271
|
end
|
91
272
|
end
|
92
273
|
|
93
274
|
def term(node, _defs)
|
94
275
|
case node
|
95
|
-
when
|
276
|
+
when InputReference, DeclarationReference
|
96
277
|
val = @evaluator.evaluate(node)
|
97
278
|
val == :unknown ? node.name : val
|
98
279
|
when Literal
|
@@ -101,6 +282,124 @@ module Kumi
|
|
101
282
|
:unknown
|
102
283
|
end
|
103
284
|
end
|
285
|
+
|
286
|
+
def check_domain_constraints(node, definitions, errors)
|
287
|
+
case node
|
288
|
+
when InputReference
|
289
|
+
# Check if InputReference points to a field with domain constraints
|
290
|
+
field_meta = @input_meta[node.name]
|
291
|
+
nil unless field_meta&.dig(:domain)
|
292
|
+
|
293
|
+
# For InputReference, the constraint comes from trait conditions
|
294
|
+
# We don't flag here since the InputReference itself is valid
|
295
|
+
when DeclarationReference
|
296
|
+
# Check if this binding evaluates to a value that violates domain constraints
|
297
|
+
definition = definitions[node.name]
|
298
|
+
return unless definition
|
299
|
+
|
300
|
+
if definition.expression.is_a?(Literal)
|
301
|
+
literal_value = definition.expression.value
|
302
|
+
check_value_against_domains(node.name, literal_value, errors, definition.loc)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
def check_value_against_domains(_var_name, value, _errors, _location)
|
308
|
+
# Check if this value violates any input domain constraints
|
309
|
+
@input_meta.each_value do |field_meta|
|
310
|
+
domain = field_meta[:domain]
|
311
|
+
next unless domain
|
312
|
+
|
313
|
+
if violates_domain?(value, domain)
|
314
|
+
# This indicates a constraint that can never be satisfied
|
315
|
+
# Rather than flagging the cascade, flag the impossible condition
|
316
|
+
return true
|
317
|
+
end
|
318
|
+
end
|
319
|
+
false
|
320
|
+
end
|
321
|
+
|
322
|
+
def violates_domain?(value, domain)
|
323
|
+
case domain
|
324
|
+
when Range
|
325
|
+
!domain.include?(value)
|
326
|
+
when Array
|
327
|
+
!domain.include?(value)
|
328
|
+
when Proc
|
329
|
+
# For Proc domains, we can't statically analyze
|
330
|
+
false
|
331
|
+
else
|
332
|
+
false
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def impossible_constraint?(lhs, rhs, operator)
|
337
|
+
# Case 1: InputReference compared against value outside its domain
|
338
|
+
if lhs.is_a?(InputReference) && rhs.is_a?(Literal)
|
339
|
+
return field_literal_impossible?(lhs, rhs, operator)
|
340
|
+
elsif rhs.is_a?(InputReference) && lhs.is_a?(Literal)
|
341
|
+
# Reverse case: literal compared to field
|
342
|
+
return field_literal_impossible?(rhs, lhs, flip_operator(operator))
|
343
|
+
end
|
344
|
+
|
345
|
+
# Case 2: DeclarationReference that evaluates to literal compared against impossible value
|
346
|
+
if lhs.is_a?(DeclarationReference) && rhs.is_a?(Literal)
|
347
|
+
return binding_literal_impossible?(lhs, rhs, operator)
|
348
|
+
elsif rhs.is_a?(DeclarationReference) && lhs.is_a?(Literal)
|
349
|
+
return binding_literal_impossible?(rhs, lhs, flip_operator(operator))
|
350
|
+
end
|
351
|
+
|
352
|
+
false
|
353
|
+
end
|
354
|
+
|
355
|
+
def field_literal_impossible?(field_ref, literal, operator)
|
356
|
+
field_meta = @input_meta[field_ref.name]
|
357
|
+
return false unless field_meta&.dig(:domain)
|
358
|
+
|
359
|
+
domain = field_meta[:domain]
|
360
|
+
literal_value = literal.value
|
361
|
+
|
362
|
+
case operator
|
363
|
+
when :==
|
364
|
+
# field == value where value is not in domain
|
365
|
+
violates_domain?(literal_value, domain)
|
366
|
+
when :!=
|
367
|
+
# field != value where value is not in domain is always true (not impossible)
|
368
|
+
false
|
369
|
+
else
|
370
|
+
# For other operators, we'd need more sophisticated analysis
|
371
|
+
false
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
def binding_literal_impossible?(binding, literal, operator)
|
376
|
+
# Check if binding evaluates to a literal that conflicts with the comparison
|
377
|
+
evaluated_value = @evaluator.evaluate(binding)
|
378
|
+
return false if evaluated_value == :unknown
|
379
|
+
|
380
|
+
literal_value = literal.value
|
381
|
+
|
382
|
+
case operator
|
383
|
+
when :==
|
384
|
+
# binding == value where binding evaluates to different value
|
385
|
+
evaluated_value != literal_value
|
386
|
+
else
|
387
|
+
# For other operators, we could add more sophisticated checking
|
388
|
+
false
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
def flip_operator(operator)
|
393
|
+
case operator
|
394
|
+
when :> then :<
|
395
|
+
when :>= then :<=
|
396
|
+
when :< then :>
|
397
|
+
when :<= then :>=
|
398
|
+
when :== then :==
|
399
|
+
when :!= then :!=
|
400
|
+
else operator
|
401
|
+
end
|
402
|
+
end
|
104
403
|
end
|
105
404
|
end
|
106
405
|
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
|
@@ -19,7 +20,7 @@ module Kumi
|
|
19
20
|
|
20
21
|
# Helper to visit each declaration's expression tree
|
21
22
|
# @param errors [Array] Error accumulator
|
22
|
-
# @yield [Syntax::Node, Syntax::
|
23
|
+
# @yield [Syntax::Node, Syntax::Base] Each node and its containing declaration
|
23
24
|
def visit_all_expressions(errors)
|
24
25
|
each_decl do |decl|
|
25
26
|
visit(decl.expression) { |node| yield(node, decl, errors) }
|
@@ -29,7 +30,7 @@ module Kumi
|
|
29
30
|
# Helper to visit only specific node types
|
30
31
|
# @param node_types [Array<Class>] Node types to match
|
31
32
|
# @param errors [Array] Error accumulator
|
32
|
-
# @yield [Syntax::Node, Syntax::
|
33
|
+
# @yield [Syntax::Node, Syntax::Base] Matching nodes and their declarations
|
33
34
|
def visit_nodes_of_type(*node_types, errors:)
|
34
35
|
visit_all_expressions(errors) do |node, decl, errs|
|
35
36
|
yield(node, decl, errs) if node_types.any? { |type| node.is_a?(type) }
|
data/lib/kumi/analyzer.rb
CHANGED
@@ -7,40 +7,57 @@ module Kumi
|
|
7
7
|
module_function
|
8
8
|
|
9
9
|
DEFAULT_PASSES = [
|
10
|
-
Passes::NameIndexer,
|
11
|
-
Passes::InputCollector,
|
12
|
-
Passes::
|
13
|
-
Passes::
|
14
|
-
Passes::
|
15
|
-
Passes::
|
16
|
-
Passes::
|
17
|
-
Passes::
|
18
|
-
Passes::
|
10
|
+
Passes::NameIndexer, # 1. Finds all names and checks for duplicates.
|
11
|
+
Passes::InputCollector, # 2. Collects field metadata from input declarations.
|
12
|
+
Passes::DeclarationValidator, # 3. Checks the basic structure of each rule.
|
13
|
+
Passes::SemanticConstraintValidator, # 4. Validates DSL semantic constraints at AST level.
|
14
|
+
Passes::DependencyResolver, # 5. Builds the dependency graph with conditional dependencies.
|
15
|
+
Passes::UnsatDetector, # 6. Detects unsatisfiable constraints and analyzes cascade mutual exclusion.
|
16
|
+
Passes::Toposorter, # 7. Creates the final evaluation order, allowing safe cycles.
|
17
|
+
Passes::BroadcastDetector, # 8. Detects which operations should be broadcast over arrays (must run before type inference).
|
18
|
+
Passes::TypeInferencer, # 9. Infers types for all declarations (uses vectorization metadata).
|
19
|
+
Passes::TypeConsistencyChecker, # 10. Validates declared vs inferred type consistency.
|
20
|
+
Passes::TypeChecker # 11. Validates types using inferred information.
|
19
21
|
].freeze
|
20
22
|
|
21
23
|
def analyze!(schema, passes: DEFAULT_PASSES, **opts)
|
22
|
-
|
24
|
+
state = AnalysisState.new(opts)
|
23
25
|
errors = []
|
24
26
|
|
25
|
-
|
27
|
+
state = run_analysis_passes(schema, passes, state, errors)
|
28
|
+
handle_analysis_errors(errors) unless errors.empty?
|
29
|
+
create_analysis_result(state)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.run_analysis_passes(schema, passes, state, errors)
|
33
|
+
passes.each do |pass_class|
|
34
|
+
pass_instance = pass_class.new(schema, state)
|
35
|
+
begin
|
36
|
+
state = pass_instance.run(errors)
|
37
|
+
rescue StandardError => e
|
38
|
+
errors << ErrorReporter.create_error(e.message, location: nil, type: :semantic)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
state
|
42
|
+
end
|
26
43
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
first_error_location = errors.first.location
|
44
|
+
def self.handle_analysis_errors(errors)
|
45
|
+
type_errors = errors.select { |e| e.type == :type }
|
46
|
+
first_error_location = errors.first.location
|
31
47
|
|
32
|
-
|
48
|
+
raise Errors::TypeError.new(format_errors(errors), first_error_location) if type_errors.any?
|
33
49
|
|
34
|
-
|
35
|
-
|
50
|
+
raise Errors::SemanticError.new(format_errors(errors), first_error_location)
|
51
|
+
end
|
36
52
|
|
53
|
+
def self.create_analysis_result(state)
|
37
54
|
Result.new(
|
38
|
-
definitions:
|
39
|
-
dependency_graph:
|
40
|
-
leaf_map:
|
41
|
-
topo_order:
|
42
|
-
decl_types:
|
43
|
-
state:
|
55
|
+
definitions: state[:definitions],
|
56
|
+
dependency_graph: state[:dependency_graph],
|
57
|
+
leaf_map: state[:leaf_map],
|
58
|
+
topo_order: state[:topo_order],
|
59
|
+
decl_types: state[:decl_types],
|
60
|
+
state: state.to_h
|
44
61
|
)
|
45
62
|
end
|
46
63
|
|
@@ -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
|