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.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +65 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '095f5b6604126a4288548c648efbfb3f88cc94bf2a51ba84b21f422286c9c0f3'
|
|
4
|
+
data.tar.gz: 0f5475b14f32d3f7524ba63d21152bda4d088312dc1b154a6c15dd73f8e038e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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
|