decision_agent 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -14
  3. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  10. data/lib/decision_agent/agent.rb +78 -9
  11. data/lib/decision_agent/audit/adapter.rb +2 -0
  12. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  13. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  14. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  15. data/lib/decision_agent/auth/authenticator.rb +2 -0
  16. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  18. data/lib/decision_agent/auth/permission.rb +2 -0
  19. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  20. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  22. data/lib/decision_agent/auth/role.rb +2 -0
  23. data/lib/decision_agent/auth/session.rb +2 -0
  24. data/lib/decision_agent/auth/session_manager.rb +2 -0
  25. data/lib/decision_agent/auth/user.rb +2 -0
  26. data/lib/decision_agent/context.rb +14 -0
  27. data/lib/decision_agent/decision.rb +113 -4
  28. data/lib/decision_agent/dmn/adapter.rb +2 -0
  29. data/lib/decision_agent/dmn/cache.rb +2 -2
  30. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  31. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  32. data/lib/decision_agent/dmn/errors.rb +2 -0
  33. data/lib/decision_agent/dmn/exporter.rb +2 -0
  34. data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
  35. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  36. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  38. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  39. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  40. data/lib/decision_agent/dmn/importer.rb +2 -0
  41. data/lib/decision_agent/dmn/model.rb +2 -4
  42. data/lib/decision_agent/dmn/parser.rb +2 -0
  43. data/lib/decision_agent/dmn/testing.rb +3 -2
  44. data/lib/decision_agent/dmn/validator.rb +5 -3
  45. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  46. data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
  47. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  48. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  49. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  50. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  51. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  52. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  53. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  54. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  55. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  56. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  57. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  58. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  59. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  60. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  61. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  62. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  63. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  64. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  65. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  66. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  67. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  68. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  69. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  70. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  71. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  72. data/lib/decision_agent/dsl/schema_validator.rb +37 -14
  73. data/lib/decision_agent/errors.rb +2 -0
  74. data/lib/decision_agent/evaluation.rb +14 -2
  75. data/lib/decision_agent/evaluators/base.rb +2 -0
  76. data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
  77. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
  78. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  79. data/lib/decision_agent/explainability/condition_trace.rb +85 -0
  80. data/lib/decision_agent/explainability/explainability_result.rb +50 -0
  81. data/lib/decision_agent/explainability/rule_trace.rb +41 -0
  82. data/lib/decision_agent/explainability/trace_collector.rb +26 -0
  83. data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
  84. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  85. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  87. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  88. data/lib/decision_agent/replay/replay.rb +4 -1
  89. data/lib/decision_agent/scoring/base.rb +2 -0
  90. data/lib/decision_agent/scoring/consensus.rb +2 -0
  91. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  92. data/lib/decision_agent/scoring/threshold.rb +2 -0
  93. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  94. data/lib/decision_agent/simulation/errors.rb +20 -0
  95. data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
  96. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
  97. data/lib/decision_agent/simulation/replay_engine.rb +488 -0
  98. data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
  99. data/lib/decision_agent/simulation/scenario_library.rb +165 -0
  100. data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
  101. data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
  102. data/lib/decision_agent/simulation.rb +19 -0
  103. data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
  104. data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
  105. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  106. data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
  107. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  108. data/lib/decision_agent/version.rb +3 -1
  109. data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
  110. data/lib/decision_agent/versioning/adapter.rb +9 -0
  111. data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
  112. data/lib/decision_agent/versioning/version_manager.rb +9 -0
  113. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  114. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  115. data/lib/decision_agent/web/dmn_editor.rb +8 -67
  116. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  117. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  118. data/lib/decision_agent/web/public/app.js +186 -26
  119. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  120. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  121. data/lib/decision_agent/web/public/dmn-editor.js +74 -8
  122. data/lib/decision_agent/web/public/index.html +69 -3
  123. data/lib/decision_agent/web/public/login.html +1 -1
  124. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  125. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  126. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  127. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  128. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  129. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  130. data/lib/decision_agent/web/public/simulation.html +146 -0
  131. data/lib/decision_agent/web/public/simulation_impact.html +495 -0
  132. data/lib/decision_agent/web/public/simulation_replay.html +547 -0
  133. data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
  134. data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
  135. data/lib/decision_agent/web/public/styles.css +65 -0
  136. data/lib/decision_agent/web/public/users.html +1 -1
  137. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  138. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  139. data/lib/decision_agent/web/server.rb +2126 -1374
  140. data/lib/decision_agent.rb +19 -1
  141. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  142. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  143. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  144. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  145. data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
  146. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  147. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  148. metadata +103 -89
  149. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  150. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  151. data/spec/ab_testing/ab_test_spec.rb +0 -270
  152. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  153. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  154. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  155. data/spec/activerecord_thread_safety_spec.rb +0 -553
  156. data/spec/advanced_operators_spec.rb +0 -3150
  157. data/spec/agent_spec.rb +0 -289
  158. data/spec/api_contract_spec.rb +0 -430
  159. data/spec/audit_adapters_spec.rb +0 -92
  160. data/spec/auth/access_audit_logger_spec.rb +0 -394
  161. data/spec/auth/authenticator_spec.rb +0 -112
  162. data/spec/auth/password_reset_spec.rb +0 -294
  163. data/spec/auth/permission_checker_spec.rb +0 -207
  164. data/spec/auth/permission_spec.rb +0 -73
  165. data/spec/auth/rbac_adapter_spec.rb +0 -778
  166. data/spec/auth/rbac_config_spec.rb +0 -82
  167. data/spec/auth/role_spec.rb +0 -51
  168. data/spec/auth/session_manager_spec.rb +0 -172
  169. data/spec/auth/session_spec.rb +0 -112
  170. data/spec/auth/user_spec.rb +0 -130
  171. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  172. data/spec/context_spec.rb +0 -127
  173. data/spec/decision_agent_spec.rb +0 -96
  174. data/spec/decision_spec.rb +0 -423
  175. data/spec/dmn/decision_graph_spec.rb +0 -282
  176. data/spec/dmn/decision_tree_spec.rb +0 -203
  177. data/spec/dmn/feel/errors_spec.rb +0 -18
  178. data/spec/dmn/feel/functions_spec.rb +0 -400
  179. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  180. data/spec/dmn/feel/types_spec.rb +0 -176
  181. data/spec/dmn/feel_parser_spec.rb +0 -489
  182. data/spec/dmn/hit_policy_spec.rb +0 -202
  183. data/spec/dmn/integration_spec.rb +0 -226
  184. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  185. data/spec/dsl_validation_spec.rb +0 -648
  186. data/spec/edge_cases_spec.rb +0 -353
  187. data/spec/evaluation_spec.rb +0 -364
  188. data/spec/evaluation_validator_spec.rb +0 -165
  189. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  190. data/spec/examples.txt +0 -1909
  191. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  192. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  193. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  194. data/spec/issue_verification_spec.rb +0 -759
  195. data/spec/json_rule_evaluator_spec.rb +0 -587
  196. data/spec/monitoring/alert_manager_spec.rb +0 -378
  197. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  198. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  199. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  200. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  201. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  202. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  203. data/spec/performance_optimizations_spec.rb +0 -493
  204. data/spec/replay_edge_cases_spec.rb +0 -699
  205. data/spec/replay_spec.rb +0 -210
  206. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  207. data/spec/scoring_spec.rb +0 -225
  208. data/spec/spec_helper.rb +0 -60
  209. data/spec/testing/batch_test_importer_spec.rb +0 -693
  210. data/spec/testing/batch_test_runner_spec.rb +0 -307
  211. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  212. data/spec/testing/test_result_comparator_spec.rb +0 -392
  213. data/spec/testing/test_scenario_spec.rb +0 -113
  214. data/spec/thread_safety_spec.rb +0 -490
  215. data/spec/thread_safety_spec.rb.broken +0 -878
  216. data/spec/versioning/adapter_spec.rb +0 -156
  217. data/spec/versioning_spec.rb +0 -1030
  218. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  219. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  220. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -0,0 +1,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require_relative "errors"
6
+
7
+ # Conditionally require ActiveRecord if available
8
+ begin
9
+ require "active_record"
10
+ rescue LoadError
11
+ # ActiveRecord not available - database queries will raise an error
12
+ end
13
+
14
+ module DecisionAgent
15
+ module Simulation
16
+ # Engine for replaying historical decisions and backtesting rule changes
17
+ # rubocop:disable Metrics/ClassLength
18
+ class ReplayEngine
19
+ attr_reader :agent, :version_manager
20
+
21
+ def initialize(agent:, version_manager: nil)
22
+ @agent = agent
23
+ @version_manager = version_manager || Versioning::VersionManager.new
24
+ end
25
+
26
+ # Replay historical decisions with a specific rule version
27
+ # @param historical_data [String, Array<Hash>, Hash] Path to CSV/JSON file, array of context hashes, or database query config
28
+ # Database config format: { database: { connection: {...}, query: "SELECT ..." } }
29
+ # or { database: { connection: {...}, table: "table_name", where: {...} } }
30
+ # @param rule_version [String, Integer, Hash, nil] Version ID, version hash, or nil to use current agent
31
+ # @param compare_with [String, Integer, Hash, nil] Optional baseline version to compare against
32
+ # @param options [Hash] Execution options
33
+ # - :parallel [Boolean] Use parallel execution (default: true)
34
+ # - :thread_count [Integer] Number of threads (default: 4)
35
+ # - :progress_callback [Proc] Progress callback
36
+ # @return [Hash] Replay results with comparison data
37
+ def replay(historical_data:, rule_version: nil, compare_with: nil, options: {})
38
+ contexts = load_historical_data(historical_data)
39
+ options = {
40
+ parallel: true,
41
+ thread_count: 4,
42
+ progress_callback: nil
43
+ }.merge(options)
44
+
45
+ # Build agent with specified version
46
+ replay_agent = build_agent_from_version(rule_version) if rule_version
47
+ replay_agent ||= @agent
48
+
49
+ # Build baseline agent if comparison requested
50
+ baseline_agent = build_agent_from_version(compare_with) if compare_with
51
+
52
+ # Execute replay
53
+ results = execute_replay(contexts, replay_agent, baseline_agent, options)
54
+
55
+ # Build comparison report
56
+ build_comparison_report(results, baseline_agent)
57
+ end
58
+
59
+ # Backtest a rule change against historical data
60
+ # @param historical_data [String, Array<Hash>, Hash] Historical context data (file path, array, or database config)
61
+ # @param proposed_version [String, Integer, Hash] Proposed rule version
62
+ # @param baseline_version [String, Integer, Hash, nil] Baseline version (default: active version)
63
+ # @param options [Hash] Execution options
64
+ # @return [Hash] Backtest results with impact analysis
65
+ def backtest(historical_data:, proposed_version:, baseline_version: nil, options: {})
66
+ baseline_version ||= get_active_version_for_rule(proposed_version)
67
+ replay(
68
+ historical_data: historical_data,
69
+ rule_version: proposed_version,
70
+ compare_with: baseline_version,
71
+ options: options
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def load_historical_data(data)
78
+ case data
79
+ when String
80
+ load_from_file(data)
81
+ when Array
82
+ data
83
+ when Hash
84
+ unless data.key?(:database) || data.key?("database")
85
+ raise InvalidHistoricalDataError, "Historical data Hash must contain :database key for database queries"
86
+ end
87
+
88
+ load_database(data[:database] || data["database"])
89
+
90
+ else
91
+ raise InvalidHistoricalDataError, "Historical data must be a file path (String), array of contexts, or database query config (Hash)"
92
+ end
93
+ end
94
+
95
+ def load_from_file(file_path)
96
+ case File.extname(file_path).downcase
97
+ when ".csv"
98
+ load_csv(file_path)
99
+ when ".json"
100
+ load_json(file_path)
101
+ else
102
+ raise InvalidHistoricalDataError, "Unsupported file format. Use CSV or JSON"
103
+ end
104
+ end
105
+
106
+ def load_csv(file_path)
107
+ contexts = []
108
+ CSV.foreach(file_path, headers: true, header_converters: :symbol) do |row|
109
+ context = row.to_h
110
+ # Convert numeric strings to numbers for better evaluator compatibility
111
+ context = context.transform_values do |v|
112
+ # Try to convert to number if it looks like a number
113
+ if v.is_a?(String) && v.match?(/^-?\d+(\.\d+)?$/)
114
+ v.include?(".") ? v.to_f : v.to_i
115
+ else
116
+ v
117
+ end
118
+ end
119
+ contexts << context
120
+ end
121
+ contexts
122
+ rescue StandardError => e
123
+ raise InvalidHistoricalDataError, "Failed to load CSV: #{e.message}"
124
+ end
125
+
126
+ def load_json(file_path)
127
+ content = File.read(file_path)
128
+ data = JSON.parse(content, symbolize_names: true)
129
+ data.is_a?(Array) ? data : [data]
130
+ rescue StandardError => e
131
+ raise InvalidHistoricalDataError, "Failed to load JSON: #{e.message}"
132
+ end
133
+
134
+ def load_database(config)
135
+ unless defined?(ActiveRecord)
136
+ raise InvalidHistoricalDataError, "ActiveRecord is required for database queries. Add 'activerecord' to your Gemfile."
137
+ end
138
+
139
+ config = {} unless config.is_a?(Hash)
140
+ connection_config = config[:connection] || config["connection"]
141
+ query = config[:query] || config["query"]
142
+ table = config[:table] || config["table"]
143
+ where_clause = config[:where] || config["where"]
144
+
145
+ raise InvalidHistoricalDataError, "Database config must include :connection" unless connection_config
146
+
147
+ # Check if query or table is provided
148
+ raise InvalidHistoricalDataError, "Database config must include :query or :table" unless query || table
149
+
150
+ # Establish connection
151
+ connection = establish_database_connection(connection_config)
152
+
153
+ # Build and execute query
154
+ execute_database_query(connection, query: query, table: table, where: where_clause)
155
+ rescue ActiveRecord::ActiveRecordError => e
156
+ raise InvalidHistoricalDataError, "Database query failed: #{e.message}"
157
+ rescue StandardError => e
158
+ # Check if it's the missing query/table error
159
+ raise InvalidHistoricalDataError, "Database config must include :query or :table" if e.message.include?("query or :table")
160
+
161
+ raise InvalidHistoricalDataError, "Failed to load from database: #{e.message}"
162
+ end
163
+
164
+ def establish_database_connection(config)
165
+ # If config is a string, assume it's a connection name/key or "default"
166
+ # Otherwise, treat it as connection parameters
167
+ if config.is_a?(String)
168
+ if config == "default" || config.empty?
169
+ # Use default ActiveRecord connection
170
+ end
171
+ # Try to find existing connection by name
172
+ # For now, fall back to default connection
173
+ ActiveRecord::Base.connection
174
+ elsif config.is_a?(Hash)
175
+ # Create a properly named class to avoid "Anonymous class is not allowed" error
176
+ # Generate a unique class name
177
+ class_name = "DecisionAgentReplayConnection#{object_id}#{Thread.current.object_id}#{Time.now.to_f.to_s.gsub(/[^0-9]/, '')}"
178
+
179
+ # Create the class in the DecisionAgent module namespace
180
+ DecisionAgent.const_set(:ReplayConnections, Module.new) unless defined?(DecisionAgent::ReplayConnections)
181
+
182
+ connection_class = Class.new(ActiveRecord::Base) do
183
+ self.abstract_class = true
184
+ end
185
+
186
+ # Set the class name properly to avoid anonymous class error
187
+ DecisionAgent::ReplayConnections.const_set(class_name, connection_class)
188
+ connection_class.establish_connection(config)
189
+ connection_class.connection
190
+ else
191
+ raise InvalidHistoricalDataError, "Connection config must be a Hash or String"
192
+ end
193
+ rescue LoadError => e
194
+ raise InvalidHistoricalDataError, "Failed to establish database connection: #{e.message}"
195
+ rescue ActiveRecord::ActiveRecordError => e
196
+ raise InvalidHistoricalDataError, "Database connection failed: #{e.message}"
197
+ end
198
+
199
+ def execute_database_query(connection, query: nil, table: nil, where: nil)
200
+ if query
201
+ # Execute raw SQL query
202
+ results = connection.select_all(query)
203
+ convert_query_results_to_contexts(results)
204
+ elsif table
205
+ # Build SQL query from table and where clause
206
+ sql = build_table_query(connection, table, where)
207
+ results = connection.select_all(sql)
208
+ convert_query_results_to_contexts(results)
209
+ else
210
+ raise InvalidHistoricalDataError, "Database config must include :query or :table"
211
+ end
212
+ end
213
+
214
+ def build_table_query(connection, table, where)
215
+ table_name = connection.quote_table_name(table)
216
+ sql = "SELECT * FROM #{table_name}"
217
+
218
+ if where.is_a?(Hash) && !where.empty?
219
+ where_conditions = where.map do |key, value|
220
+ quoted_key = connection.quote_column_name(key.to_s)
221
+ quoted_value = connection.quote(value)
222
+ "#{quoted_key} = #{quoted_value}"
223
+ end.join(" AND ")
224
+ sql += " WHERE #{where_conditions}"
225
+ end
226
+
227
+ sql
228
+ end
229
+
230
+ def convert_query_results_to_contexts(results)
231
+ if results.respond_to?(:columns) && results.respond_to?(:rows)
232
+ convert_activerecord_results(results)
233
+ elsif results.is_a?(Array)
234
+ convert_array_results(results)
235
+ elsif results.respond_to?(:each)
236
+ convert_enumerable_results(results)
237
+ else
238
+ raise InvalidHistoricalDataError, "Unexpected query result format: #{results.class}"
239
+ end
240
+ end
241
+
242
+ def convert_activerecord_results(results)
243
+ columns = results.columns.map(&:to_sym)
244
+ results.rows.each_with_object([]) do |row, contexts|
245
+ context = build_context_from_row(row, columns)
246
+ contexts << context if context.any?
247
+ end
248
+ end
249
+
250
+ def build_context_from_row(row, columns)
251
+ columns.each_with_object({}) do |column, context|
252
+ index = columns.index(column)
253
+ next if skip_metadata_field?(column, row[index])
254
+
255
+ value = parse_json_value(row[index])
256
+ context[column] = value
257
+ end
258
+ end
259
+
260
+ def skip_metadata_field?(column, value)
261
+ %i[id created_at updated_at].include?(column) && value.nil?
262
+ end
263
+
264
+ def parse_json_value(value)
265
+ return value unless value.is_a?(String)
266
+ return value unless value.start_with?("{") || value.start_with?("[")
267
+
268
+ JSON.parse(value, symbolize_names: true)
269
+ rescue JSON::ParserError
270
+ value
271
+ end
272
+
273
+ def convert_array_results(results)
274
+ results.each_with_object([]) do |row, contexts|
275
+ context = normalize_row_to_hash(row)
276
+ cleaned_context = clean_context(context)
277
+ contexts << cleaned_context if cleaned_context.any?
278
+ end
279
+ end
280
+
281
+ def convert_enumerable_results(results)
282
+ results.each_with_object([]) do |row, contexts|
283
+ context = normalize_row_to_hash(row)
284
+ cleaned_context = clean_context(context)
285
+ contexts << cleaned_context if cleaned_context.any?
286
+ end
287
+ end
288
+
289
+ def normalize_row_to_hash(row)
290
+ if row.is_a?(Hash)
291
+ row.transform_keys(&:to_sym)
292
+ elsif row.respond_to?(:to_h)
293
+ row.to_h.transform_keys(&:to_sym)
294
+ else
295
+ {}
296
+ end
297
+ end
298
+
299
+ def clean_context(context)
300
+ context.reject { |k, v| %i[id created_at updated_at].include?(k) && v.nil? }
301
+ end
302
+
303
+ def build_agent_from_version(version)
304
+ version_hash = resolve_version(version)
305
+ evaluators = build_evaluators_from_version(version_hash)
306
+ Agent.new(
307
+ evaluators: evaluators,
308
+ scoring_strategy: @agent.scoring_strategy,
309
+ audit_adapter: Audit::NullAdapter.new
310
+ )
311
+ end
312
+
313
+ def resolve_version(version)
314
+ case version
315
+ when String, Integer
316
+ version_data = @version_manager.get_version(version_id: version)
317
+ raise VersionComparisonError, "Version not found: #{version}" unless version_data
318
+
319
+ version_data
320
+ when Hash
321
+ version
322
+ else
323
+ raise VersionComparisonError, "Invalid version format: #{version.class}"
324
+ end
325
+ end
326
+
327
+ def build_evaluators_from_version(version)
328
+ content = version[:content] || version["content"]
329
+ return @agent.evaluators unless content
330
+
331
+ if content.is_a?(Hash) && content[:evaluators]
332
+ build_evaluators_from_config(content[:evaluators])
333
+ elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
334
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
335
+ else
336
+ @agent.evaluators
337
+ end
338
+ end
339
+
340
+ def build_evaluators_from_config(configs)
341
+ Array(configs).map do |config|
342
+ case config[:type] || config["type"]
343
+ when "json_rule"
344
+ Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
345
+ when "dmn"
346
+ model = config[:model] || config["model"]
347
+ decision_id = config[:decision_id] || config["decision_id"]
348
+ Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
349
+ else
350
+ raise VersionComparisonError, "Unknown evaluator type: #{config[:type]}"
351
+ end
352
+ end
353
+ end
354
+
355
+ def get_active_version_for_rule(proposed_version)
356
+ version_hash = resolve_version(proposed_version)
357
+ rule_id = version_hash[:rule_id] || version_hash["rule_id"]
358
+ return nil unless rule_id
359
+
360
+ @version_manager.get_active_version(rule_id: rule_id)
361
+ end
362
+
363
+ def execute_replay(contexts, replay_agent, baseline_agent, options)
364
+ results = []
365
+ mutex = Mutex.new
366
+ completed = 0
367
+ total = contexts.size
368
+
369
+ if options[:parallel] && contexts.size > 1
370
+ execute_parallel(contexts, replay_agent, baseline_agent, options, mutex) do |result|
371
+ mutex.synchronize do
372
+ results << result
373
+ completed += 1
374
+ options[:progress_callback]&.call(
375
+ completed: completed,
376
+ total: total,
377
+ percentage: (completed.to_f / total * 100).round(2)
378
+ )
379
+ end
380
+ end
381
+ else
382
+ contexts.each_with_index do |context, index|
383
+ result = execute_single_replay(context, replay_agent, baseline_agent)
384
+ results << result
385
+ completed = index + 1
386
+ options[:progress_callback]&.call(
387
+ completed: completed,
388
+ total: total,
389
+ percentage: (completed.to_f / total * 100).round(2)
390
+ )
391
+ end
392
+ end
393
+
394
+ results
395
+ end
396
+
397
+ def execute_parallel(contexts, replay_agent, baseline_agent, options, _mutex)
398
+ thread_count = [options[:thread_count], contexts.size].min
399
+ queue = Queue.new
400
+ contexts.each { |c| queue << c }
401
+
402
+ threads = Array.new(thread_count) do
403
+ Thread.new do
404
+ loop do
405
+ context = begin
406
+ queue.pop(true)
407
+ rescue ThreadError
408
+ nil
409
+ end
410
+ break unless context
411
+
412
+ result = execute_single_replay(context, replay_agent, baseline_agent)
413
+ yield result
414
+ end
415
+ end
416
+ end
417
+
418
+ threads.each(&:join)
419
+ end
420
+
421
+ def execute_single_replay(context, replay_agent, baseline_agent)
422
+ ctx = context.is_a?(Context) ? context : Context.new(context)
423
+
424
+ begin
425
+ replay_decision = replay_agent.decide(context: ctx)
426
+ rescue NoEvaluationsError
427
+ # If no evaluators match, return a default result
428
+ return {
429
+ context: ctx.to_h,
430
+ replay_decision: nil,
431
+ replay_confidence: 0.0,
432
+ baseline_decision: nil,
433
+ baseline_confidence: 0.0,
434
+ changed: false,
435
+ confidence_delta: nil,
436
+ error: "No evaluators returned a decision"
437
+ }
438
+ end
439
+
440
+ begin
441
+ baseline_decision = baseline_agent&.decide(context: ctx)
442
+ rescue NoEvaluationsError
443
+ baseline_decision = nil
444
+ end
445
+
446
+ {
447
+ context: ctx.to_h,
448
+ replay_decision: replay_decision.decision,
449
+ replay_confidence: replay_decision.confidence,
450
+ baseline_decision: baseline_decision&.decision,
451
+ baseline_confidence: baseline_decision&.confidence,
452
+ changed: (baseline_decision&.decision || nil) != replay_decision.decision,
453
+ confidence_delta: baseline_decision ? (replay_decision.confidence - baseline_decision.confidence) : nil
454
+ }
455
+ end
456
+
457
+ def build_comparison_report(results, baseline_agent)
458
+ # Filter out results with errors for statistics, but count all for total_decisions
459
+ valid_results = results.reject { |r| r[:error] }
460
+ total = results.size # Total contexts processed
461
+ changed = valid_results.count { |r| r[:changed] }
462
+ unchanged = valid_results.size - changed
463
+
464
+ confidence_deltas = valid_results.map { |r| r[:confidence_delta] }.compact
465
+ avg_confidence_delta = confidence_deltas.any? ? confidence_deltas.sum / confidence_deltas.size : 0
466
+
467
+ decision_distribution = valid_results.group_by { |r| r[:replay_decision] }.transform_values(&:count)
468
+ baseline_distribution = valid_results.select { |r| r[:baseline_decision] }
469
+ .group_by { |r| r[:baseline_decision] }
470
+ .transform_values(&:count)
471
+
472
+ {
473
+ total_decisions: total,
474
+ changed_decisions: changed,
475
+ unchanged_decisions: unchanged,
476
+ change_rate: valid_results.size.positive? ? (changed.to_f / valid_results.size) : 0,
477
+ average_confidence_delta: avg_confidence_delta,
478
+ decision_distribution: decision_distribution,
479
+ baseline_distribution: baseline_distribution,
480
+ results: results,
481
+ has_baseline: !baseline_agent.nil?,
482
+ errors: results.count { |r| r[:error] }
483
+ }
484
+ end
485
+ # rubocop:enable Metrics/ClassLength
486
+ end
487
+ end
488
+ end