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.
- checksums.yaml +4 -4
- data/README.md +234 -14
- data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
- data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
- data/lib/decision_agent/agent.rb +78 -9
- data/lib/decision_agent/audit/adapter.rb +2 -0
- data/lib/decision_agent/audit/logger_adapter.rb +2 -0
- data/lib/decision_agent/audit/null_adapter.rb +2 -0
- data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
- data/lib/decision_agent/auth/authenticator.rb +2 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
- data/lib/decision_agent/auth/password_reset_token.rb +2 -0
- data/lib/decision_agent/auth/permission.rb +2 -0
- data/lib/decision_agent/auth/permission_checker.rb +2 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
- data/lib/decision_agent/auth/rbac_config.rb +2 -0
- data/lib/decision_agent/auth/role.rb +2 -0
- data/lib/decision_agent/auth/session.rb +2 -0
- data/lib/decision_agent/auth/session_manager.rb +2 -0
- data/lib/decision_agent/auth/user.rb +2 -0
- data/lib/decision_agent/context.rb +14 -0
- data/lib/decision_agent/decision.rb +113 -4
- data/lib/decision_agent/dmn/adapter.rb +2 -0
- data/lib/decision_agent/dmn/cache.rb +2 -2
- data/lib/decision_agent/dmn/decision_graph.rb +7 -7
- data/lib/decision_agent/dmn/decision_tree.rb +16 -8
- data/lib/decision_agent/dmn/errors.rb +2 -0
- data/lib/decision_agent/dmn/exporter.rb +2 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
- data/lib/decision_agent/dmn/feel/functions.rb +2 -0
- data/lib/decision_agent/dmn/feel/parser.rb +2 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
- data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
- data/lib/decision_agent/dmn/feel/types.rb +2 -0
- data/lib/decision_agent/dmn/importer.rb +2 -0
- data/lib/decision_agent/dmn/model.rb +2 -4
- data/lib/decision_agent/dmn/parser.rb +2 -0
- data/lib/decision_agent/dmn/testing.rb +3 -2
- data/lib/decision_agent/dmn/validator.rb +5 -3
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
- data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
- data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
- data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
- data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
- data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
- data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
- data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
- data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
- data/lib/decision_agent/dsl/operators/base.rb +70 -0
- data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
- data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
- data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
- data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
- data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
- data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
- data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
- data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
- data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
- data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
- data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
- data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
- data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
- data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
- data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
- data/lib/decision_agent/dsl/rule_parser.rb +2 -0
- data/lib/decision_agent/dsl/schema_validator.rb +37 -14
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +85 -0
- data/lib/decision_agent/explainability/explainability_result.rb +50 -0
- data/lib/decision_agent/explainability/rule_trace.rb +41 -0
- data/lib/decision_agent/explainability/trace_collector.rb +26 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
- data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
- data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
- data/lib/decision_agent/replay/replay.rb +4 -1
- data/lib/decision_agent/scoring/base.rb +2 -0
- data/lib/decision_agent/scoring/consensus.rb +2 -0
- data/lib/decision_agent/scoring/max_weight.rb +2 -0
- data/lib/decision_agent/scoring/threshold.rb +2 -0
- data/lib/decision_agent/scoring/weighted_average.rb +2 -0
- data/lib/decision_agent/simulation/errors.rb +20 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
- data/lib/decision_agent/simulation/replay_engine.rb +488 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
- data/lib/decision_agent/simulation/scenario_library.rb +165 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
- data/lib/decision_agent/simulation.rb +19 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
- data/lib/decision_agent/testing/test_scenario.rb +2 -0
- data/lib/decision_agent/version.rb +3 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
- data/lib/decision_agent/versioning/adapter.rb +9 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
- data/lib/decision_agent/versioning/version_manager.rb +9 -0
- data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
- data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
- data/lib/decision_agent/web/dmn_editor.rb +8 -67
- data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
- data/lib/decision_agent/web/public/app.js +186 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +74 -8
- data/lib/decision_agent/web/public/index.html +69 -3
- data/lib/decision_agent/web/public/login.html +1 -1
- data/lib/decision_agent/web/public/sample_batch.csv +11 -0
- data/lib/decision_agent/web/public/sample_impact.csv +11 -0
- data/lib/decision_agent/web/public/sample_replay.csv +11 -0
- data/lib/decision_agent/web/public/sample_rules.json +118 -0
- data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
- data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
- data/lib/decision_agent/web/public/simulation.html +146 -0
- data/lib/decision_agent/web/public/simulation_impact.html +495 -0
- data/lib/decision_agent/web/public/simulation_replay.html +547 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/public/users.html +1 -1
- data/lib/decision_agent/web/rack_helpers.rb +106 -0
- data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
- data/lib/decision_agent/web/server.rb +2126 -1374
- data/lib/decision_agent.rb +19 -1
- data/lib/generators/decision_agent/install/install_generator.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- metadata +103 -89
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -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
|
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
|
|
11
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
end
|
|
198
|
+
return if %w[present blank].include?(operator.to_s)
|
|
199
|
+
return unless value.nil?
|
|
184
200
|
|
|
185
|
-
#
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
40
|
-
raise InvalidWeightError, weight unless
|
|
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)
|