decision_agent 0.2.0 → 1.0.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +313 -8
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/agent.rb +72 -1
  5. data/lib/decision_agent/context.rb +1 -0
  6. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  7. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  8. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  9. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  10. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  11. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  12. data/lib/decision_agent/decision.rb +102 -2
  13. data/lib/decision_agent/dmn/adapter.rb +135 -0
  14. data/lib/decision_agent/dmn/cache.rb +306 -0
  15. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  16. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  17. data/lib/decision_agent/dmn/errors.rb +30 -0
  18. data/lib/decision_agent/dmn/exporter.rb +217 -0
  19. data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
  20. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  21. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  22. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  23. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  24. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  25. data/lib/decision_agent/dmn/importer.rb +77 -0
  26. data/lib/decision_agent/dmn/model.rb +197 -0
  27. data/lib/decision_agent/dmn/parser.rb +191 -0
  28. data/lib/decision_agent/dmn/testing.rb +333 -0
  29. data/lib/decision_agent/dmn/validator.rb +315 -0
  30. data/lib/decision_agent/dmn/versioning.rb +229 -0
  31. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  32. data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
  33. data/lib/decision_agent/dsl/schema_validator.rb +53 -14
  34. data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
  35. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  36. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  37. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  38. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  39. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  40. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  41. data/lib/decision_agent/simulation/errors.rb +18 -0
  42. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  43. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  44. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  45. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  46. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  47. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  48. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  49. data/lib/decision_agent/simulation.rb +17 -0
  50. data/lib/decision_agent/version.rb +1 -1
  51. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  52. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  53. data/lib/decision_agent/web/public/app.js +119 -0
  54. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  55. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  56. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  57. data/lib/decision_agent/web/public/index.html +52 -0
  58. data/lib/decision_agent/web/public/simulation.html +130 -0
  59. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  60. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  61. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  62. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  63. data/lib/decision_agent/web/public/styles.css +86 -0
  64. data/lib/decision_agent/web/server.rb +1059 -23
  65. data/lib/decision_agent.rb +60 -2
  66. metadata +105 -61
  67. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  68. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  69. data/spec/ab_testing/ab_test_spec.rb +0 -270
  70. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
  71. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  72. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  73. data/spec/activerecord_thread_safety_spec.rb +0 -553
  74. data/spec/advanced_operators_spec.rb +0 -3150
  75. data/spec/agent_spec.rb +0 -289
  76. data/spec/api_contract_spec.rb +0 -430
  77. data/spec/audit_adapters_spec.rb +0 -92
  78. data/spec/auth/access_audit_logger_spec.rb +0 -394
  79. data/spec/auth/authenticator_spec.rb +0 -112
  80. data/spec/auth/password_reset_spec.rb +0 -294
  81. data/spec/auth/permission_checker_spec.rb +0 -207
  82. data/spec/auth/permission_spec.rb +0 -73
  83. data/spec/auth/rbac_adapter_spec.rb +0 -550
  84. data/spec/auth/rbac_config_spec.rb +0 -82
  85. data/spec/auth/role_spec.rb +0 -51
  86. data/spec/auth/session_manager_spec.rb +0 -172
  87. data/spec/auth/session_spec.rb +0 -112
  88. data/spec/auth/user_spec.rb +0 -130
  89. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  90. data/spec/context_spec.rb +0 -127
  91. data/spec/decision_agent_spec.rb +0 -96
  92. data/spec/decision_spec.rb +0 -423
  93. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  94. data/spec/dsl_validation_spec.rb +0 -648
  95. data/spec/edge_cases_spec.rb +0 -353
  96. data/spec/evaluation_spec.rb +0 -364
  97. data/spec/evaluation_validator_spec.rb +0 -165
  98. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  99. data/spec/examples.txt +0 -1633
  100. data/spec/issue_verification_spec.rb +0 -759
  101. data/spec/json_rule_evaluator_spec.rb +0 -587
  102. data/spec/monitoring/alert_manager_spec.rb +0 -378
  103. data/spec/monitoring/metrics_collector_spec.rb +0 -499
  104. data/spec/monitoring/monitored_agent_spec.rb +0 -222
  105. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  106. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  107. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  108. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  109. data/spec/performance_optimizations_spec.rb +0 -486
  110. data/spec/replay_edge_cases_spec.rb +0 -699
  111. data/spec/replay_spec.rb +0 -210
  112. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  113. data/spec/scoring_spec.rb +0 -225
  114. data/spec/spec_helper.rb +0 -60
  115. data/spec/testing/batch_test_importer_spec.rb +0 -693
  116. data/spec/testing/batch_test_runner_spec.rb +0 -307
  117. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  118. data/spec/testing/test_result_comparator_spec.rb +0 -392
  119. data/spec/testing/test_scenario_spec.rb +0 -113
  120. data/spec/thread_safety_spec.rb +0 -482
  121. data/spec/thread_safety_spec.rb.broken +0 -878
  122. data/spec/versioning/adapter_spec.rb +0 -156
  123. data/spec/versioning_spec.rb +0 -1030
  124. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  125. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  126. data/spec/web_ui_rack_spec.rb +0 -1840
@@ -14,16 +14,116 @@ module DecisionAgent
14
14
  freeze
15
15
  end
16
16
 
17
- def to_h
17
+ # Returns array of condition descriptions that led to this decision
18
+ # @param verbose [Boolean] If true, returns detailed condition information
19
+ # @return [Array<String>] Array of condition descriptions
20
+ def because(verbose: false)
21
+ all_explainability_results.flat_map { |er| er.because(verbose: verbose) }
22
+ end
23
+
24
+ # Returns array of condition descriptions that failed
25
+ # @param verbose [Boolean] If true, returns detailed condition information
26
+ # @return [Array<String>] Array of failed condition descriptions
27
+ def failed_conditions(verbose: false)
28
+ all_explainability_results.flat_map { |er| er.failed_conditions(verbose: verbose) }
29
+ end
30
+
31
+ # Returns explainability data in machine-readable format
32
+ # @param verbose [Boolean] If true, returns detailed explainability information
33
+ # @return [Hash] Explainability data
34
+ def explainability(verbose: false)
18
35
  {
19
36
  decision: @decision,
37
+ because: because(verbose: verbose),
38
+ failed_conditions: failed_conditions(verbose: verbose),
39
+ rule_traces: verbose ? all_explainability_results.map { |er| er.to_h(verbose: true) } : nil
40
+ }.compact
41
+ end
42
+
43
+ def to_h
44
+ # Structure decision result as explainability by default
45
+ # This makes explainability the primary format for decision results
46
+ explainability_data = explainability(verbose: false)
47
+
48
+ {
49
+ # Explainability fields (primary structure)
50
+ decision: explainability_data[:decision],
51
+ because: explainability_data[:because],
52
+ failed_conditions: explainability_data[:failed_conditions],
53
+ # Additional metadata for completeness
20
54
  confidence: @confidence,
21
55
  explanations: @explanations,
22
56
  evaluations: @evaluations.map(&:to_h),
23
- audit_payload: @audit_payload
57
+ audit_payload: @audit_payload,
58
+ # Full explainability data (includes rule_traces in verbose mode)
59
+ explainability: explainability_data
24
60
  }
25
61
  end
26
62
 
63
+ private
64
+
65
+ def all_explainability_results
66
+ @evaluations.flat_map { |evaluation| extract_explainability_from_evaluation(evaluation) }
67
+ end
68
+
69
+ def extract_explainability_from_evaluation(evaluation)
70
+ return [] unless evaluation.metadata.is_a?(Hash)
71
+ return [] unless evaluation.metadata[:explainability]
72
+
73
+ explainability_data = normalize_hash_keys(evaluation.metadata[:explainability])
74
+ rule_traces = reconstruct_rule_traces(explainability_data)
75
+ evaluator_name = explainability_data[:evaluator_name] || evaluation.evaluator_name
76
+
77
+ [Explainability::ExplainabilityResult.new(
78
+ evaluator_name: evaluator_name,
79
+ rule_traces: rule_traces
80
+ )]
81
+ end
82
+
83
+ def normalize_hash_keys(data)
84
+ return data unless data.is_a?(Hash)
85
+
86
+ data.transform_keys(&:to_sym)
87
+ end
88
+
89
+ def reconstruct_rule_traces(explainability_data)
90
+ rule_traces_data = explainability_data[:rule_traces] || []
91
+ rule_traces_data.map { |rt_data| reconstruct_rule_trace(rt_data) }
92
+ end
93
+
94
+ def reconstruct_rule_trace(rt_data)
95
+ normalized_rt = normalize_hash_keys(rt_data)
96
+ condition_traces = reconstruct_condition_traces(normalized_rt)
97
+
98
+ Explainability::RuleTrace.new(
99
+ rule_id: normalized_rt[:rule_id],
100
+ matched: normalized_rt[:matched],
101
+ condition_traces: condition_traces,
102
+ decision: normalized_rt[:decision],
103
+ weight: normalized_rt[:weight],
104
+ reason: normalized_rt[:reason]
105
+ )
106
+ end
107
+
108
+ def reconstruct_condition_traces(rule_trace_data)
109
+ condition_traces_data = rule_trace_data[:condition_traces] || []
110
+ condition_traces_data.map { |ct_data| reconstruct_condition_trace(ct_data) }
111
+ end
112
+
113
+ def reconstruct_condition_trace(ct_data)
114
+ normalized_ct = normalize_hash_keys(ct_data)
115
+
116
+ Explainability::ConditionTrace.new(
117
+ field: normalized_ct[:field],
118
+ operator: normalized_ct[:operator],
119
+ expected_value: normalized_ct[:expected_value],
120
+ actual_value: normalized_ct[:actual_value],
121
+ result: normalized_ct[:result]
122
+ )
123
+ end
124
+
125
+ public
126
+
27
127
  def ==(other)
28
128
  other.is_a?(Decision) &&
29
129
  @decision == other.decision &&
@@ -0,0 +1,135 @@
1
+ require_relative "feel/evaluator"
2
+
3
+ module DecisionAgent
4
+ module Dmn
5
+ # Converts DMN decision tables to DecisionAgent JSON rule format
6
+ class Adapter
7
+ def initialize(decision_table)
8
+ @table = decision_table
9
+ @feel = Feel::Evaluator.new
10
+ end
11
+
12
+ # Convert DMN decision table to JSON rules
13
+ def to_json_rules
14
+ {
15
+ "version" => "1.0",
16
+ "ruleset" => @table.id,
17
+ "description" => "Converted from DMN decision table",
18
+ "rules" => convert_rules
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def convert_rules
25
+ @table.rules.map.with_index do |rule, idx|
26
+ convert_rule(rule, idx)
27
+ end
28
+ end
29
+
30
+ def convert_rule(rule, idx)
31
+ {
32
+ "id" => rule.id || "rule_#{idx + 1}",
33
+ "if" => build_condition(rule),
34
+ "then" => build_output(rule),
35
+ "description" => rule.description
36
+ }.compact
37
+ end
38
+
39
+ def build_condition(rule)
40
+ # Build 'all' condition combining all input entries
41
+ conditions = []
42
+
43
+ rule.input_entries.each_with_index do |entry, idx|
44
+ next if entry == "-" # Skip "don't care" entries
45
+
46
+ input = @table.inputs[idx]
47
+ condition = convert_feel_to_condition(entry, input.expression || input.label)
48
+ conditions << condition if condition
49
+ end
50
+
51
+ # If no conditions, return a condition that always matches
52
+ # Use a simple true condition instead of empty "all" array
53
+ return { "field" => "__always_match__", "op" => "eq", "value" => true } if conditions.empty?
54
+
55
+ # If only one condition, return it directly
56
+ return conditions.first if conditions.size == 1
57
+
58
+ # Otherwise, wrap in 'all'
59
+ { "all" => conditions }
60
+ end
61
+
62
+ def convert_feel_to_condition(feel_expression, field_name)
63
+ parsed = @feel.parse_expression(feel_expression)
64
+
65
+ # Ensure we have valid operator and value
66
+ operator = parsed[:operator] || "eq"
67
+ value = parsed[:value]
68
+
69
+ # If value is nil, we can't create a valid condition
70
+ if value.nil?
71
+ warn "Warning: FEEL expression '#{feel_expression}' parsed to nil value, skipping"
72
+ return nil
73
+ end
74
+
75
+ {
76
+ "field" => field_name,
77
+ "op" => operator,
78
+ "value" => value
79
+ }
80
+ rescue StandardError => e
81
+ # Log warning and skip invalid expressions
82
+ warn "Warning: Could not parse FEEL expression '#{feel_expression}': #{e.message}"
83
+ nil
84
+ end
85
+
86
+ def build_output(rule)
87
+ # For Phase 2A, we take the first output as the decision
88
+ # Multi-output support in Phase 2B
89
+ output_value = rule.output_entries.first
90
+
91
+ # Parse FEEL expression in output value (remove quotes from string literals)
92
+ parsed_value = parse_output_value(output_value)
93
+
94
+ # Ensure we always have a valid decision value (not nil or empty string)
95
+ parsed_value = "no_decision" if parsed_value.nil? || (parsed_value.is_a?(String) && parsed_value.empty?)
96
+
97
+ {
98
+ "decision" => parsed_value,
99
+ "weight" => 1.0,
100
+ "reason" => rule.description || "DMN rule #{rule.id} matched"
101
+ }
102
+ end
103
+
104
+ def parse_output_value(value)
105
+ # Handle nil values
106
+ return nil if value.nil?
107
+
108
+ # If already not a string, return as-is (number, boolean, etc.)
109
+ return value unless value.is_a?(String)
110
+
111
+ value_str = value.to_s.strip
112
+
113
+ # Return nil for empty strings
114
+ return nil if value_str.empty?
115
+
116
+ # Remove quotes from string literals
117
+ return value_str[1..-2] if value_str.start_with?('"') && value_str.end_with?('"')
118
+
119
+ # Try to parse as number
120
+ if value_str.match?(/^-?\d+\.\d+$/)
121
+ return value_str.to_f
122
+ elsif value_str.match?(/^-?\d+$/)
123
+ return value_str.to_i
124
+ end
125
+
126
+ # Boolean
127
+ return true if value_str.downcase == "true"
128
+ return false if value_str.downcase == "false"
129
+
130
+ # Return as-is (unquoted string)
131
+ value_str
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "zlib"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ # DMN Evaluation Cache
9
+ # Provides caching for DMN model parsing and evaluation results
10
+ class EvaluationCache
11
+ attr_reader :model_cache, :result_cache, :stats
12
+
13
+ def initialize(max_model_cache_size: 100, max_result_cache_size: 1000, ttl: 3600)
14
+ @model_cache = {}
15
+ @result_cache = {}
16
+ @max_model_cache_size = max_model_cache_size
17
+ @max_result_cache_size = max_result_cache_size
18
+ @ttl = ttl # Time to live in seconds
19
+ @mutex = Mutex.new
20
+ @stats = {
21
+ model_cache_hits: 0,
22
+ model_cache_misses: 0,
23
+ result_cache_hits: 0,
24
+ result_cache_misses: 0
25
+ }
26
+ end
27
+
28
+ # Cache a parsed DMN model
29
+ def cache_model(model_id, model)
30
+ @mutex.synchronize do
31
+ # Evict oldest if cache is full
32
+ evict_oldest_model if @model_cache.size >= @max_model_cache_size
33
+
34
+ @model_cache[model_id] = {
35
+ model: model,
36
+ cached_at: Time.now.to_i
37
+ }
38
+ end
39
+ end
40
+
41
+ # Get a cached model
42
+ def get_model(model_id)
43
+ @mutex.synchronize do
44
+ entry = @model_cache[model_id]
45
+
46
+ if entry && !expired?(entry[:cached_at])
47
+ @stats[:model_cache_hits] += 1
48
+ entry[:model]
49
+ else
50
+ @stats[:model_cache_misses] += 1
51
+ @model_cache.delete(model_id) if entry
52
+ nil
53
+ end
54
+ end
55
+ end
56
+
57
+ # Cache an evaluation result
58
+ def cache_result(decision_id, context_hash, result)
59
+ @mutex.synchronize do
60
+ # Evict oldest if cache is full
61
+ evict_oldest_result if @result_cache.size >= @max_result_cache_size
62
+
63
+ cache_key = generate_result_key(decision_id, context_hash)
64
+ @result_cache[cache_key] = {
65
+ result: result,
66
+ cached_at: Time.now.to_i
67
+ }
68
+ end
69
+ end
70
+
71
+ # Get a cached evaluation result
72
+ def get_result(decision_id, context_hash)
73
+ @mutex.synchronize do
74
+ cache_key = generate_result_key(decision_id, context_hash)
75
+ entry = @result_cache[cache_key]
76
+
77
+ if entry && !expired?(entry[:cached_at])
78
+ @stats[:result_cache_hits] += 1
79
+ entry[:result]
80
+ else
81
+ @stats[:result_cache_misses] += 1
82
+ @result_cache.delete(cache_key) if entry
83
+ nil
84
+ end
85
+ end
86
+ end
87
+
88
+ # Clear all caches
89
+ def clear
90
+ @mutex.synchronize do
91
+ @model_cache.clear
92
+ @result_cache.clear
93
+ @stats.each_key { |k| @stats[k] = 0 }
94
+ end
95
+ end
96
+
97
+ # Clear model cache
98
+ def clear_models
99
+ @mutex.synchronize do
100
+ @model_cache.clear
101
+ end
102
+ end
103
+
104
+ # Clear result cache
105
+ def clear_results
106
+ @mutex.synchronize do
107
+ @result_cache.clear
108
+ end
109
+ end
110
+
111
+ # Get cache statistics
112
+ def statistics
113
+ @mutex.synchronize do
114
+ model_hit_rate = calculate_hit_rate(@stats[:model_cache_hits], @stats[:model_cache_misses])
115
+ result_hit_rate = calculate_hit_rate(@stats[:result_cache_hits], @stats[:result_cache_misses])
116
+
117
+ @stats.merge(
118
+ model_cache_size: @model_cache.size,
119
+ result_cache_size: @result_cache.size,
120
+ model_hit_rate: model_hit_rate,
121
+ result_hit_rate: result_hit_rate
122
+ )
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def expired?(cached_at)
129
+ (Time.now.to_i - cached_at) > @ttl
130
+ end
131
+
132
+ def evict_oldest_model
133
+ return if @model_cache.empty?
134
+
135
+ oldest_key = @model_cache.min_by { |_k, v| v[:cached_at] }[0]
136
+ @model_cache.delete(oldest_key)
137
+ end
138
+
139
+ def evict_oldest_result
140
+ return if @result_cache.empty?
141
+
142
+ oldest_key = @result_cache.min_by { |_k, v| v[:cached_at] }[0]
143
+ @result_cache.delete(oldest_key)
144
+ end
145
+
146
+ def generate_result_key(decision_id, context_hash)
147
+ Digest::SHA256.hexdigest("#{decision_id}:#{context_hash}")
148
+ end
149
+
150
+ def calculate_hit_rate(hits, misses)
151
+ total = hits + misses
152
+ total.positive? ? (hits.to_f / total * 100).round(2) : 0
153
+ end
154
+ end
155
+
156
+ # Enhanced DMN Evaluator with Caching
157
+ class CachedDmnEvaluator
158
+ attr_reader :cache, :evaluator
159
+
160
+ def initialize(dmn_model:, decision_id:, cache: nil, enable_caching: true)
161
+ @dmn_model = dmn_model
162
+ @decision_id = decision_id
163
+ @cache = cache || EvaluationCache.new
164
+ @enable_caching = enable_caching
165
+
166
+ # Create the underlying evaluator
167
+ @evaluator = Evaluators::DmnEvaluator.new(
168
+ dmn_model: dmn_model,
169
+ decision_id: decision_id
170
+ )
171
+ end
172
+
173
+ # Evaluate with caching
174
+ def evaluate(context:)
175
+ return @evaluator.evaluate(context: context) unless @enable_caching
176
+
177
+ # Generate context hash for cache key
178
+ context_hash = generate_context_hash(context)
179
+
180
+ # Try to get cached result
181
+ cached_result = @cache.get_result(@decision_id, context_hash)
182
+ return cached_result if cached_result
183
+
184
+ # Evaluate and cache result
185
+ result = @evaluator.evaluate(context: context)
186
+ @cache.cache_result(@decision_id, context_hash, result)
187
+
188
+ result
189
+ end
190
+
191
+ # Warm up cache with common inputs
192
+ def warm_cache(input_samples)
193
+ input_samples.each do |inputs|
194
+ context = Context.new(inputs)
195
+ evaluate(context: context)
196
+ end
197
+ end
198
+
199
+ # Get cache statistics
200
+ def cache_stats
201
+ @cache.statistics
202
+ end
203
+
204
+ # Clear cache
205
+ def clear_cache
206
+ @cache.clear_results
207
+ end
208
+
209
+ private
210
+
211
+ def generate_context_hash(context)
212
+ # Create a deterministic hash of the context
213
+ # Use CRC32 for better performance (much faster than SHA256, still deterministic)
214
+ data = context.is_a?(Context) ? context.to_h : context
215
+
216
+ # For deterministic hashing, sort keys and create a stable representation
217
+ # Use CRC32 which is faster than SHA256 while still being deterministic
218
+ sorted_data = data.sort.to_h
219
+ json_str = sorted_data.to_json
220
+ Zlib.crc32(json_str)
221
+ end
222
+ end
223
+
224
+ # FEEL Expression Cache
225
+ # Caches compiled/parsed FEEL expressions for reuse
226
+ class FeelExpressionCache
227
+ def initialize(max_size: 500)
228
+ @cache = {}
229
+ @max_size = max_size
230
+ @mutex = Mutex.new
231
+ @stats = { hits: 0, misses: 0 }
232
+ end
233
+
234
+ # Cache a parsed FEEL expression
235
+ def cache_expression(expression_string, parsed_expression)
236
+ @mutex.synchronize do
237
+ evict_oldest if @cache.size >= @max_size
238
+
239
+ @cache[expression_string] = {
240
+ expression: parsed_expression,
241
+ accessed_at: Time.now.to_i,
242
+ access_count: 0
243
+ }
244
+ end
245
+ end
246
+
247
+ # Get a cached expression
248
+ def get_expression(expression_string)
249
+ @mutex.synchronize do
250
+ entry = @cache[expression_string]
251
+
252
+ if entry
253
+ @stats[:hits] += 1
254
+ entry[:accessed_at] = Time.now.to_i
255
+ entry[:access_count] += 1
256
+ entry[:expression]
257
+ else
258
+ @stats[:misses] += 1
259
+ nil
260
+ end
261
+ end
262
+ end
263
+
264
+ # Clear cache
265
+ def clear
266
+ @mutex.synchronize do
267
+ @cache.clear
268
+ @stats[:hits] = 0
269
+ @stats[:misses] = 0
270
+ end
271
+ end
272
+
273
+ # Get statistics
274
+ def statistics
275
+ @mutex.synchronize do
276
+ hit_rate = @stats[:hits] + @stats[:misses]
277
+ hit_rate = hit_rate.positive? ? (@stats[:hits].to_f / hit_rate * 100).round(2) : 0
278
+
279
+ {
280
+ size: @cache.size,
281
+ hits: @stats[:hits],
282
+ misses: @stats[:misses],
283
+ hit_rate: hit_rate,
284
+ most_accessed: most_accessed_expressions
285
+ }
286
+ end
287
+ end
288
+
289
+ private
290
+
291
+ def evict_oldest
292
+ return if @cache.empty?
293
+
294
+ # Evict least recently accessed
295
+ oldest_key = @cache.min_by { |_k, v| v[:accessed_at] }[0]
296
+ @cache.delete(oldest_key)
297
+ end
298
+
299
+ def most_accessed_expressions
300
+ @cache.sort_by { |_k, v| -v[:access_count] }.first(5).map do |expr, data|
301
+ { expression: expr, count: data[:access_count] }
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end