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
@@ -0,0 +1,39 @@
1
+ module DecisionAgent
2
+ module Explainability
3
+ # Represents the trace of a rule evaluation including all conditions
4
+ class RuleTrace
5
+ attr_reader :rule_id, :matched, :condition_traces, :decision, :weight, :reason
6
+
7
+ def initialize(rule_id:, matched:, condition_traces: [], decision: nil, weight: nil, reason: nil)
8
+ @rule_id = rule_id.to_s.freeze
9
+ @matched = matched
10
+ @condition_traces = Array(condition_traces).freeze
11
+ @decision = decision ? decision.to_s.freeze : nil
12
+ @weight = weight
13
+ @reason = reason ? reason.to_s.freeze : nil
14
+ freeze
15
+ end
16
+
17
+ def passed_conditions
18
+ @condition_traces.select(&:passed?)
19
+ end
20
+
21
+ def failed_conditions
22
+ @condition_traces.select(&:failed?)
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ rule_id: @rule_id,
28
+ matched: @matched,
29
+ decision: @decision,
30
+ weight: @weight,
31
+ reason: @reason,
32
+ condition_traces: @condition_traces.map(&:to_h),
33
+ passed_conditions: passed_conditions.map(&:description),
34
+ failed_conditions: failed_conditions.map(&:description)
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ module DecisionAgent
2
+ module Explainability
3
+ # Collects condition traces during evaluation
4
+ class TraceCollector
5
+ attr_reader :traces
6
+
7
+ def initialize
8
+ @traces = []
9
+ end
10
+
11
+ def add_trace(trace)
12
+ @traces << trace
13
+ end
14
+
15
+ def clear
16
+ @traces.clear
17
+ end
18
+
19
+ def empty?
20
+ @traces.empty?
21
+ end
22
+ end
23
+ end
24
+ end
@@ -16,6 +16,7 @@ module DecisionAgent
16
16
  @alert_handlers = []
17
17
  @check_interval = 60 # seconds
18
18
  @monitoring_thread = nil
19
+ @rule_counter = 0
19
20
  freeze_config
20
21
  end
21
22
 
@@ -198,7 +199,10 @@ module DecisionAgent
198
199
  end
199
200
 
200
201
  def generate_rule_id(name)
201
- "#{sanitize_name(name)}_#{Time.now.to_i}_#{rand(1000)}"
202
+ synchronize do
203
+ @rule_counter += 1
204
+ "#{sanitize_name(name)}_#{Time.now.to_i}_#{@rule_counter}"
205
+ end
202
206
  end
203
207
 
204
208
  def sanitize_name(name)
@@ -0,0 +1,18 @@
1
+ module DecisionAgent
2
+ module Simulation
3
+ # Base error class for simulation module
4
+ class SimulationError < StandardError; end
5
+
6
+ # Error raised when scenario execution fails
7
+ class ScenarioExecutionError < SimulationError; end
8
+
9
+ # Error raised when historical data is invalid
10
+ class InvalidHistoricalDataError < SimulationError; end
11
+
12
+ # Error raised when version comparison fails
13
+ class VersionComparisonError < SimulationError; end
14
+
15
+ # Error raised when shadow test configuration is invalid
16
+ class InvalidShadowTestError < SimulationError; end
17
+ end
18
+ end
@@ -0,0 +1,498 @@
1
+ require_relative "errors"
2
+
3
+ module DecisionAgent
4
+ module Simulation
5
+ # Analyzer for quantifying rule change impact
6
+ # rubocop:disable Metrics/ClassLength
7
+ class ImpactAnalyzer
8
+ attr_reader :version_manager
9
+
10
+ def initialize(version_manager: nil)
11
+ @version_manager = version_manager || Versioning::VersionManager.new
12
+ end
13
+
14
+ # Analyze impact of a proposed rule change
15
+ # @param baseline_version [String, Integer, Hash] Baseline rule version
16
+ # @param proposed_version [String, Integer, Hash] Proposed rule version
17
+ # @param test_data [Array<Hash>] Test contexts to evaluate
18
+ # @param options [Hash] Analysis options
19
+ # - :parallel [Boolean] Use parallel execution (default: true)
20
+ # - :thread_count [Integer] Number of threads (default: 4)
21
+ # - :calculate_risk [Boolean] Calculate risk score (default: true)
22
+ # @return [Hash] Impact analysis report
23
+ def analyze(baseline_version:, proposed_version:, test_data:, options: {})
24
+ options = {
25
+ parallel: true,
26
+ thread_count: 4,
27
+ calculate_risk: true
28
+ }.merge(options)
29
+
30
+ baseline_agent = build_agent_from_version(baseline_version)
31
+ proposed_agent = build_agent_from_version(proposed_version)
32
+
33
+ # Execute both versions on test data
34
+ results = execute_comparison(test_data, baseline_agent, proposed_agent, options)
35
+
36
+ # Build impact report
37
+ build_impact_report(results, options)
38
+ end
39
+
40
+ # Calculate risk score for a rule change
41
+ # @param results [Array<Hash>] Comparison results
42
+ # @return [Float] Risk score between 0.0 (low risk) and 1.0 (high risk)
43
+ def calculate_risk_score(results)
44
+ return 0.0 if results.empty?
45
+
46
+ total = results.size
47
+ decision_changes = results.count { |r| r[:decision_changed] }
48
+ large_confidence_shifts = results.count { |r| (r[:confidence_delta] || 0).abs > 0.2 }
49
+ rejections_increased = count_rejection_increases(results)
50
+
51
+ # Risk factors
52
+ change_rate = decision_changes.to_f / total
53
+ confidence_volatility = large_confidence_shifts.to_f / total
54
+ rejection_risk = rejections_increased.to_f / total
55
+
56
+ # Weighted risk score
57
+ risk_score = (
58
+ (change_rate * 0.4) +
59
+ (confidence_volatility * 0.3) +
60
+ (rejection_risk * 0.3)
61
+ )
62
+
63
+ [risk_score, 1.0].min # Cap at 1.0
64
+ end
65
+
66
+ private
67
+
68
+ def build_agent_from_version(version)
69
+ version_hash = resolve_version(version)
70
+ evaluators = build_evaluators_from_version(version_hash)
71
+ Agent.new(
72
+ evaluators: evaluators,
73
+ scoring_strategy: Scoring::WeightedAverage.new,
74
+ audit_adapter: Audit::NullAdapter.new
75
+ )
76
+ end
77
+
78
+ def resolve_version(version)
79
+ case version
80
+ when String, Integer
81
+ version_data = @version_manager.get_version(version_id: version)
82
+ raise VersionComparisonError, "Version not found: #{version}" unless version_data
83
+
84
+ version_data
85
+ when Hash
86
+ version
87
+ else
88
+ raise VersionComparisonError, "Invalid version format: #{version.class}"
89
+ end
90
+ end
91
+
92
+ def build_evaluators_from_version(version)
93
+ content = version[:content] || version["content"]
94
+ raise VersionComparisonError, "Version has no content" unless content
95
+
96
+ if content.is_a?(Hash) && content[:evaluators]
97
+ build_evaluators_from_config(content[:evaluators])
98
+ elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
99
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
100
+ else
101
+ raise VersionComparisonError, "Cannot build evaluators from version content"
102
+ end
103
+ end
104
+
105
+ def build_evaluators_from_config(configs)
106
+ Array(configs).map do |config|
107
+ case config[:type] || config["type"]
108
+ when "json_rule"
109
+ Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
110
+ when "dmn"
111
+ model = config[:model] || config["model"]
112
+ decision_id = config[:decision_id] || config["decision_id"]
113
+ Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
114
+ else
115
+ raise VersionComparisonError, "Unknown evaluator type: #{config[:type]}"
116
+ end
117
+ end
118
+ end
119
+
120
+ def execute_comparison(test_data, baseline_agent, proposed_agent, options)
121
+ results = []
122
+ mutex = Mutex.new
123
+
124
+ if options[:parallel] && test_data.size > 1
125
+ execute_parallel(test_data, baseline_agent, proposed_agent, options, mutex) do |result|
126
+ mutex.synchronize { results << result }
127
+ end
128
+ else
129
+ test_data.each do |context|
130
+ result = execute_single_comparison(context, baseline_agent, proposed_agent)
131
+ results << result
132
+ end
133
+ end
134
+
135
+ results
136
+ end
137
+
138
+ def execute_parallel(test_data, baseline_agent, proposed_agent, options, _mutex)
139
+ thread_count = [options[:thread_count], test_data.size].min
140
+ queue = Queue.new
141
+ test_data.each { |c| queue << c }
142
+
143
+ threads = Array.new(thread_count) do
144
+ Thread.new do
145
+ loop do
146
+ context = begin
147
+ queue.pop(true)
148
+ rescue StandardError
149
+ nil
150
+ end
151
+ break unless context
152
+
153
+ result = execute_single_comparison(context, baseline_agent, proposed_agent)
154
+ yield result
155
+ end
156
+ end
157
+ end
158
+
159
+ threads.each(&:join)
160
+ end
161
+
162
+ def execute_single_comparison(context, baseline_agent, proposed_agent)
163
+ ctx = context.is_a?(Context) ? context : Context.new(context)
164
+
165
+ baseline_metrics = measure_decision_metrics(ctx, baseline_agent, :baseline)
166
+ proposed_metrics = measure_decision_metrics(ctx, proposed_agent, :proposed)
167
+ delta_metrics = calculate_decision_delta(baseline_metrics, proposed_metrics)
168
+
169
+ build_comparison_result(ctx, baseline_metrics, proposed_metrics, delta_metrics)
170
+ end
171
+
172
+ def measure_decision_metrics(context, agent, _label)
173
+ start_time = Time.now
174
+ begin
175
+ decision = agent.decide(context: context)
176
+ duration_ms = (Time.now - start_time) * 1000
177
+ evaluations_count = decision.evaluations&.size || 0
178
+ { decision: decision, duration_ms: duration_ms, evaluations_count: evaluations_count }
179
+ rescue NoEvaluationsError
180
+ duration_ms = (Time.now - start_time) * 1000
181
+ { decision: nil, duration_ms: duration_ms, evaluations_count: 0 }
182
+ end
183
+ end
184
+
185
+ def calculate_decision_delta(baseline_metrics, proposed_metrics)
186
+ baseline_decision = baseline_metrics[:decision]
187
+ proposed_decision = proposed_metrics[:decision]
188
+
189
+ decision_changed, confidence_delta = if baseline_decision.nil? && proposed_decision.nil?
190
+ [false, 0]
191
+ elsif baseline_decision.nil?
192
+ [true, proposed_decision.confidence]
193
+ elsif proposed_decision.nil?
194
+ [true, -baseline_decision.confidence]
195
+ else
196
+ [
197
+ baseline_decision.decision != proposed_decision.decision,
198
+ proposed_decision.confidence - baseline_decision.confidence
199
+ ]
200
+ end
201
+
202
+ baseline_duration = baseline_metrics[:duration_ms]
203
+ proposed_duration = proposed_metrics[:duration_ms]
204
+ performance_delta_ms = proposed_duration - baseline_duration
205
+ performance_delta_percent = baseline_duration.positive? ? (performance_delta_ms / baseline_duration * 100) : 0
206
+
207
+ {
208
+ decision_changed: decision_changed,
209
+ confidence_delta: confidence_delta,
210
+ performance_delta_ms: performance_delta_ms,
211
+ performance_delta_percent: performance_delta_percent
212
+ }
213
+ end
214
+
215
+ def build_comparison_result(context, baseline_metrics, proposed_metrics, delta_metrics)
216
+ baseline_decision = baseline_metrics[:decision]
217
+ proposed_decision = proposed_metrics[:decision]
218
+
219
+ {
220
+ context: context.to_h,
221
+ baseline_decision: baseline_decision&.decision,
222
+ baseline_confidence: baseline_decision&.confidence || 0,
223
+ baseline_duration_ms: baseline_metrics[:duration_ms],
224
+ baseline_evaluations_count: baseline_metrics[:evaluations_count],
225
+ proposed_decision: proposed_decision&.decision,
226
+ proposed_confidence: proposed_decision&.confidence || 0,
227
+ proposed_duration_ms: proposed_metrics[:duration_ms],
228
+ proposed_evaluations_count: proposed_metrics[:evaluations_count],
229
+ decision_changed: delta_metrics[:decision_changed],
230
+ confidence_delta: delta_metrics[:confidence_delta],
231
+ confidence_shift_magnitude: delta_metrics[:confidence_delta].abs,
232
+ performance_delta_ms: delta_metrics[:performance_delta_ms],
233
+ performance_delta_percent: delta_metrics[:performance_delta_percent]
234
+ }
235
+ end
236
+
237
+ def build_impact_report(results, options)
238
+ report = build_base_report(results)
239
+ report[:confidence_impact] = build_confidence_impact(results)
240
+ report[:rule_execution_frequency] = build_rule_frequency(results)
241
+ report[:performance_impact] = calculate_performance_impact(results)
242
+ add_risk_analysis(report, results, options)
243
+ report
244
+ end
245
+
246
+ def build_base_report(results)
247
+ total = results.size
248
+ decision_changes = results.count { |r| r[:decision_changed] }
249
+ baseline_distribution = results.group_by { |r| r[:baseline_decision] }.transform_values(&:count)
250
+ proposed_distribution = results.group_by { |r| r[:proposed_decision] }.transform_values(&:count)
251
+
252
+ {
253
+ total_contexts: total,
254
+ decision_changes: decision_changes,
255
+ change_rate: total.positive? ? (decision_changes.to_f / total) : 0,
256
+ decision_distribution: {
257
+ baseline: baseline_distribution,
258
+ proposed: proposed_distribution
259
+ },
260
+ results: results
261
+ }
262
+ end
263
+
264
+ def build_confidence_impact(results)
265
+ confidence_deltas = results.map { |r| r[:confidence_delta] }.compact
266
+ avg_confidence_delta = confidence_deltas.any? ? confidence_deltas.sum / confidence_deltas.size : 0
267
+ max_confidence_shift = confidence_deltas.map(&:abs).max || 0
268
+
269
+ {
270
+ average_delta: avg_confidence_delta,
271
+ max_shift: max_confidence_shift,
272
+ positive_shifts: confidence_deltas.count(&:positive?),
273
+ negative_shifts: confidence_deltas.count(&:negative?)
274
+ }
275
+ end
276
+
277
+ def build_rule_frequency(results)
278
+ {
279
+ baseline: calculate_rule_frequency(results, :baseline_decision),
280
+ proposed: calculate_rule_frequency(results, :proposed_decision)
281
+ }
282
+ end
283
+
284
+ def add_risk_analysis(report, results, options)
285
+ return unless options[:calculate_risk]
286
+
287
+ report[:risk_score] = calculate_risk_score(results)
288
+ report[:risk_level] = categorize_risk(report[:risk_score])
289
+ end
290
+
291
+ def calculate_rule_frequency(results, decision_key)
292
+ # Approximate rule frequency from decision distribution
293
+ # In a real implementation, this would track which rules fired
294
+ results.group_by { |r| r[decision_key] }.transform_values { |v| v.size.to_f / results.size }
295
+ end
296
+
297
+ def count_rejection_increases(results)
298
+ results.count do |r|
299
+ baseline = r[:baseline_decision].to_s.downcase
300
+ proposed = r[:proposed_decision].to_s.downcase
301
+ (baseline.include?("approve") || baseline.include?("accept")) &&
302
+ (proposed.include?("reject") || proposed.include?("deny"))
303
+ end
304
+ end
305
+
306
+ def categorize_risk(risk_score)
307
+ case risk_score
308
+ when 0.0...0.2
309
+ "low"
310
+ when 0.2...0.5
311
+ "medium"
312
+ when 0.5...0.8
313
+ "high"
314
+ else
315
+ "critical"
316
+ end
317
+ end
318
+
319
+ # Calculate performance impact metrics
320
+ # @param results [Array<Hash>] Comparison results with performance data
321
+ # @return [Hash] Performance impact metrics
322
+ def calculate_performance_impact(results)
323
+ return {} if results.empty?
324
+
325
+ metrics = extract_performance_metrics(results)
326
+ latency_stats = calculate_latency_statistics(metrics)
327
+ throughput_stats = calculate_throughput_statistics(latency_stats)
328
+ complexity_stats = calculate_complexity_statistics(metrics)
329
+ performance_deltas = calculate_performance_deltas(metrics, latency_stats, throughput_stats)
330
+
331
+ build_performance_impact_hash(latency_stats, throughput_stats, complexity_stats, performance_deltas)
332
+ end
333
+
334
+ def extract_performance_metrics(results)
335
+ {
336
+ baseline_durations: results.map { |r| r[:baseline_duration_ms] }.compact,
337
+ proposed_durations: results.map { |r| r[:proposed_duration_ms] }.compact,
338
+ performance_deltas: results.map { |r| r[:performance_delta_ms] }.compact,
339
+ performance_delta_percents: results.map { |r| r[:performance_delta_percent] }.compact,
340
+ baseline_evaluations: results.map { |r| r[:baseline_evaluations_count] }.compact,
341
+ proposed_evaluations: results.map { |r| r[:proposed_evaluations_count] }.compact
342
+ }
343
+ end
344
+
345
+ def calculate_latency_statistics(metrics)
346
+ baseline_durations = metrics[:baseline_durations]
347
+ proposed_durations = metrics[:proposed_durations]
348
+
349
+ {
350
+ baseline_avg: calculate_average(baseline_durations),
351
+ baseline_min: baseline_durations.min || 0,
352
+ baseline_max: baseline_durations.max || 0,
353
+ proposed_avg: calculate_average(proposed_durations),
354
+ proposed_min: proposed_durations.min || 0,
355
+ proposed_max: proposed_durations.max || 0
356
+ }
357
+ end
358
+
359
+ def calculate_throughput_statistics(latency_stats)
360
+ baseline_throughput = latency_stats[:baseline_avg].positive? ? (1000.0 / latency_stats[:baseline_avg]) : 0
361
+ proposed_throughput = latency_stats[:proposed_avg].positive? ? (1000.0 / latency_stats[:proposed_avg]) : 0
362
+
363
+ {
364
+ baseline: baseline_throughput,
365
+ proposed: proposed_throughput
366
+ }
367
+ end
368
+
369
+ def calculate_complexity_statistics(metrics)
370
+ baseline_avg = calculate_average(metrics[:baseline_evaluations], as_float: true)
371
+ proposed_avg = calculate_average(metrics[:proposed_evaluations], as_float: true)
372
+
373
+ {
374
+ baseline_avg: baseline_avg,
375
+ proposed_avg: proposed_avg,
376
+ delta: proposed_avg - baseline_avg
377
+ }
378
+ end
379
+
380
+ def calculate_performance_deltas(metrics, _latency_stats, throughput_stats)
381
+ avg_delta_ms = calculate_average(metrics[:performance_deltas])
382
+ avg_delta_percent = calculate_average(metrics[:performance_delta_percents])
383
+ baseline_throughput = throughput_stats[:baseline]
384
+ proposed_throughput = throughput_stats[:proposed]
385
+ throughput_delta_percent = baseline_throughput.positive? ? ((proposed_throughput - baseline_throughput) / baseline_throughput * 100) : 0
386
+
387
+ {
388
+ avg_delta_ms: avg_delta_ms,
389
+ avg_delta_percent: avg_delta_percent,
390
+ throughput_delta_percent: throughput_delta_percent
391
+ }
392
+ end
393
+
394
+ def calculate_average(values, as_float: false)
395
+ return 0 if values.empty?
396
+
397
+ sum = as_float ? values.sum.to_f : values.sum
398
+ sum / values.size
399
+ end
400
+
401
+ def build_performance_impact_hash(latency_stats, throughput_stats, complexity_stats, performance_deltas)
402
+ {
403
+ latency: build_latency_impact(latency_stats, performance_deltas),
404
+ throughput: build_throughput_impact(throughput_stats, performance_deltas),
405
+ rule_complexity: build_complexity_impact(complexity_stats),
406
+ impact_level: categorize_performance_impact(performance_deltas[:avg_delta_percent]),
407
+ summary: build_performance_summary(
408
+ performance_deltas[:avg_delta_percent],
409
+ performance_deltas[:throughput_delta_percent],
410
+ complexity_stats[:delta]
411
+ )
412
+ }
413
+ end
414
+
415
+ def build_latency_impact(latency_stats, performance_deltas)
416
+ {
417
+ baseline: {
418
+ average_ms: latency_stats[:baseline_avg].round(4),
419
+ min_ms: latency_stats[:baseline_min].round(4),
420
+ max_ms: latency_stats[:baseline_max].round(4)
421
+ },
422
+ proposed: {
423
+ average_ms: latency_stats[:proposed_avg].round(4),
424
+ min_ms: latency_stats[:proposed_min].round(4),
425
+ max_ms: latency_stats[:proposed_max].round(4)
426
+ },
427
+ delta_ms: performance_deltas[:avg_delta_ms].round(4),
428
+ delta_percent: performance_deltas[:avg_delta_percent].round(2)
429
+ }
430
+ end
431
+
432
+ def build_throughput_impact(throughput_stats, performance_deltas)
433
+ {
434
+ baseline_decisions_per_second: throughput_stats[:baseline].round(2),
435
+ proposed_decisions_per_second: throughput_stats[:proposed].round(2),
436
+ delta_percent: performance_deltas[:throughput_delta_percent].round(2)
437
+ }
438
+ end
439
+
440
+ def build_complexity_impact(complexity_stats)
441
+ {
442
+ baseline_avg_evaluations: complexity_stats[:baseline_avg].round(2),
443
+ proposed_avg_evaluations: complexity_stats[:proposed_avg].round(2),
444
+ evaluations_delta: complexity_stats[:delta].round(2)
445
+ }
446
+ end
447
+
448
+ # Categorize performance impact level
449
+ # @param delta_percent [Float] Performance delta percentage
450
+ # @return [String] Impact level: "improvement", "neutral", "minor_degradation", "moderate_degradation", "significant_degradation"
451
+ def categorize_performance_impact(delta_percent)
452
+ case delta_percent
453
+ when -Float::INFINITY...-5.0
454
+ "improvement"
455
+ when -5.0...5.0
456
+ "neutral"
457
+ when 5.0...15.0
458
+ "minor_degradation"
459
+ when 15.0...30.0
460
+ "moderate_degradation"
461
+ else
462
+ "significant_degradation"
463
+ end
464
+ end
465
+
466
+ # Build human-readable performance summary
467
+ # @param latency_delta_percent [Float] Latency delta percentage
468
+ # @param throughput_delta_percent [Float] Throughput delta percentage
469
+ # @param evaluations_delta [Float] Evaluations delta
470
+ # @return [String] Summary text
471
+ def build_performance_summary(latency_delta_percent, throughput_delta_percent, evaluations_delta)
472
+ parts = []
473
+
474
+ if latency_delta_percent.abs > 5.0
475
+ direction = latency_delta_percent.positive? ? "slower" : "faster"
476
+ parts << "Average latency is #{latency_delta_percent.abs.round(2)}% #{direction}"
477
+ end
478
+
479
+ if throughput_delta_percent.abs > 5.0
480
+ direction = throughput_delta_percent.positive? ? "higher" : "lower"
481
+ parts << "Throughput is #{throughput_delta_percent.abs.round(2)}% #{direction}"
482
+ end
483
+
484
+ if evaluations_delta.abs > 0.5
485
+ direction = evaluations_delta.positive? ? "more" : "fewer"
486
+ parts << "Average #{direction} #{evaluations_delta.abs.round(2)} rule evaluations per decision"
487
+ end
488
+
489
+ if parts.empty?
490
+ "Performance impact is minimal (<5% change)"
491
+ else
492
+ "#{parts.join('. ')}."
493
+ end
494
+ end
495
+ # rubocop:enable Metrics/ClassLength
496
+ end
497
+ end
498
+ end