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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Dsl
5
+ module Operators
6
+ # Handles statistical aggregation operators: min, max, sum, average, median, stddev, variance, percentile, count
7
+ module StatisticalAggregations
8
+ def self.handle(op, actual_value, expected_value, param_cache: nil, param_cache_mutex: nil)
9
+ case op
10
+ when "min"
11
+ # Checks if min(field_value) equals expected_value
12
+ return false unless actual_value.is_a?(Array)
13
+ return false if actual_value.empty?
14
+ return false unless expected_value.is_a?(Numeric)
15
+
16
+ actual_value.min == expected_value
17
+
18
+ when "max"
19
+ # Checks if max(field_value) equals expected_value
20
+ return false unless actual_value.is_a?(Array)
21
+ return false if actual_value.empty?
22
+ return false unless expected_value.is_a?(Numeric)
23
+
24
+ actual_value.max == expected_value
25
+
26
+ when "sum"
27
+ # Checks if sum of numeric array equals expected_value
28
+ return false unless actual_value.is_a?(Array)
29
+ return false if actual_value.empty?
30
+
31
+ # OPTIMIZE: calculate sum in single pass, filtering as we go
32
+ sum_value = 0.0
33
+ found_numeric = false
34
+ actual_value.each do |v|
35
+ if v.is_a?(Numeric)
36
+ sum_value += v
37
+ found_numeric = true
38
+ end
39
+ end
40
+ return false unless found_numeric
41
+
42
+ Base.compare_aggregation_result(sum_value, expected_value)
43
+
44
+ when "average", "mean"
45
+ # Checks if average of numeric array equals expected_value
46
+ return false unless actual_value.is_a?(Array)
47
+ return false if actual_value.empty?
48
+
49
+ # OPTIMIZE: calculate sum and count in single pass
50
+ sum_value = 0.0
51
+ count = 0
52
+ actual_value.each do |v|
53
+ if v.is_a?(Numeric)
54
+ sum_value += v
55
+ count += 1
56
+ end
57
+ end
58
+ return false if count.zero?
59
+
60
+ avg_value = sum_value / count
61
+ Base.compare_aggregation_result(avg_value, expected_value)
62
+
63
+ when "median"
64
+ # Checks if median of numeric array equals expected_value
65
+ return false unless actual_value.is_a?(Array)
66
+ return false if actual_value.empty?
67
+
68
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
69
+ return false if numeric_array.empty?
70
+
71
+ median_value = if numeric_array.size.odd?
72
+ numeric_array[numeric_array.size / 2]
73
+ else
74
+ (numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
75
+ end
76
+ Base.compare_aggregation_result(median_value, expected_value)
77
+
78
+ when "stddev", "standard_deviation"
79
+ # Checks if standard deviation of numeric array equals expected_value
80
+ return false unless actual_value.is_a?(Array)
81
+ return false if actual_value.size < 2
82
+
83
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
84
+ return false if numeric_array.size < 2
85
+
86
+ mean = numeric_array.sum.to_f / numeric_array.size
87
+ variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
88
+ stddev_value = Math.sqrt(variance)
89
+ Base.compare_aggregation_result(stddev_value, expected_value)
90
+
91
+ when "variance"
92
+ # Checks if variance of numeric array equals expected_value
93
+ return false unless actual_value.is_a?(Array)
94
+ return false if actual_value.size < 2
95
+
96
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
97
+ return false if numeric_array.size < 2
98
+
99
+ mean = numeric_array.sum.to_f / numeric_array.size
100
+ variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
101
+ Base.compare_aggregation_result(variance_value, expected_value)
102
+
103
+ when "percentile"
104
+ # Checks if Nth percentile of numeric array meets threshold
105
+ return false unless actual_value.is_a?(Array)
106
+ return false if actual_value.empty?
107
+
108
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
109
+ return false if numeric_array.empty?
110
+
111
+ params = parse_percentile_params(expected_value, param_cache: param_cache, param_cache_mutex: param_cache_mutex)
112
+ return false unless params
113
+
114
+ percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
115
+ percentile_value = if percentile_index == percentile_index.to_i
116
+ numeric_array[percentile_index.to_i]
117
+ else
118
+ lower = numeric_array[percentile_index.floor]
119
+ upper = numeric_array[percentile_index.ceil]
120
+ lower + ((upper - lower) * (percentile_index - percentile_index.floor))
121
+ end
122
+
123
+ compare_percentile_result(percentile_value, params)
124
+
125
+ when "count"
126
+ # Checks if count of array elements meets threshold
127
+ return false unless actual_value.is_a?(Array)
128
+
129
+ count_value = actual_value.size
130
+ Base.compare_aggregation_result(count_value, expected_value)
131
+ end
132
+ # Returns nil if not handled by this module
133
+ end
134
+
135
+ # Parse percentile parameters
136
+ def self.parse_percentile_params(value, param_cache: nil, param_cache_mutex: nil)
137
+ return nil unless value.is_a?(Hash)
138
+
139
+ # Normalize to hash (already a hash, but normalize keys)
140
+ normalized = Base.normalize_params_to_hash(value, [])
141
+
142
+ cache = param_cache
143
+ mutex = param_cache_mutex
144
+ if cache.nil? || mutex.nil?
145
+ cache = ConditionEvaluator.instance_variable_get(:@param_cache)
146
+ mutex = ConditionEvaluator.instance_variable_get(:@param_cache_mutex)
147
+ end
148
+
149
+ cache_key = Base.normalize_param_cache_key(normalized, "percentile")
150
+ cached = cache[cache_key]
151
+ return cached if cached
152
+
153
+ mutex.synchronize do
154
+ cache[cache_key] ||= parse_percentile_params_impl(normalized)
155
+ end
156
+ end
157
+
158
+ def self.parse_percentile_params_impl(value)
159
+ percentile = value[:percentile] || value["percentile"]
160
+ return nil unless percentile.is_a?(Numeric) && percentile >= 0 && percentile <= 100
161
+
162
+ {
163
+ percentile: percentile.to_f,
164
+ threshold: value[:threshold] || value["threshold"],
165
+ gt: value[:gt] || value["gt"],
166
+ lt: value[:lt] || value["lt"],
167
+ gte: value[:gte] || value["gte"],
168
+ lte: value[:lte] || value["lte"],
169
+ eq: value[:eq] || value["eq"]
170
+ }
171
+ end
172
+
173
+ # Compare percentile result
174
+ def self.compare_percentile_result(actual, params)
175
+ result = true
176
+ result &&= (actual >= params[:threshold]) if params[:threshold]
177
+ result &&= (actual > params[:gt]) if params[:gt]
178
+ result &&= (actual < params[:lt]) if params[:lt]
179
+ result &&= (actual >= params[:gte]) if params[:gte]
180
+ result &&= (actual <= params[:lte]) if params[:lte]
181
+ result &&= (actual == params[:eq]) if params[:eq]
182
+ result
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Dsl
5
+ module Operators
6
+ # Handles string aggregation operators: join, length
7
+ module StringAggregations
8
+ def self.handle(op, actual_value, expected_value, param_cache: nil, param_cache_mutex: nil)
9
+ case op
10
+ when "join"
11
+ # Joins array of strings with separator
12
+ return false unless actual_value.is_a?(Array)
13
+ return false if actual_value.empty?
14
+
15
+ string_array = actual_value.map(&:to_s)
16
+ params = parse_join_params(expected_value, param_cache: param_cache, param_cache_mutex: param_cache_mutex)
17
+ return false unless params
18
+
19
+ joined = string_array.join(params[:separator])
20
+
21
+ if params[:result]
22
+ joined == params[:result]
23
+ elsif params[:contains]
24
+ joined.include?(params[:contains])
25
+ else
26
+ false
27
+ end
28
+
29
+ when "length"
30
+ # Gets length of string or array
31
+ return false if actual_value.nil?
32
+
33
+ length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
34
+ actual_value.length
35
+ else
36
+ return false
37
+ end
38
+
39
+ compare_length_result(length_value, expected_value)
40
+ end
41
+ # Returns nil if not handled by this module
42
+ end
43
+
44
+ # Parse join parameters
45
+ def self.parse_join_params(value, param_cache: nil, param_cache_mutex: nil)
46
+ return nil unless value.is_a?(Hash)
47
+
48
+ normalized = Base.normalize_params_to_hash(value, [])
49
+
50
+ cache = param_cache
51
+ mutex = param_cache_mutex
52
+ if cache.nil? || mutex.nil?
53
+ cache = ConditionEvaluator.instance_variable_get(:@param_cache)
54
+ mutex = ConditionEvaluator.instance_variable_get(:@param_cache_mutex)
55
+ end
56
+
57
+ cache_key = Base.normalize_param_cache_key(normalized, "join")
58
+ cached = cache[cache_key]
59
+ return cached if cached
60
+
61
+ mutex.synchronize do
62
+ cache[cache_key] ||= parse_join_params_impl(normalized)
63
+ end
64
+ end
65
+
66
+ def self.parse_join_params_impl(value)
67
+ separator = value[:separator] || value["separator"]
68
+ return nil unless separator
69
+
70
+ {
71
+ separator: separator.to_s,
72
+ result: value[:result] || value["result"],
73
+ contains: value[:contains] || value["contains"]
74
+ }
75
+ end
76
+
77
+ # Compare length result
78
+ def self.compare_length_result(actual, expected)
79
+ ConditionEvaluator.compare_length_result(actual, expected)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Dsl
5
+ module Operators
6
+ # Handles string operators: contains, starts_with, ends_with, matches
7
+ module StringOperators
8
+ def self.handle(op, actual_value, expected_value, regex_cache: nil, regex_cache_mutex: nil)
9
+ case op
10
+ when "contains"
11
+ # Checks if string contains substring (case-sensitive)
12
+ string_operator?(actual_value, expected_value) &&
13
+ actual_value.include?(expected_value)
14
+
15
+ when "starts_with"
16
+ # Checks if string starts with prefix (case-sensitive)
17
+ string_operator?(actual_value, expected_value) &&
18
+ actual_value.start_with?(expected_value)
19
+
20
+ when "ends_with"
21
+ # Checks if string ends with suffix (case-sensitive)
22
+ string_operator?(actual_value, expected_value) &&
23
+ actual_value.end_with?(expected_value)
24
+
25
+ when "matches"
26
+ # Matches string against regular expression
27
+ # expected_value can be a string (converted to regex) or Regexp object
28
+ if !actual_value.is_a?(String) || expected_value.nil?
29
+ false
30
+ else
31
+ begin
32
+ regex = get_cached_regex(expected_value, regex_cache: regex_cache, regex_cache_mutex: regex_cache_mutex)
33
+ !regex.match(actual_value).nil?
34
+ rescue RegexpError
35
+ false
36
+ end
37
+ end
38
+ end
39
+ # Returns nil if not handled by this module
40
+ end
41
+
42
+ # String operator validation
43
+ def self.string_operator?(actual_value, expected_value)
44
+ actual_value.is_a?(String) && expected_value.is_a?(String)
45
+ end
46
+
47
+ # Get or compile regex with caching
48
+ def self.get_cached_regex(pattern, regex_cache: nil, regex_cache_mutex: nil)
49
+ return pattern if pattern.is_a?(Regexp)
50
+
51
+ # Use provided caches or access ConditionEvaluator class variables
52
+ cache = regex_cache
53
+ mutex = regex_cache_mutex
54
+
55
+ if cache.nil? || mutex.nil?
56
+ cache = ConditionEvaluator.instance_variable_get(:@regex_cache)
57
+ mutex = ConditionEvaluator.instance_variable_get(:@regex_cache_mutex)
58
+ end
59
+
60
+ # Fast path: check cache without lock
61
+ cached = cache[pattern]
62
+ return cached if cached
63
+
64
+ # Slow path: compile and cache
65
+ mutex.synchronize do
66
+ cache[pattern] ||= Regexp.new(pattern.to_s)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Dsl
5
+ module Operators
6
+ # Handles time component extraction operators: hour_of_day, day_of_month, month, year, week_of_year
7
+ module TimeComponentOperators
8
+ def self.handle(op, actual_value, expected_value)
9
+ case op
10
+ when "hour_of_day"
11
+ # Extracts hour of day (0-23) and compares
12
+ return false unless actual_value
13
+
14
+ date = ConditionEvaluator.parse_date(actual_value)
15
+ return false unless date
16
+
17
+ hour = date.hour
18
+ compare_numeric_result(hour, expected_value)
19
+
20
+ when "day_of_month"
21
+ # Extracts day of month (1-31) and compares
22
+ return false unless actual_value
23
+
24
+ date = ConditionEvaluator.parse_date(actual_value)
25
+ return false unless date
26
+
27
+ day = date.day
28
+ compare_numeric_result(day, expected_value)
29
+
30
+ when "month"
31
+ # Extracts month (1-12) and compares
32
+ return false unless actual_value
33
+
34
+ date = ConditionEvaluator.parse_date(actual_value)
35
+ return false unless date
36
+
37
+ month = date.month
38
+ compare_numeric_result(month, expected_value)
39
+
40
+ when "year"
41
+ # Extracts year and compares
42
+ return false unless actual_value
43
+
44
+ date = ConditionEvaluator.parse_date(actual_value)
45
+ return false unless date
46
+
47
+ year = date.year
48
+ compare_numeric_result(year, expected_value)
49
+
50
+ when "week_of_year"
51
+ # Extracts week of year (1-52) and compares
52
+ return false unless actual_value
53
+
54
+ date = ConditionEvaluator.parse_date(actual_value)
55
+ return false unless date
56
+
57
+ week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
58
+ compare_numeric_result(week, expected_value)
59
+ end
60
+ # Returns nil if not handled by this module
61
+ end
62
+
63
+ # Compare numeric result (for time component extraction)
64
+ def self.compare_numeric_result(actual, expected)
65
+ return actual == expected unless expected.is_a?(Hash)
66
+
67
+ Helpers::ComparisonHelpers.compare_numeric_with_hash(actual, expected)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module DecisionAgent
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Dsl
3
5
  # JSON Schema validator for Decision Agent rule DSL
@@ -7,8 +9,11 @@ module DecisionAgent
7
9
  eq neq gt gte lt lte in present blank
8
10
  contains starts_with ends_with matches
9
11
  between modulo
10
- sin cos tan sqrt power exp log
11
- round floor ceil abs
12
+ sin cos tan asin acos atan atan2
13
+ sinh cosh tanh
14
+ sqrt cbrt power exp log log10 log2
15
+ round floor ceil truncate abs
16
+ factorial gcd lcm
12
17
  min max sum average mean median stddev standard_deviation variance percentile count
13
18
  before_date after_date within_days day_of_week
14
19
  duration_seconds duration_minutes duration_hours duration_days
@@ -162,9 +167,10 @@ module DecisionAgent
162
167
  end
163
168
 
164
169
  def validate_field_condition(condition, path)
165
- field = condition["field"] || condition[:field]
166
- operator = condition["op"] || condition[:op]
167
- value = condition["value"] || condition[:value]
170
+ # Use key? to properly handle false values (|| would treat false as falsy)
171
+ field = extract_key_value(condition, "field", :field)
172
+ operator = extract_key_value(condition, "op", :op)
173
+ value = extract_key_value(condition, "value", :value)
168
174
 
169
175
  # Validate field
170
176
  @errors << "#{path}: Field condition missing 'field' key" unless field
@@ -176,14 +182,23 @@ module DecisionAgent
176
182
  end
177
183
 
178
184
  validate_operator(operator, path)
185
+ validate_field_condition_value(operator, value, path)
186
+ validate_field_path(field, path) if field
187
+ end
188
+
189
+ def extract_key_value(hash, string_key, symbol_key)
190
+ return hash[string_key] if hash.key?(string_key)
191
+ return hash[symbol_key] if hash.key?(symbol_key)
192
+
193
+ nil
194
+ end
179
195
 
196
+ def validate_field_condition_value(operator, value, path)
180
197
  # Validate value (not required for 'present' and 'blank')
181
- if !%w[present blank].include?(operator.to_s) && value.nil?
182
- @errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
183
- end
198
+ return if %w[present blank].include?(operator.to_s)
199
+ return unless value.nil?
184
200
 
185
- # Validate dot-notation in field path
186
- validate_field_path(field, path) if field
201
+ @errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
187
202
  end
188
203
 
189
204
  def validate_operator(operator, path)
@@ -253,13 +268,20 @@ module DecisionAgent
253
268
  return
254
269
  end
255
270
 
256
- # Validate decision
257
- decision = then_clause["decision"] || then_clause[:decision]
271
+ validate_then_clause_decision(then_clause, rule_path)
272
+ validate_then_clause_weight(then_clause, rule_path)
273
+ validate_then_clause_reason(then_clause, rule_path)
274
+ end
275
+
276
+ def validate_then_clause_decision(then_clause, rule_path)
277
+ # Use key? to properly handle false values (|| would treat false as falsy)
278
+ decision = extract_key_value(then_clause, "decision", :decision)
258
279
 
259
280
  # Check if decision exists (including false and 0, but not nil)
260
281
  @errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
282
+ end
261
283
 
262
- # Validate optional weight
284
+ def validate_then_clause_weight(then_clause, rule_path)
263
285
  weight = then_clause["weight"] || then_clause[:weight]
264
286
 
265
287
  if weight && !weight.is_a?(Numeric)
@@ -267,8 +289,9 @@ module DecisionAgent
267
289
  elsif weight && (weight < 0.0 || weight > 1.0)
268
290
  @errors << "#{rule_path}.then.weight: Must be between 0.0 and 1.0, got #{weight}"
269
291
  end
292
+ end
270
293
 
271
- # Validate optional reason
294
+ def validate_then_clause_reason(then_clause, rule_path)
272
295
  reason = then_clause["reason"] || then_clause[:reason]
273
296
 
274
297
  return unless reason && !reason.is_a?(String)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  class Error < StandardError; end
3
5
 
@@ -1,7 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
4
+ # Single evaluation produced by an evaluator: a suggested decision, weight, reason, and optional metadata.
2
5
  class Evaluation
3
6
  attr_reader :decision, :weight, :reason, :evaluator_name, :metadata
4
7
 
8
+ # @param decision [String, #to_s] The suggested decision value
9
+ # @param weight [Numeric] Importance of this evaluation (0.0 to 1.0)
10
+ # @param reason [String, #to_s] Human-readable reason for the decision
11
+ # @param evaluator_name [String, #to_s] Name of the evaluator that produced this
12
+ # @param metadata [Hash] Optional extra data (e.g. explainability)
13
+ # @raise [InvalidWeightError] when weight is not between 0.0 and 1.0
5
14
  def initialize(decision:, weight:, reason:, evaluator_name:, metadata: {})
6
15
  validate_weight!(weight)
7
16
 
@@ -14,6 +23,7 @@ module DecisionAgent
14
23
  freeze
15
24
  end
16
25
 
26
+ # @return [Hash] Symbol-keyed hash of decision, weight, reason, evaluator_name, metadata
17
27
  def to_h
18
28
  {
19
29
  decision: @decision,
@@ -24,6 +34,8 @@ module DecisionAgent
24
34
  }
25
35
  end
26
36
 
37
+ # @param other [Object] Object to compare
38
+ # @return [Boolean] true if other is an Evaluation with same attributes
27
39
  def ==(other)
28
40
  other.is_a?(Evaluation) &&
29
41
  @decision == other.decision &&
@@ -36,8 +48,8 @@ module DecisionAgent
36
48
  private
37
49
 
38
50
  def validate_weight!(weight)
39
- w = weight.to_f
40
- raise InvalidWeightError, weight unless w.between?(0.0, 1.0)
51
+ weight_value = weight.to_f
52
+ raise InvalidWeightError, weight unless weight_value.between?(0.0, 1.0)
41
53
  end
42
54
 
43
55
  def deep_freeze(obj)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Evaluators
3
5
  class Base