decision_agent 0.2.0 → 0.3.0

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -1
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/dmn/adapter.rb +135 -0
  5. data/lib/decision_agent/dmn/cache.rb +306 -0
  6. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  7. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  8. data/lib/decision_agent/dmn/errors.rb +30 -0
  9. data/lib/decision_agent/dmn/exporter.rb +217 -0
  10. data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
  11. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  12. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  13. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  14. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  15. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  16. data/lib/decision_agent/dmn/importer.rb +77 -0
  17. data/lib/decision_agent/dmn/model.rb +197 -0
  18. data/lib/decision_agent/dmn/parser.rb +191 -0
  19. data/lib/decision_agent/dmn/testing.rb +333 -0
  20. data/lib/decision_agent/dmn/validator.rb +315 -0
  21. data/lib/decision_agent/dmn/versioning.rb +229 -0
  22. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  23. data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
  24. data/lib/decision_agent/dsl/schema_validator.rb +2 -1
  25. data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
  26. data/lib/decision_agent/version.rb +1 -1
  27. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  28. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  29. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  30. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  31. data/lib/decision_agent/web/public/index.html +3 -0
  32. data/lib/decision_agent/web/public/styles.css +21 -0
  33. data/lib/decision_agent/web/server.rb +465 -0
  34. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  35. data/spec/auth/rbac_adapter_spec.rb +228 -0
  36. data/spec/dmn/decision_graph_spec.rb +282 -0
  37. data/spec/dmn/decision_tree_spec.rb +203 -0
  38. data/spec/dmn/feel/errors_spec.rb +18 -0
  39. data/spec/dmn/feel/functions_spec.rb +400 -0
  40. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  41. data/spec/dmn/feel/types_spec.rb +176 -0
  42. data/spec/dmn/feel_parser_spec.rb +489 -0
  43. data/spec/dmn/hit_policy_spec.rb +202 -0
  44. data/spec/dmn/integration_spec.rb +226 -0
  45. data/spec/examples.txt +1846 -1570
  46. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  47. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  48. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  49. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  50. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  51. data/spec/performance_optimizations_spec.rb +10 -3
  52. data/spec/thread_safety_spec.rb +10 -2
  53. data/spec/web_ui_rack_spec.rb +294 -0
  54. metadata +65 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9fa9e4bed91c21f5402c19a902132014c30ab88b2acae6416d51831a544a0da
4
- data.tar.gz: df95ffc19c834fc4a6c6bfe9fa72544d958e6715fef217e6ca680171c095caa8
3
+ metadata.gz: '095f5b6604126a4288548c648efbfb3f88cc94bf2a51ba84b21f422286c9c0f3'
4
+ data.tar.gz: 0f5475b14f32d3f7524ba63d21152bda4d088312dc1b154a6c15dd73f8e038e0
5
5
  SHA512:
6
- metadata.gz: baf0c6e8b0a5895883cf6d3186f9f6576417a7bf943032aded6c5a989617a3dace19cb5f8d949d5488b10499d437f62519842ea22cc05751b9e5111ba3bc3860
7
- data.tar.gz: 4196b121c6c061e0271278fba1f2861c830b6b358d09e0cf8cd29859aa05b7049b6eb3272f6ba961b9f9bc55a39786e1f1eb43f8728fb9039a82186da3ba9ae0
6
+ metadata.gz: 60f10c33779fac9cf26ed698a704b91e4ff65ca200d76870fa593a9074506b10a7791a471ab5cbb0fcdc9603145dc965131508619f0f1356f163cdaf069f49c9
7
+ data.tar.gz: d60e5eaf6d7350a55047594481c00360fa188e66db11e84cf863c7c734d1210c777e7fb358a689470d84256dd225b22376deb50648443dd0257a5041816086c1
data/README.md CHANGED
@@ -80,7 +80,8 @@ See [Code Examples](docs/CODE_EXAMPLES.md) for more comprehensive examples.
80
80
  - **Pluggable Architecture** - Custom evaluators, scoring, audit adapters
81
81
  - **Framework Agnostic** - Works with Rails, Sinatra, or standalone
82
82
  - **JSON Rule DSL** - Non-technical users can write rules
83
- - **Visual Rule Builder** - Web UI for rule management
83
+ - **DMN 1.3 Support** - Industry-standard Decision Model and Notation with full FEEL expression language
84
+ - **Visual Rule Builder** - Web UI for rule management and DMN modeler
84
85
 
85
86
  ### Production Features
86
87
  - **Real-time Monitoring** - Live dashboard with WebSocket updates
@@ -121,6 +122,40 @@ end
121
122
 
122
123
  See [Web UI Integration Guide](docs/WEB_UI_RAILS_INTEGRATION.md) for detailed setup.
123
124
 
125
+ ## DMN (Decision Model and Notation) Support
126
+
127
+ DecisionAgent includes full support for **DMN 1.3**, the industry standard for decision modeling:
128
+
129
+ ```ruby
130
+ require 'decision_agent'
131
+ require 'decision_agent/dmn/importer'
132
+ require 'decision_agent/evaluators/dmn_evaluator'
133
+
134
+ # Import DMN XML file
135
+ importer = DecisionAgent::Dmn::Importer.new
136
+ result = importer.import('path/to/model.dmn', created_by: 'user@example.com')
137
+
138
+ # Create DMN evaluator
139
+ evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
140
+ model: result[:model],
141
+ decision_id: 'loan_approval'
142
+ )
143
+
144
+ # Use with Agent
145
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
146
+ result = agent.decide(context: { amount: 50000, credit_score: 750 })
147
+ ```
148
+
149
+ **Features:**
150
+ - **DMN 1.3 Standard** - Full OMG DMN 1.3 compliance
151
+ - **FEEL Expressions** - Complete FEEL 1.3 language support (arithmetic, logical, functions)
152
+ - **All Hit Policies** - UNIQUE, FIRST, PRIORITY, ANY, COLLECT
153
+ - **Import/Export** - Round-trip conversion with other DMN tools (Camunda, Drools, IBM ODM)
154
+ - **Visual Modeler** - Web-based DMN editor at `/dmn/editor`
155
+ - **CLI Commands** - `decision_agent dmn import` and `decision_agent dmn export`
156
+
157
+ See [DMN Guide](docs/DMN_GUIDE.md) for complete documentation and [DMN Examples](examples/dmn/README.md) for working examples.
158
+
124
159
  ## Monitoring & Analytics
125
160
 
126
161
  Real-time monitoring, metrics, and alerting for production environments.
@@ -170,6 +205,11 @@ See [Monitoring & Analytics Guide](docs/MONITORING_AND_ANALYTICS.md) for complet
170
205
 
171
206
  ### Core Features
172
207
  - [Advanced Operators](docs/ADVANCED_OPERATORS.md) - String, numeric, date/time, collection, and geospatial operators
208
+ - [DMN Guide](docs/DMN_GUIDE.md) - Complete DMN 1.3 support guide
209
+ - [DMN API Reference](docs/DMN_API.md) - DMN API documentation
210
+ - [FEEL Reference](docs/FEEL_REFERENCE.md) - FEEL expression language reference
211
+ - [DMN Migration Guide](docs/DMN_MIGRATION_GUIDE.md) - Migrating from JSON to DMN
212
+ - [DMN Best Practices](docs/DMN_BEST_PRACTICES.md) - DMN modeling best practices
173
213
  - [Versioning System](docs/VERSIONING.md) - Version control for rules
174
214
  - [A/B Testing](docs/AB_TESTING.md) - Compare rule versions with statistical analysis
175
215
  - [Web UI](docs/WEB_UI.md) - Visual rule builder
data/bin/decision_agent CHANGED
@@ -3,6 +3,8 @@
3
3
  require "bundler/setup"
4
4
  require_relative "../lib/decision_agent"
5
5
  require_relative "../lib/decision_agent/web/server"
6
+ require_relative "../lib/decision_agent/dmn/importer"
7
+ require_relative "../lib/decision_agent/dmn/exporter"
6
8
 
7
9
  def print_help
8
10
  puts <<~HELP
@@ -14,6 +16,8 @@ def print_help
14
16
  Commands:
15
17
  web [PORT] Start the web UI rule builder (default port: 4567)
16
18
  validate FILE Validate a rules JSON file
19
+ dmn import FILE Import a DMN XML file
20
+ dmn export RULESET OUTPUT Export a ruleset to DMN XML
17
21
  version Show version
18
22
  help Show this help message
19
23
 
@@ -21,6 +25,8 @@ def print_help
21
25
  decision_agent web # Start web UI on port 4567
22
26
  decision_agent web 8080 # Start web UI on port 8080
23
27
  decision_agent validate rules.json
28
+ decision_agent dmn import loan_decision.dmn
29
+ decision_agent dmn export loan_rules loan_export.dmn
24
30
  decision_agent version
25
31
 
26
32
  For more information, visit:
@@ -74,6 +80,75 @@ def validate_file(filepath)
74
80
  end
75
81
  end
76
82
 
83
+ def dmn_import(filepath, ruleset_name: nil)
84
+ unless File.exist?(filepath)
85
+ puts "❌ Error: File not found: #{filepath}"
86
+ exit 1
87
+ end
88
+
89
+ begin
90
+ puts "📥 Importing DMN file: #{filepath}..."
91
+
92
+ importer = DecisionAgent::Dmn::Importer.new
93
+ result = importer.import(
94
+ filepath,
95
+ ruleset_name: ruleset_name,
96
+ created_by: ENV["USER"] || "cli_user"
97
+ )
98
+
99
+ puts "✅ Import successful!"
100
+ puts " Model: #{result[:model].name}"
101
+ puts " Decisions imported: #{result[:decisions_imported]}"
102
+ puts " Namespace: #{result[:model].namespace}"
103
+
104
+ result[:model].decisions.each do |decision|
105
+ puts " - Decision: #{decision.name} (#{decision.id})"
106
+ if decision.decision_table
107
+ puts " Rules: #{decision.decision_table.rules.size}"
108
+ puts " Hit Policy: #{decision.decision_table.hit_policy}"
109
+ end
110
+ end
111
+
112
+ if result[:versions].any?
113
+ puts ""
114
+ puts " Versions created:"
115
+ result[:versions].each do |version|
116
+ puts " - #{version[:rule_id]}: version #{version[:version]}"
117
+ end
118
+ end
119
+ rescue DecisionAgent::Dmn::InvalidDmnModelError, DecisionAgent::Dmn::DmnParseError => e
120
+ puts "❌ DMN Import Error:"
121
+ puts " #{e.message}"
122
+ exit 1
123
+ rescue StandardError => e
124
+ puts "❌ Unexpected Error:"
125
+ puts " #{e.message}"
126
+ puts " #{e.backtrace.first}" if ENV["DEBUG"]
127
+ exit 1
128
+ end
129
+ end
130
+
131
+ def dmn_export(ruleset_id, output_path)
132
+ puts "📤 Exporting ruleset: #{ruleset_id}..."
133
+
134
+ exporter = DecisionAgent::Dmn::Exporter.new
135
+ dmn_xml = exporter.export(ruleset_id, output_path: output_path)
136
+
137
+ puts "✅ Export successful!"
138
+ puts " Ruleset: #{ruleset_id}"
139
+ puts " Output: #{output_path}"
140
+ puts " Size: #{dmn_xml.bytesize} bytes"
141
+ rescue DecisionAgent::Dmn::InvalidDmnModelError => e
142
+ puts "❌ Export Error:"
143
+ puts " #{e.message}"
144
+ exit 1
145
+ rescue StandardError => e
146
+ puts "❌ Unexpected Error:"
147
+ puts " #{e.message}"
148
+ puts " #{e.backtrace.first}" if ENV["DEBUG"]
149
+ exit 1
150
+ end
151
+
77
152
  # Main CLI handler
78
153
  command = ARGV[0] || "help"
79
154
 
@@ -90,6 +165,35 @@ when "validate"
90
165
  end
91
166
  validate_file(ARGV[1])
92
167
 
168
+ when "dmn"
169
+ subcommand = ARGV[1]
170
+ case subcommand
171
+ when "import"
172
+ if ARGV[2].nil?
173
+ puts "❌ Error: Please provide a DMN file path"
174
+ puts "Usage: decision_agent dmn import <file.xml>"
175
+ exit 1
176
+ end
177
+ ruleset_name = ARGV[3] # Optional ruleset name
178
+ dmn_import(ARGV[2], ruleset_name: ruleset_name)
179
+
180
+ when "export"
181
+ if ARGV[2].nil? || ARGV[3].nil?
182
+ puts "❌ Error: Please provide ruleset ID and output file path"
183
+ puts "Usage: decision_agent dmn export <ruleset> <output.xml>"
184
+ exit 1
185
+ end
186
+ dmn_export(ARGV[2], ARGV[3])
187
+
188
+ else
189
+ puts "❌ Unknown DMN subcommand: #{subcommand || '(none)'}"
190
+ puts ""
191
+ puts "DMN Commands:"
192
+ puts " import FILE Import a DMN XML file"
193
+ puts " export RULESET OUTPUT Export a ruleset to DMN XML"
194
+ exit 1
195
+ end
196
+
93
197
  when "version"
94
198
  puts "DecisionAgent version #{DecisionAgent::VERSION}"
95
199
 
@@ -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