decision_agent 0.3.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -0,0 +1,318 @@
1
+ require_relative "errors"
2
+
3
+ module DecisionAgent
4
+ module Simulation
5
+ # Engine for managing and executing test scenarios
6
+ class ScenarioEngine
7
+ attr_reader :agent, :version_manager
8
+
9
+ def initialize(agent:, version_manager: nil)
10
+ @agent = agent
11
+ @version_manager = version_manager || Versioning::VersionManager.new
12
+ end
13
+
14
+ # Execute a single scenario
15
+ # @param scenario [Hash] Scenario definition with context and optional metadata
16
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version to use
17
+ # @return [Hash] Scenario execution result
18
+ def execute(scenario:, rule_version: nil)
19
+ context = scenario[:context] || scenario["context"] || scenario
20
+ metadata = scenario[:metadata] || scenario["metadata"] || {}
21
+
22
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
23
+ analysis_agent ||= @agent
24
+
25
+ ctx = context.is_a?(Context) ? context : Context.new(context)
26
+ decision = analysis_agent.decide(context: ctx)
27
+
28
+ {
29
+ scenario_id: scenario[:id] || scenario["id"] || generate_scenario_id,
30
+ context: ctx.to_h,
31
+ decision: decision.decision,
32
+ confidence: decision.confidence,
33
+ explanations: decision.explanations,
34
+ metadata: metadata,
35
+ executed_at: Time.now.utc.iso8601
36
+ }
37
+ end
38
+
39
+ # Execute multiple scenarios
40
+ # @param scenarios [Array<Hash>] Array of scenario definitions
41
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version
42
+ # @param options [Hash] Execution options
43
+ # - :parallel [Boolean] Use parallel execution (default: true)
44
+ # - :thread_count [Integer] Number of threads (default: 4)
45
+ # - :progress_callback [Proc] Progress callback
46
+ # @return [Hash] Batch execution results
47
+ def execute_batch(scenarios:, rule_version: nil, options: {})
48
+ options = {
49
+ parallel: true,
50
+ thread_count: 4,
51
+ progress_callback: nil
52
+ }.merge(options)
53
+
54
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
55
+ analysis_agent ||= @agent
56
+
57
+ results = execute_scenarios_with_progress(
58
+ scenarios, analysis_agent, rule_version, options
59
+ )
60
+
61
+ build_batch_report(results)
62
+ end
63
+
64
+ def execute_scenarios_with_progress(scenarios, analysis_agent, rule_version, options)
65
+ results = []
66
+ mutex = Mutex.new
67
+ progress_tracker = ProgressTracker.new(scenarios.size, options[:progress_callback])
68
+
69
+ if options[:parallel] && scenarios.size > 1
70
+ execute_parallel(scenarios, analysis_agent, options, mutex) do |result|
71
+ mutex.synchronize do
72
+ results << result
73
+ progress_tracker.increment
74
+ end
75
+ end
76
+ else
77
+ execute_sequential(scenarios, rule_version, results, progress_tracker)
78
+ end
79
+
80
+ results
81
+ end
82
+
83
+ def execute_sequential(scenarios, rule_version, results, progress_tracker)
84
+ scenarios.each_with_index do |scenario, _index|
85
+ result = execute(scenario: scenario, rule_version: rule_version)
86
+ results << result
87
+ progress_tracker.increment
88
+ end
89
+ end
90
+
91
+ # Helper class for tracking progress
92
+ class ProgressTracker
93
+ def initialize(total, callback)
94
+ @total = total
95
+ @callback = callback
96
+ @completed = 0
97
+ end
98
+
99
+ def increment
100
+ @completed += 1
101
+ return unless @callback
102
+
103
+ @callback.call(
104
+ completed: @completed,
105
+ total: @total,
106
+ percentage: (@completed.to_f / @total * 100).round(2)
107
+ )
108
+ end
109
+ end
110
+
111
+ # Compare scenarios across different rule versions
112
+ # @param scenarios [Array<Hash>] Scenarios to test
113
+ # @param versions [Array<String, Integer, Hash>] Rule versions to compare
114
+ # @param options [Hash] Execution options
115
+ # @return [Hash] Comparison results
116
+ def compare_versions(scenarios:, versions:, options: {})
117
+ options = {
118
+ parallel: true,
119
+ thread_count: 4
120
+ }.merge(options)
121
+
122
+ version_results = {}
123
+ versions.each do |version|
124
+ results = execute_batch(scenarios: scenarios, rule_version: version, options: options)
125
+ version_id = version.is_a?(Hash) ? (version[:id] || version["id"]) : version
126
+ version_results[version_id.to_s] = results
127
+ end
128
+
129
+ {
130
+ scenarios: scenarios,
131
+ versions: versions.map { |v| v.is_a?(Hash) ? (v[:id] || v["id"]) : v },
132
+ results_by_version: version_results,
133
+ comparison: build_version_comparison(version_results)
134
+ }
135
+ end
136
+
137
+ private
138
+
139
+ def build_agent_from_version(version)
140
+ version_hash = resolve_version(version)
141
+ evaluators = build_evaluators_from_version(version_hash)
142
+ Agent.new(
143
+ evaluators: evaluators,
144
+ scoring_strategy: @agent.scoring_strategy,
145
+ audit_adapter: Audit::NullAdapter.new
146
+ )
147
+ end
148
+
149
+ def resolve_version(version)
150
+ case version
151
+ when String, Integer
152
+ version_data = @version_manager.get_version(version_id: version)
153
+ raise VersionComparisonError, "Version not found: #{version}" unless version_data
154
+
155
+ version_data
156
+ when Hash
157
+ version
158
+ else
159
+ raise VersionComparisonError, "Invalid version format: #{version.class}"
160
+ end
161
+ end
162
+
163
+ def build_evaluators_from_version(version)
164
+ content = version[:content] || version["content"]
165
+ return @agent.evaluators unless content
166
+
167
+ if content.is_a?(Hash) && content[:evaluators]
168
+ build_evaluators_from_config(content[:evaluators])
169
+ elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
170
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
171
+ else
172
+ @agent.evaluators
173
+ end
174
+ end
175
+
176
+ def build_evaluators_from_config(configs)
177
+ Array(configs).map do |config|
178
+ case config[:type] || config["type"]
179
+ when "json_rule"
180
+ Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
181
+ when "dmn"
182
+ model = config[:model] || config["model"]
183
+ decision_id = config[:decision_id] || config["decision_id"]
184
+ Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
185
+ else
186
+ raise VersionComparisonError, "Unknown evaluator type: #{config[:type]}"
187
+ end
188
+ end
189
+ end
190
+
191
+ def execute_parallel(scenarios, analysis_agent, options, _mutex, &block)
192
+ thread_count = [options[:thread_count], scenarios.size].min
193
+ queue = Queue.new
194
+ scenarios.each { |s| queue << s }
195
+
196
+ threads = Array.new(thread_count) do
197
+ Thread.new do
198
+ process_scenarios_from_queue(queue, analysis_agent, &block)
199
+ end
200
+ end
201
+
202
+ threads.each(&:join)
203
+ end
204
+
205
+ def process_scenarios_from_queue(queue, analysis_agent)
206
+ loop do
207
+ scenario = dequeue_scenario(queue)
208
+ break unless scenario
209
+
210
+ result = process_scenario(scenario, analysis_agent)
211
+ yield result
212
+ end
213
+ end
214
+
215
+ def dequeue_scenario(queue)
216
+ queue.pop(true)
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def process_scenario(scenario, analysis_agent)
222
+ context = extract_scenario_context(scenario)
223
+ metadata = extract_scenario_metadata(scenario)
224
+ ctx = context.is_a?(Context) ? context : Context.new(context)
225
+
226
+ begin
227
+ decision = analysis_agent.decide(context: ctx)
228
+ build_scenario_result(scenario, ctx, decision, metadata)
229
+ rescue NoEvaluationsError
230
+ build_error_result(scenario, ctx, metadata)
231
+ end
232
+ end
233
+
234
+ def extract_scenario_context(scenario)
235
+ scenario[:context] || scenario["context"] || scenario
236
+ end
237
+
238
+ def extract_scenario_metadata(scenario)
239
+ scenario[:metadata] || scenario["metadata"] || {}
240
+ end
241
+
242
+ def build_scenario_result(scenario, context, decision, metadata)
243
+ {
244
+ scenario_id: extract_scenario_id(scenario),
245
+ context: context.to_h,
246
+ decision: decision.decision,
247
+ confidence: decision.confidence,
248
+ explanations: decision.explanations,
249
+ metadata: metadata,
250
+ executed_at: Time.now.utc.iso8601
251
+ }
252
+ end
253
+
254
+ def build_error_result(scenario, context, metadata)
255
+ {
256
+ scenario_id: extract_scenario_id(scenario),
257
+ context: context.to_h,
258
+ decision: nil,
259
+ confidence: 0.0,
260
+ explanations: [],
261
+ metadata: metadata,
262
+ executed_at: Time.now.utc.iso8601,
263
+ error: "No evaluators returned a decision"
264
+ }
265
+ end
266
+
267
+ def extract_scenario_id(scenario)
268
+ scenario[:id] || scenario["id"] || generate_scenario_id
269
+ end
270
+
271
+ def build_batch_report(results)
272
+ {
273
+ total_scenarios: results.size,
274
+ decision_distribution: results.group_by { |r| r[:decision] }.transform_values(&:count),
275
+ average_confidence: calculate_average_confidence(results),
276
+ min_confidence: results.map { |r| r[:confidence] }.min || 0,
277
+ max_confidence: results.map { |r| r[:confidence] }.max || 0,
278
+ results: results
279
+ }
280
+ end
281
+
282
+ def build_version_comparison(version_results)
283
+ comparison = {}
284
+ version_ids = version_results.keys
285
+
286
+ # Compare decision distributions
287
+ decision_comparison = {}
288
+ version_ids.each do |version_id|
289
+ results = version_results[version_id][:results] || []
290
+ decision_comparison[version_id] = results.group_by { |r| r[:decision] }.transform_values(&:count)
291
+ end
292
+
293
+ comparison[:decision_distributions] = decision_comparison
294
+
295
+ # Compare average confidence
296
+ confidence_comparison = {}
297
+ version_ids.each do |version_id|
298
+ results = version_results[version_id][:results] || []
299
+ confidences = results.map { |r| r[:confidence] }.compact
300
+ confidence_comparison[version_id] = confidences.any? ? confidences.sum / confidences.size : 0
301
+ end
302
+
303
+ comparison[:average_confidence] = confidence_comparison
304
+
305
+ comparison
306
+ end
307
+
308
+ def calculate_average_confidence(results)
309
+ confidences = results.map { |r| r[:confidence] }.compact
310
+ confidences.any? ? confidences.sum / confidences.size : 0
311
+ end
312
+
313
+ def generate_scenario_id
314
+ "scenario_#{Time.now.to_f}_#{rand(1000)}"
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,163 @@
1
+ module DecisionAgent
2
+ module Simulation
3
+ # Library of pre-defined test scenario templates
4
+ class ScenarioLibrary
5
+ # Get scenario template by name
6
+ # @param template_name [String, Symbol] Template name
7
+ # @return [Hash, nil] Scenario template or nil if not found
8
+ def self.get_template(template_name)
9
+ templates[template_name.to_sym] || templates[template_name.to_s]
10
+ end
11
+
12
+ # List all available templates
13
+ # @return [Array<String>] Array of template names
14
+ def self.list_templates
15
+ templates.keys.map(&:to_s)
16
+ end
17
+
18
+ # Create scenario from template
19
+ # @param template_name [String, Symbol] Template name
20
+ # @param overrides [Hash] Values to override in template
21
+ # @return [Hash] Scenario definition
22
+ def self.create_scenario(template_name, overrides: {})
23
+ template = get_template(template_name)
24
+ raise DecisionAgent::Simulation::ScenarioExecutionError, "Template not found: #{template_name}" unless template
25
+
26
+ scenario = template.dup
27
+ merge_overrides(scenario, overrides)
28
+ scenario
29
+ end
30
+
31
+ # Get edge case scenarios for a given context structure
32
+ # @param base_context [Hash] Base context to generate edge cases from
33
+ # @return [Array<Hash>] Array of edge case scenarios
34
+ def self.generate_edge_cases(base_context)
35
+ scenarios = []
36
+
37
+ # Generate scenarios with nil values
38
+ base_context.each_key do |key|
39
+ edge_scenario = base_context.dup
40
+ edge_scenario[key] = nil
41
+ scenarios << { context: edge_scenario, metadata: { type: "edge_case", field: key, value: "nil" } }
42
+ end
43
+
44
+ # Generate scenarios with extreme numeric values and empty strings
45
+ base_context.each do |key, value|
46
+ if value.is_a?(Numeric)
47
+ # Zero value
48
+ zero_scenario = base_context.dup
49
+ zero_scenario[key] = 0
50
+ scenarios << { context: zero_scenario, metadata: { type: "edge_case", field: key, value: "zero" } }
51
+
52
+ # Negative value (if positive)
53
+ if value.positive?
54
+ neg_scenario = base_context.dup
55
+ neg_scenario[key] = -value
56
+ scenarios << { context: neg_scenario, metadata: { type: "edge_case", field: key, value: "negative" } }
57
+ end
58
+
59
+ # Very large value
60
+ large_scenario = base_context.dup
61
+ large_scenario[key] = value * 1000
62
+ scenarios << { context: large_scenario, metadata: { type: "edge_case", field: key, value: "large" } }
63
+ elsif value.is_a?(String)
64
+ # Empty string
65
+ empty_scenario = base_context.dup
66
+ empty_scenario[key] = ""
67
+ scenarios << { context: empty_scenario, metadata: { type: "edge_case", field: key, value: "empty_string" } }
68
+ end
69
+ end
70
+
71
+ scenarios
72
+ end
73
+
74
+ def self.templates
75
+ loan_approval_templates
76
+ .merge(fraud_detection_templates)
77
+ .merge(pricing_templates)
78
+ end
79
+
80
+ def self.loan_approval_templates
81
+ {
82
+ loan_approval_high_risk: build_loan_scenario(100_000, 550, 30_000, "unemployed", "high_risk"),
83
+ loan_approval_low_risk: build_loan_scenario(50_000, 800, 100_000, "employed", "low_risk"),
84
+ loan_approval_medium_risk: build_loan_scenario(75_000, 650, 60_000, "employed", "medium_risk")
85
+ }
86
+ end
87
+
88
+ def self.build_loan_scenario(amount, credit_score, income, employment_status, risk_level)
89
+ {
90
+ context: {
91
+ amount: amount,
92
+ credit_score: credit_score,
93
+ income: income,
94
+ employment_status: employment_status
95
+ },
96
+ metadata: {
97
+ type: "loan_approval",
98
+ category: risk_level,
99
+ description: "#{risk_level.capitalize.tr('_', ' ')} loan application scenario"
100
+ }
101
+ }
102
+ end
103
+
104
+ def self.fraud_detection_templates
105
+ {
106
+ fraud_detection_suspicious: build_fraud_scenario(10_000, 5, 50, "unusual", "suspicious"),
107
+ fraud_detection_normal: build_fraud_scenario(100, 365, 3, "usual", "normal")
108
+ }
109
+ end
110
+
111
+ def self.build_fraud_scenario(amount, account_age, transaction_count, location, category)
112
+ {
113
+ context: {
114
+ transaction_amount: amount,
115
+ account_age_days: account_age,
116
+ transaction_count_24h: transaction_count,
117
+ location: location
118
+ },
119
+ metadata: {
120
+ type: "fraud_detection",
121
+ category: category,
122
+ description: "#{category.capitalize} transaction scenario"
123
+ }
124
+ }
125
+ end
126
+
127
+ def self.pricing_templates
128
+ {
129
+ pricing_high_value: build_pricing_scenario("premium", 5_000, 10_000, "high", "high_value"),
130
+ pricing_standard: build_pricing_scenario("standard", 100, 500, "medium", "standard")
131
+ }
132
+ end
133
+
134
+ def self.build_pricing_scenario(tier, order_value, loyalty_points, frequency, category)
135
+ {
136
+ context: {
137
+ customer_tier: tier,
138
+ order_value: order_value,
139
+ loyalty_points: loyalty_points,
140
+ purchase_frequency: frequency
141
+ },
142
+ metadata: {
143
+ type: "pricing",
144
+ category: category,
145
+ description: "#{category.capitalize.tr('_', ' ')} customer pricing scenario"
146
+ }
147
+ }
148
+ end
149
+
150
+ def self.merge_overrides(scenario, overrides)
151
+ scenario[:context] = scenario[:context].merge(overrides[:context]) if overrides[:context]
152
+
153
+ scenario[:metadata] = (scenario[:metadata] || {}).merge(overrides[:metadata]) if overrides[:metadata]
154
+
155
+ overrides.each do |key, value|
156
+ next if %i[context metadata].include?(key)
157
+
158
+ scenario[key] = value
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end