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.
- checksums.yaml +4 -4
- data/README.md +313 -8
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- 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 +819 -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 +984 -838
- data/lib/decision_agent/dsl/schema_validator.rb +53 -14
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -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 +52 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +86 -0
- data/lib/decision_agent/web/server.rb +1059 -23
- data/lib/decision_agent.rb +60 -2
- metadata +105 -61
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -550
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1633
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -499
- data/spec/monitoring/monitored_agent_spec.rb +0 -222
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -486
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -482
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -1840
|
@@ -14,16 +14,116 @@ module DecisionAgent
|
|
|
14
14
|
freeze
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
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
|