kumi 0.0.0 → 0.0.3
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/.rubocop.yml +113 -3
- data/CHANGELOG.md +21 -1
- data/CLAUDE.md +387 -0
- data/README.md +257 -20
- data/docs/development/README.md +120 -0
- data/docs/development/error-reporting.md +361 -0
- data/documents/AST.md +126 -0
- data/documents/DSL.md +154 -0
- data/documents/FUNCTIONS.md +132 -0
- data/documents/SYNTAX.md +367 -0
- data/examples/deep_schema_compilation_and_evaluation_benchmark.rb +106 -0
- data/examples/federal_tax_calculator_2024.rb +112 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +80 -0
- data/lib/generators/trait_engine/templates/schema_spec.rb.erb +27 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +51 -0
- data/lib/kumi/analyzer/passes/definition_validator.rb +42 -0
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +71 -0
- data/lib/kumi/analyzer/passes/input_collector.rb +55 -0
- data/lib/kumi/analyzer/passes/name_indexer.rb +24 -0
- data/lib/kumi/analyzer/passes/pass_base.rb +67 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +72 -0
- data/lib/kumi/analyzer/passes/type_checker.rb +139 -0
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +45 -0
- data/lib/kumi/analyzer/passes/type_inferencer.rb +125 -0
- data/lib/kumi/analyzer/passes/unsat_detector.rb +107 -0
- data/lib/kumi/analyzer/passes/visitor_pass.rb +41 -0
- data/lib/kumi/analyzer.rb +54 -0
- data/lib/kumi/atom_unsat_solver.rb +349 -0
- data/lib/kumi/compiled_schema.rb +41 -0
- data/lib/kumi/compiler.rb +127 -0
- data/lib/kumi/domain/enum_analyzer.rb +53 -0
- data/lib/kumi/domain/range_analyzer.rb +83 -0
- data/lib/kumi/domain/validator.rb +84 -0
- data/lib/kumi/domain/violation_formatter.rb +40 -0
- data/lib/kumi/domain.rb +8 -0
- data/lib/kumi/error_reporter.rb +164 -0
- data/lib/kumi/error_reporting.rb +95 -0
- data/lib/kumi/errors.rb +116 -0
- data/lib/kumi/evaluation_wrapper.rb +20 -0
- data/lib/kumi/explain.rb +282 -0
- data/lib/kumi/export/deserializer.rb +39 -0
- data/lib/kumi/export/errors.rb +12 -0
- data/lib/kumi/export/node_builders.rb +140 -0
- data/lib/kumi/export/node_registry.rb +38 -0
- data/lib/kumi/export/node_serializers.rb +156 -0
- data/lib/kumi/export/serializer.rb +23 -0
- data/lib/kumi/export.rb +33 -0
- data/lib/kumi/function_registry/collection_functions.rb +92 -0
- data/lib/kumi/function_registry/comparison_functions.rb +31 -0
- data/lib/kumi/function_registry/conditional_functions.rb +36 -0
- data/lib/kumi/function_registry/function_builder.rb +92 -0
- data/lib/kumi/function_registry/logical_functions.rb +42 -0
- data/lib/kumi/function_registry/math_functions.rb +72 -0
- data/lib/kumi/function_registry/string_functions.rb +54 -0
- data/lib/kumi/function_registry/type_functions.rb +51 -0
- data/lib/kumi/function_registry.rb +138 -0
- data/lib/kumi/input/type_matcher.rb +92 -0
- data/lib/kumi/input/validator.rb +52 -0
- data/lib/kumi/input/violation_creator.rb +50 -0
- data/lib/kumi/input.rb +8 -0
- data/lib/kumi/parser/build_context.rb +25 -0
- data/lib/kumi/parser/dsl.rb +12 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +125 -0
- data/lib/kumi/parser/expression_converter.rb +58 -0
- data/lib/kumi/parser/guard_rails.rb +43 -0
- data/lib/kumi/parser/input_builder.rb +94 -0
- data/lib/kumi/parser/input_proxy.rb +29 -0
- data/lib/kumi/parser/parser.rb +66 -0
- data/lib/kumi/parser/schema_builder.rb +172 -0
- data/lib/kumi/parser/sugar.rb +108 -0
- data/lib/kumi/schema.rb +49 -0
- data/lib/kumi/schema_instance.rb +43 -0
- data/lib/kumi/syntax/declarations.rb +23 -0
- data/lib/kumi/syntax/expressions.rb +30 -0
- data/lib/kumi/syntax/node.rb +46 -0
- data/lib/kumi/syntax/root.rb +12 -0
- data/lib/kumi/syntax/terminal_expressions.rb +27 -0
- data/lib/kumi/syntax.rb +9 -0
- data/lib/kumi/types/builder.rb +21 -0
- data/lib/kumi/types/compatibility.rb +86 -0
- data/lib/kumi/types/formatter.rb +24 -0
- data/lib/kumi/types/inference.rb +40 -0
- data/lib/kumi/types/normalizer.rb +70 -0
- data/lib/kumi/types/validator.rb +35 -0
- data/lib/kumi/types.rb +64 -0
- data/lib/kumi/version.rb +1 -1
- data/lib/kumi.rb +7 -3
- data/scripts/generate_function_docs.rb +59 -0
- data/test_impossible_cascade.rb +51 -0
- metadata +93 -10
- data/sig/kumi.rbs +0 -4
@@ -0,0 +1,349 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
# AtomUnsatSolver detects logical contradictions in constraint systems using three analysis passes:
|
5
|
+
# 1. Numerical bounds checking for symbol-numeric inequalities (e.g. x > 5, x < 3)
|
6
|
+
# 2. Equality contradiction detection for same-type comparisons
|
7
|
+
# 3. Strict inequality cycle detection using Kahn's topological sort (stack-safe)
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# atoms = [Atom.new(:>, :x, 5), Atom.new(:<, :x, 3)]
|
11
|
+
# AtomUnsatSolver.unsat?(atoms) #=> true (contradiction: x > 5 AND x < 3)
|
12
|
+
#
|
13
|
+
# @example Cycle detection
|
14
|
+
# atoms = [Atom.new(:<, :x, :y), Atom.new(:<, :y, :z), Atom.new(:<, :z, :x)]
|
15
|
+
# AtomUnsatSolver.unsat?(atoms) #=> true (cycle: x < y < z < x)
|
16
|
+
module AtomUnsatSolver
|
17
|
+
# Represents a constraint atom with operator and operands
|
18
|
+
# @!attribute [r] op
|
19
|
+
# @return [Symbol] comparison operator (:>, :<, :>=, :<=, :==)
|
20
|
+
# @!attribute [r] lhs
|
21
|
+
# @return [Object] left-hand side operand
|
22
|
+
# @!attribute [r] rhs
|
23
|
+
# @return [Object] right-hand side operand
|
24
|
+
Atom = Struct.new(:op, :lhs, :rhs)
|
25
|
+
|
26
|
+
# Represents a directed edge in the strict inequality graph
|
27
|
+
# @!attribute [r] from
|
28
|
+
# @return [Symbol] source vertex
|
29
|
+
# @!attribute [r] to
|
30
|
+
# @return [Symbol] target vertex
|
31
|
+
Edge = Struct.new(:from, :to)
|
32
|
+
|
33
|
+
module_function
|
34
|
+
|
35
|
+
# Main entry point: checks if the given constraint atoms are unsatisfiable
|
36
|
+
#
|
37
|
+
# @param atoms [Array<Atom>] constraint atoms to analyze
|
38
|
+
# @param debug [Boolean] enable debug output
|
39
|
+
# @return [Boolean] true if constraints are unsatisfiable
|
40
|
+
def unsat?(atoms, debug: false)
|
41
|
+
# Pass 1: Check numerical bound contradictions (symbol vs numeric)
|
42
|
+
return true if numerical_contradiction?(atoms, debug: debug)
|
43
|
+
|
44
|
+
# Pass 2: Check equality contradictions (same-type comparisons)
|
45
|
+
return true if equality_contradiction?(atoms, debug: debug)
|
46
|
+
|
47
|
+
# Pass 3: Check strict inequality cycles using stack-safe Kahn's algorithm
|
48
|
+
edges = build_strict_inequality_edges(atoms)
|
49
|
+
puts "edges: #{edges.map { |e| "#{e.from}→#{e.to}" }.join(', ')}" if debug
|
50
|
+
|
51
|
+
StrictInequalitySolver.cycle?(edges, debug: debug)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Pass 1: Detects numerical bound contradictions using interval analysis
|
55
|
+
# Handles cases like x > 5 AND x < 3 (contradictory bounds)
|
56
|
+
# Also detects always-false comparisons like 100 < 100 or 5 > 5
|
57
|
+
#
|
58
|
+
# @param atoms [Array<Atom>] constraint atoms
|
59
|
+
# @param debug [Boolean] enable debug output
|
60
|
+
# @return [Boolean] true if numerical contradiction exists
|
61
|
+
def numerical_contradiction?(atoms, debug: false)
|
62
|
+
return true if always_false_constraints_exist?(atoms, debug)
|
63
|
+
|
64
|
+
check_bound_contradictions(atoms, debug)
|
65
|
+
end
|
66
|
+
|
67
|
+
def always_false_constraints_exist?(atoms, debug)
|
68
|
+
atoms.any? do |atom|
|
69
|
+
next false unless always_false_comparison?(atom)
|
70
|
+
|
71
|
+
puts "always-false comparison detected: #{atom.lhs} #{atom.op} #{atom.rhs}" if debug
|
72
|
+
true
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def check_bound_contradictions(atoms, debug)
|
77
|
+
lowers = Hash.new(-Float::INFINITY)
|
78
|
+
uppers = Hash.new(Float::INFINITY)
|
79
|
+
|
80
|
+
atoms.each do |atom|
|
81
|
+
sym, num, op = extract_symbol_numeric_pair(atom)
|
82
|
+
next unless sym
|
83
|
+
|
84
|
+
update_bounds(lowers, uppers, sym, num, op)
|
85
|
+
end
|
86
|
+
|
87
|
+
contradiction = uppers.any? { |sym, hi| hi < lowers[sym] }
|
88
|
+
puts "numerical contradiction detected" if contradiction && debug
|
89
|
+
contradiction
|
90
|
+
end
|
91
|
+
|
92
|
+
# Pass 2: Detects equality contradictions using union-find equivalence classes
|
93
|
+
# Handles cases like x == y AND x > y (equality vs strict inequality)
|
94
|
+
#
|
95
|
+
# @param atoms [Array<Atom>] constraint atoms
|
96
|
+
# @param debug [Boolean] enable debug output
|
97
|
+
# @return [Boolean] true if equality contradiction exists
|
98
|
+
def equality_contradiction?(atoms, debug: false)
|
99
|
+
equal_pairs, strict_pairs = collect_equality_pairs(atoms)
|
100
|
+
|
101
|
+
return true if direct_equality_contradiction?(equal_pairs, strict_pairs, debug)
|
102
|
+
|
103
|
+
transitive_equality_contradiction?(equal_pairs, strict_pairs, debug)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Extracts symbol-numeric pairs and normalizes operator direction
|
107
|
+
# @param atom [Atom] constraint atom
|
108
|
+
# @return [Array(Symbol, Numeric, Symbol)] normalized [symbol, number, operator] or [nil, nil, nil]
|
109
|
+
def extract_symbol_numeric_pair(atom)
|
110
|
+
if atom.lhs.is_a?(Symbol) && atom.rhs.is_a?(Numeric)
|
111
|
+
[atom.lhs, atom.rhs, atom.op]
|
112
|
+
elsif atom.rhs.is_a?(Symbol) && atom.lhs.is_a?(Numeric)
|
113
|
+
[atom.rhs, atom.lhs, flip_operator(atom.op)]
|
114
|
+
else
|
115
|
+
[nil, nil, nil]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Updates variable bounds based on constraint
|
120
|
+
# @param lowers [Hash] lower bounds by symbol
|
121
|
+
# @param uppers [Hash] upper bounds by symbol
|
122
|
+
# @param sym [Symbol] variable symbol
|
123
|
+
# @param num [Numeric] constraint value
|
124
|
+
# @param operator [Symbol] constraint operator
|
125
|
+
def update_bounds(lowers, uppers, sym, num, operator)
|
126
|
+
case operator
|
127
|
+
when :> then lowers[sym] = [lowers[sym], num + 1].max # x > 5 means x >= 6
|
128
|
+
when :>= then lowers[sym] = [lowers[sym], num].max # x >= 5 means x >= 5
|
129
|
+
when :< then uppers[sym] = [uppers[sym], num - 1].min # x < 5 means x <= 4
|
130
|
+
when :<= then uppers[sym] = [uppers[sym], num].min # x <= 5 means x <= 5
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Flips comparison operator for normalization
|
135
|
+
# @param operator [Symbol] original operator
|
136
|
+
# @return [Symbol] flipped operator
|
137
|
+
def flip_operator(operator)
|
138
|
+
{ :> => :<, :>= => :<=, :< => :>, :<= => :>= }[operator]
|
139
|
+
end
|
140
|
+
|
141
|
+
# Detects always-false comparisons like 5 > 5, 100 < 100, etc.
|
142
|
+
# These represent impossible conditions since they can never be true
|
143
|
+
# @param atom [Atom] constraint atom to check
|
144
|
+
# @return [Boolean] true if comparison is always false
|
145
|
+
def always_false_comparison?(atom)
|
146
|
+
return false unless atom.lhs.is_a?(Numeric) && atom.rhs.is_a?(Numeric)
|
147
|
+
|
148
|
+
lhs = atom.lhs
|
149
|
+
rhs = atom.rhs
|
150
|
+
case atom.op
|
151
|
+
when :> then lhs <= rhs # 5 > 5 is always false
|
152
|
+
when :< then lhs >= rhs # 5 < 5 is always false
|
153
|
+
when :>= then lhs < rhs # 5 >= 6 is always false
|
154
|
+
when :<= then lhs > rhs # 6 <= 5 is always false
|
155
|
+
when :== then lhs != rhs # 5 == 6 is always false
|
156
|
+
when :!= then lhs == rhs # 5 != 5 is always false
|
157
|
+
else false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Builds directed edges for strict inequality cycle detection
|
162
|
+
# Only creates edges when both endpoints are symbols (variables)
|
163
|
+
# Filters out symbol-numeric pairs (handled by numerical_contradiction?)
|
164
|
+
#
|
165
|
+
# @param atoms [Array<Atom>] constraint atoms
|
166
|
+
# @return [Array<Edge>] directed edges for cycle detection
|
167
|
+
def build_strict_inequality_edges(atoms)
|
168
|
+
atoms.filter_map do |atom|
|
169
|
+
next unless atom.lhs.is_a?(Symbol) && atom.rhs.is_a?(Symbol)
|
170
|
+
|
171
|
+
case atom.op
|
172
|
+
when :> then Edge.new(atom.rhs, atom.lhs) # x > y ⇒ edge y → x
|
173
|
+
when :< then Edge.new(atom.lhs, atom.rhs) # x < y ⇒ edge x → y
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Collects equality and strict inequality pairs for same-type operands
|
179
|
+
# @param atoms [Array<Atom>] constraint atoms
|
180
|
+
# @return [Array(Set, Set)] [equality pairs, strict inequality pairs]
|
181
|
+
def collect_equality_pairs(atoms)
|
182
|
+
equal_pairs = Set.new
|
183
|
+
strict_pairs = Set.new
|
184
|
+
|
185
|
+
atoms.each do |atom|
|
186
|
+
next unless atom.lhs.instance_of?(atom.rhs.class)
|
187
|
+
|
188
|
+
pair = [atom.lhs, atom.rhs].sort
|
189
|
+
case atom.op
|
190
|
+
when :==
|
191
|
+
equal_pairs << pair
|
192
|
+
when :>, :<
|
193
|
+
strict_pairs << pair
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
[equal_pairs, strict_pairs]
|
198
|
+
end
|
199
|
+
|
200
|
+
# Checks for direct equality contradictions (x == y AND x > y)
|
201
|
+
# @param equal_pairs [Set] equality constraint pairs
|
202
|
+
# @param strict_pairs [Set] strict inequality pairs
|
203
|
+
# @param debug [Boolean] enable debug output
|
204
|
+
# @return [Boolean] true if direct contradiction exists
|
205
|
+
def direct_equality_contradiction?(equal_pairs, strict_pairs, debug)
|
206
|
+
conflicting_pairs = equal_pairs & strict_pairs
|
207
|
+
return false unless conflicting_pairs.any?
|
208
|
+
|
209
|
+
puts "equality contradiction detected" if debug
|
210
|
+
true
|
211
|
+
end
|
212
|
+
|
213
|
+
# Checks for transitive equality contradictions using union-find
|
214
|
+
# @param equal_pairs [Set] equality constraint pairs
|
215
|
+
# @param strict_pairs [Set] strict inequality pairs
|
216
|
+
# @param debug [Boolean] enable debug output
|
217
|
+
# @return [Boolean] true if transitive contradiction exists
|
218
|
+
def transitive_equality_contradiction?(equal_pairs, strict_pairs, debug)
|
219
|
+
equiv_classes = build_equivalence_classes(equal_pairs)
|
220
|
+
equiv_classes.each do |equiv_class|
|
221
|
+
equiv_class.combination(2).each do |var1, var2|
|
222
|
+
pair = [var1, var2].sort
|
223
|
+
next unless strict_pairs.include?(pair)
|
224
|
+
|
225
|
+
puts "transitive equality contradiction detected" if debug
|
226
|
+
return true
|
227
|
+
end
|
228
|
+
end
|
229
|
+
false
|
230
|
+
end
|
231
|
+
|
232
|
+
# Builds equivalence classes using union-find algorithm
|
233
|
+
# @param equal_pairs [Set] equality constraint pairs
|
234
|
+
# @return [Array<Array>] equivalence classes (groups of equal variables)
|
235
|
+
def build_equivalence_classes(equal_pairs)
|
236
|
+
parent = Hash.new { |h, k| h[k] = k }
|
237
|
+
|
238
|
+
equal_pairs.each do |pair|
|
239
|
+
root1 = find_root(pair[0], parent)
|
240
|
+
root2 = find_root(pair[1], parent)
|
241
|
+
parent[root1] = root2
|
242
|
+
end
|
243
|
+
|
244
|
+
group_variables_by_root(parent)
|
245
|
+
end
|
246
|
+
|
247
|
+
# Finds root of equivalence class with path compression
|
248
|
+
# @param element [Object] element to find root for
|
249
|
+
# @param parent [Hash] parent pointers for union-find
|
250
|
+
# @return [Object] root element of equivalence class
|
251
|
+
def find_root(element, parent)
|
252
|
+
return element if parent[element] == element
|
253
|
+
|
254
|
+
parent[element] = find_root(parent[element], parent)
|
255
|
+
parent[element]
|
256
|
+
end
|
257
|
+
|
258
|
+
# Groups variables by their equivalence class root
|
259
|
+
# @param parent [Hash] parent pointers for union-find
|
260
|
+
# @return [Array<Array>] equivalence classes with multiple elements
|
261
|
+
def group_variables_by_root(parent)
|
262
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
263
|
+
parent.each_key do |var|
|
264
|
+
groups[find_root(var, parent)] << var
|
265
|
+
end
|
266
|
+
groups.values.select { |group| group.size > 1 }
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
# Stack-safe strict inequality cycle detector using Kahn's topological sort algorithm
|
271
|
+
#
|
272
|
+
# This module implements iterative cycle detection to avoid SystemStackError on deep graphs.
|
273
|
+
# Uses Kahn's algorithm: if topological sort cannot order all vertices, a cycle exists.
|
274
|
+
module StrictInequalitySolver
|
275
|
+
module_function
|
276
|
+
|
277
|
+
# Detects cycles in directed graph using stack-safe Kahn's topological sort
|
278
|
+
#
|
279
|
+
# @param edges [Array<Edge>] directed edges representing strict inequalities
|
280
|
+
# @param debug [Boolean] enable debug output
|
281
|
+
# @return [Boolean] true if cycle exists
|
282
|
+
def cycle?(edges, debug: false)
|
283
|
+
return false if edges.empty?
|
284
|
+
|
285
|
+
graph, in_degree = build_graph_with_degrees(edges)
|
286
|
+
processed_count = kahns_algorithm(graph, in_degree)
|
287
|
+
|
288
|
+
detect_cycle_from_processing_count(processed_count, graph.size, debug)
|
289
|
+
end
|
290
|
+
|
291
|
+
def kahns_algorithm(graph, in_degree)
|
292
|
+
queue = graph.keys.select { |v| in_degree[v].zero? }
|
293
|
+
processed_count = 0
|
294
|
+
|
295
|
+
until queue.empty?
|
296
|
+
vertex = queue.shift
|
297
|
+
processed_count += 1
|
298
|
+
|
299
|
+
graph[vertex].each do |neighbor|
|
300
|
+
in_degree[neighbor] -= 1
|
301
|
+
queue << neighbor if in_degree[neighbor].zero?
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
processed_count
|
306
|
+
end
|
307
|
+
|
308
|
+
def detect_cycle_from_processing_count(processed_count, total_vertices, debug)
|
309
|
+
has_cycle = processed_count < total_vertices
|
310
|
+
puts "cycle detected in strict inequality graph" if has_cycle && debug
|
311
|
+
has_cycle
|
312
|
+
end
|
313
|
+
|
314
|
+
# Builds adjacency list graph and in-degree counts from edges
|
315
|
+
# Pre-populates all vertices (including those with no outgoing edges) to avoid mutation during iteration
|
316
|
+
#
|
317
|
+
# @param edges [Array<Edge>] directed edges
|
318
|
+
# @return [Array(Hash, Hash)] [adjacency_list, in_degree_counts]
|
319
|
+
def build_graph_with_degrees(edges)
|
320
|
+
vertices = collect_all_vertices(edges)
|
321
|
+
graph, in_degree = initialize_graph_structures(vertices)
|
322
|
+
populate_graph_data(edges, graph, in_degree)
|
323
|
+
[graph, in_degree]
|
324
|
+
end
|
325
|
+
|
326
|
+
def collect_all_vertices(edges)
|
327
|
+
vertices = Set.new
|
328
|
+
edges.each { |e| vertices << e.from << e.to }
|
329
|
+
vertices
|
330
|
+
end
|
331
|
+
|
332
|
+
def initialize_graph_structures(vertices)
|
333
|
+
graph = Hash.new { |h, k| h[k] = [] }
|
334
|
+
in_degree = Hash.new(0)
|
335
|
+
vertices.each do |v|
|
336
|
+
graph[v]
|
337
|
+
in_degree[v] = 0
|
338
|
+
end
|
339
|
+
[graph, in_degree]
|
340
|
+
end
|
341
|
+
|
342
|
+
def populate_graph_data(edges, graph, in_degree)
|
343
|
+
edges.each do |edge|
|
344
|
+
graph[edge.from] << edge.to
|
345
|
+
in_degree[edge.to] += 1
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
349
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
class CompiledSchema
|
5
|
+
attr_reader :bindings
|
6
|
+
|
7
|
+
def initialize(bindings)
|
8
|
+
@bindings = bindings.freeze
|
9
|
+
end
|
10
|
+
|
11
|
+
def evaluate(ctx, *key_names)
|
12
|
+
target_keys = key_names.empty? ? @bindings.keys : validate_keys(key_names)
|
13
|
+
|
14
|
+
target_keys.each_with_object({}) do |key, result|
|
15
|
+
result[key] = evaluate_binding(key, ctx)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def evaluate_binding(key, ctx)
|
20
|
+
memo = ctx.instance_variable_get(:@__schema_cache__)
|
21
|
+
return memo[key] if memo&.key?(key)
|
22
|
+
|
23
|
+
value = @bindings[key][1].call(ctx)
|
24
|
+
memo[key] = value if memo
|
25
|
+
value
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def hash_like?(obj)
|
31
|
+
obj.respond_to?(:key?) && obj.respond_to?(:[])
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate_keys(keys)
|
35
|
+
unknown_keys = keys - @bindings.keys
|
36
|
+
return keys if unknown_keys.empty?
|
37
|
+
|
38
|
+
raise Kumi::Errors::RuntimeError, "No binding named #{unknown_keys.first}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
# Compiles an analyzed schema into executable lambdas
|
5
|
+
class Compiler
|
6
|
+
# ExprCompilers holds per-node compile implementations
|
7
|
+
module ExprCompilers
|
8
|
+
def compile_literal(expr)
|
9
|
+
v = expr.value
|
10
|
+
->(_ctx) { v }
|
11
|
+
end
|
12
|
+
|
13
|
+
def compile_field_node(expr)
|
14
|
+
compile_field(expr)
|
15
|
+
end
|
16
|
+
|
17
|
+
def compile_binding_node(expr)
|
18
|
+
fn = @bindings[expr.name].last
|
19
|
+
->(ctx) { fn.call(ctx) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def compile_list(expr)
|
23
|
+
fns = expr.elements.map { |e| compile_expr(e) }
|
24
|
+
->(ctx) { fns.map { |fn| fn.call(ctx) } }
|
25
|
+
end
|
26
|
+
|
27
|
+
def compile_call(expr)
|
28
|
+
fn_name = expr.fn_name
|
29
|
+
arg_fns = expr.args.map { |a| compile_expr(a) }
|
30
|
+
->(ctx) { invoke_function(fn_name, arg_fns, ctx, expr.loc) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def compile_cascade(expr)
|
34
|
+
pairs = expr.cases.map { |c| [compile_expr(c.condition), compile_expr(c.result)] }
|
35
|
+
lambda do |ctx|
|
36
|
+
pairs.each { |cond, res| return res.call(ctx) if cond.call(ctx) }
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
include ExprCompilers
|
43
|
+
|
44
|
+
# Map node classes to compiler methods
|
45
|
+
DISPATCH = {
|
46
|
+
Syntax::TerminalExpressions::Literal => :compile_literal,
|
47
|
+
Syntax::TerminalExpressions::FieldRef => :compile_field_node,
|
48
|
+
Syntax::TerminalExpressions::Binding => :compile_binding_node,
|
49
|
+
Syntax::Expressions::ListExpression => :compile_list,
|
50
|
+
Syntax::Expressions::CallExpression => :compile_call,
|
51
|
+
Syntax::Expressions::CascadeExpression => :compile_cascade
|
52
|
+
}.freeze
|
53
|
+
|
54
|
+
def self.compile(schema, analyzer:)
|
55
|
+
new(schema, analyzer).compile
|
56
|
+
end
|
57
|
+
|
58
|
+
def initialize(schema, analyzer)
|
59
|
+
@schema = schema
|
60
|
+
@analysis = analyzer
|
61
|
+
@bindings = {}
|
62
|
+
end
|
63
|
+
|
64
|
+
def compile
|
65
|
+
build_index
|
66
|
+
@analysis.topo_order.each do |name|
|
67
|
+
decl = @index[name] or raise("Unknown binding #{name}")
|
68
|
+
compile_declaration(decl)
|
69
|
+
end
|
70
|
+
|
71
|
+
CompiledSchema.new(@bindings.freeze)
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def build_index
|
77
|
+
@index = {}
|
78
|
+
@schema.attributes.each { |a| @index[a.name] = a }
|
79
|
+
@schema.traits.each { |t| @index[t.name] = t }
|
80
|
+
end
|
81
|
+
|
82
|
+
def compile_declaration(decl)
|
83
|
+
kind = decl.is_a?(Syntax::Declarations::Trait) ? :trait : :attr
|
84
|
+
fn = compile_expr(decl.expression)
|
85
|
+
@bindings[decl.name] = [kind, fn]
|
86
|
+
end
|
87
|
+
|
88
|
+
# Dispatch to the appropriate compile_* method
|
89
|
+
def compile_expr(expr)
|
90
|
+
method = DISPATCH.fetch(expr.class)
|
91
|
+
send(method, expr)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Existing helpers unchanged
|
95
|
+
def compile_field(node)
|
96
|
+
name = node.name
|
97
|
+
loc = node.loc
|
98
|
+
lambda do |ctx|
|
99
|
+
return ctx[name] if ctx.respond_to?(:key?) && ctx.key?(name)
|
100
|
+
|
101
|
+
raise Errors::RuntimeError,
|
102
|
+
"Key '#{name}' not found at #{loc}. Available: #{ctx.respond_to?(:keys) ? ctx.keys.join(', ') : 'N/A'}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def invoke_function(name, arg_fns, ctx, loc)
|
107
|
+
fn = FunctionRegistry.fetch(name)
|
108
|
+
values = arg_fns.map { |fn| fn.call(ctx) }
|
109
|
+
fn.call(*values)
|
110
|
+
rescue StandardError => e
|
111
|
+
# Preserve original error class and backtrace while adding context
|
112
|
+
enhanced_message = "Error calling fn(:#{name}) at #{loc}: #{e.message}"
|
113
|
+
|
114
|
+
if e.is_a?(Kumi::Errors::Error)
|
115
|
+
# Re-raise Kumi errors with enhanced message but preserve type
|
116
|
+
e.define_singleton_method(:message) { enhanced_message }
|
117
|
+
raise e
|
118
|
+
else
|
119
|
+
# For non-Kumi errors, wrap in RuntimeError but preserve original error info
|
120
|
+
runtime_error = Errors::RuntimeError.new(enhanced_message)
|
121
|
+
runtime_error.set_backtrace(e.backtrace)
|
122
|
+
runtime_error.define_singleton_method(:cause) { e }
|
123
|
+
raise runtime_error
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Domain
|
5
|
+
class EnumAnalyzer
|
6
|
+
def self.analyze(enum)
|
7
|
+
{
|
8
|
+
type: :enumeration,
|
9
|
+
values: enum,
|
10
|
+
size: enum.size,
|
11
|
+
sample_values: generate_samples(enum),
|
12
|
+
invalid_samples: generate_invalid_samples(enum)
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.generate_samples(enum)
|
17
|
+
enum.sample([enum.size, 3].min)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.generate_invalid_samples(enum)
|
21
|
+
case enum.first
|
22
|
+
when String
|
23
|
+
generate_string_invalid_samples(enum)
|
24
|
+
when Integer
|
25
|
+
generate_integer_invalid_samples(enum)
|
26
|
+
when Symbol
|
27
|
+
generate_symbol_invalid_samples(enum)
|
28
|
+
else
|
29
|
+
generate_default_invalid_samples
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private_class_method def self.generate_string_invalid_samples(enum)
|
34
|
+
candidates = ["invalid_string", "", "not_in_list"]
|
35
|
+
candidates.reject { |v| enum.include?(v) }
|
36
|
+
end
|
37
|
+
|
38
|
+
private_class_method def self.generate_integer_invalid_samples(enum)
|
39
|
+
candidates = [-999, 0, 999]
|
40
|
+
candidates.reject { |v| enum.include?(v) }
|
41
|
+
end
|
42
|
+
|
43
|
+
private_class_method def self.generate_symbol_invalid_samples(enum)
|
44
|
+
candidates = %i[invalid_symbol not_in_list]
|
45
|
+
candidates.reject { |v| enum.include?(v) }
|
46
|
+
end
|
47
|
+
|
48
|
+
private_class_method def self.generate_default_invalid_samples
|
49
|
+
[nil, "invalid", -1]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Domain
|
5
|
+
class RangeAnalyzer
|
6
|
+
def self.analyze(range)
|
7
|
+
{
|
8
|
+
type: :range,
|
9
|
+
min: range.begin,
|
10
|
+
max: range.end,
|
11
|
+
exclusive_end: range.exclude_end?,
|
12
|
+
size: calculate_size(range),
|
13
|
+
sample_values: generate_samples(range),
|
14
|
+
boundary_values: [range.begin, range.end],
|
15
|
+
invalid_samples: generate_invalid_samples(range)
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.calculate_size(range)
|
20
|
+
return :infinite if range.begin.nil? || range.end.nil?
|
21
|
+
return :large if range.end - range.begin > 1000
|
22
|
+
|
23
|
+
if integer_range?(range)
|
24
|
+
range.exclude_end? ? range.end - range.begin : range.end - range.begin + 1
|
25
|
+
else
|
26
|
+
:continuous
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.generate_samples(range)
|
31
|
+
samples = [range.begin]
|
32
|
+
|
33
|
+
samples << calculate_midpoint(range) if numeric_range?(range)
|
34
|
+
|
35
|
+
samples << calculate_endpoint(range)
|
36
|
+
samples.uniq
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.generate_invalid_samples(range)
|
40
|
+
invalid = []
|
41
|
+
|
42
|
+
invalid << calculate_before_start(range) if range.begin.is_a?(Numeric)
|
43
|
+
|
44
|
+
invalid << calculate_after_end(range) if range.end.is_a?(Numeric)
|
45
|
+
|
46
|
+
invalid
|
47
|
+
end
|
48
|
+
|
49
|
+
private_class_method def self.integer_range?(range)
|
50
|
+
range.begin.is_a?(Integer) && range.end.is_a?(Integer)
|
51
|
+
end
|
52
|
+
|
53
|
+
private_class_method def self.numeric_range?(range)
|
54
|
+
range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
|
55
|
+
end
|
56
|
+
|
57
|
+
private_class_method def self.calculate_midpoint(range)
|
58
|
+
mid = (range.begin + range.end) / 2.0
|
59
|
+
range.begin.is_a?(Integer) ? mid.round : mid
|
60
|
+
end
|
61
|
+
|
62
|
+
private_class_method def self.calculate_endpoint(range)
|
63
|
+
if range.exclude_end?
|
64
|
+
range.end - (range.begin.is_a?(Integer) ? 1 : 0.1)
|
65
|
+
else
|
66
|
+
range.end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private_class_method def self.calculate_before_start(range)
|
71
|
+
range.begin - (range.begin.is_a?(Integer) ? 1 : 0.1)
|
72
|
+
end
|
73
|
+
|
74
|
+
private_class_method def self.calculate_after_end(range)
|
75
|
+
if range.exclude_end?
|
76
|
+
range.end
|
77
|
+
else
|
78
|
+
range.end + (range.end.is_a?(Integer) ? 1 : 0.1)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|