decision_agent 0.3.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module DecisionAgent
6
+ module DataEnrichment
7
+ # Circuit breaker pattern for resilience
8
+ #
9
+ # Prevents cascading failures by opening the circuit after N failures
10
+ class CircuitBreaker
11
+ include MonitorMixin
12
+
13
+ # Circuit states
14
+ CLOSED = :closed # Normal operation
15
+ OPEN = :open # Circuit is open, failing fast
16
+ HALF_OPEN = :half_open # Testing if service recovered
17
+
18
+ def initialize(failure_threshold: 5, timeout: 60, success_threshold: 2)
19
+ super()
20
+ @failure_threshold = failure_threshold
21
+ @timeout = timeout
22
+ @success_threshold = success_threshold
23
+ @state = CLOSED
24
+ @failure_count = 0
25
+ @success_count = 0
26
+ @last_failure_time = nil
27
+ end
28
+
29
+ # Execute block with circuit breaker protection
30
+ #
31
+ # @yield Block to execute
32
+ # @return [Object] Result of block execution
33
+ # @raise [CircuitOpenError] If circuit is open
34
+ def call
35
+ synchronize do
36
+ check_state
37
+ raise CircuitOpenError, "Circuit is open" if @state == OPEN
38
+ end
39
+
40
+ begin
41
+ result = yield
42
+ record_success
43
+ result
44
+ rescue StandardError => e
45
+ record_failure
46
+ raise e
47
+ end
48
+ end
49
+
50
+ # Check if circuit is open
51
+ #
52
+ # @return [Boolean]
53
+ def open?
54
+ synchronize do
55
+ check_state
56
+ @state == OPEN
57
+ end
58
+ end
59
+
60
+ # Reset circuit breaker to closed state
61
+ def reset
62
+ synchronize do
63
+ @state = CLOSED
64
+ @failure_count = 0
65
+ @success_count = 0
66
+ @last_failure_time = nil
67
+ end
68
+ end
69
+
70
+ # Get current state
71
+ #
72
+ # @return [Symbol] Current state
73
+ def state
74
+ synchronize do
75
+ check_state
76
+ @state
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def check_state
83
+ case @state
84
+ when OPEN
85
+ # Check if timeout has elapsed, move to half-open
86
+ if @last_failure_time && (Time.now - @last_failure_time) >= @timeout
87
+ @state = HALF_OPEN
88
+ @success_count = 0
89
+ end
90
+ when HALF_OPEN
91
+ # State remains half-open until success threshold is met
92
+ when CLOSED
93
+ # State remains closed until failure threshold is met
94
+ end
95
+ end
96
+
97
+ def record_success
98
+ synchronize do
99
+ case @state
100
+ when HALF_OPEN
101
+ @success_count += 1
102
+ if @success_count >= @success_threshold
103
+ @state = CLOSED
104
+ @failure_count = 0
105
+ @success_count = 0
106
+ end
107
+ when CLOSED
108
+ @failure_count = 0 # Reset failure count on success
109
+ end
110
+ end
111
+ end
112
+
113
+ def record_failure
114
+ synchronize do
115
+ @failure_count += 1
116
+ @last_failure_time = Time.now
117
+
118
+ case @state
119
+ when HALF_OPEN
120
+ # Failures in half-open state immediately open the circuit
121
+ @state = OPEN
122
+ @success_count = 0
123
+ when CLOSED
124
+ # Open circuit if failure threshold is met
125
+ @state = OPEN if @failure_count >= @failure_threshold
126
+ end
127
+ end
128
+ end
129
+
130
+ # Error raised when circuit is open
131
+ class CircuitOpenError < StandardError
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "base64"
7
+ require_relative "cache_adapter"
8
+ require_relative "cache/memory_adapter"
9
+ require_relative "circuit_breaker"
10
+ require_relative "errors"
11
+
12
+ module DecisionAgent
13
+ module DataEnrichment
14
+ # HTTP client for data enrichment requests
15
+ class Client
16
+ # Error classes
17
+ class RequestError < StandardError
18
+ attr_reader :status_code, :response_body
19
+
20
+ def initialize(message, status_code: nil, response_body: nil)
21
+ super(message)
22
+ @status_code = status_code
23
+ @response_body = response_body
24
+ end
25
+ end
26
+
27
+ class TimeoutError < RequestError
28
+ end
29
+
30
+ class NetworkError < RequestError
31
+ end
32
+
33
+ attr_reader :config, :cache_adapter, :circuit_breaker
34
+
35
+ def initialize(config:, cache_adapter: nil, circuit_breaker: nil)
36
+ @config = config
37
+ @cache_adapter = cache_adapter || Cache::MemoryAdapter.new
38
+ @circuit_breakers = {}
39
+ @circuit_breaker_default = circuit_breaker || CircuitBreaker.new
40
+ @circuit_breaker = @circuit_breaker_default
41
+ end
42
+
43
+ # Fetch data from configured endpoint
44
+ #
45
+ # @param endpoint_name [Symbol] Endpoint identifier
46
+ # @param params [Hash] Request parameters
47
+ # @param use_cache [Boolean] Whether to use cache (default: true)
48
+ # @return [Hash] Response data
49
+ # @raise [RequestError] If request fails
50
+ def fetch(endpoint_name, params: {}, use_cache: true)
51
+ endpoint_config = @config.endpoint(endpoint_name)
52
+ raise ArgumentError, "Unknown endpoint: #{endpoint_name}" unless endpoint_config
53
+
54
+ # Generate cache key
55
+ cache_key = @cache_adapter.cache_key(endpoint_name, params)
56
+
57
+ # Check cache first
58
+ if use_cache
59
+ cached = @cache_adapter.get(cache_key)
60
+ return cached if cached
61
+ end
62
+
63
+ # Execute request with circuit breaker
64
+ circuit_breaker = get_circuit_breaker(endpoint_name)
65
+ begin
66
+ response_data = circuit_breaker.call do
67
+ execute_request(endpoint_name, endpoint_config, params)
68
+ end
69
+ rescue CircuitBreaker::CircuitOpenError => e
70
+ # Try to return cached data on circuit open
71
+ cached = @cache_adapter.get(cache_key) if use_cache
72
+ return cached if cached
73
+
74
+ raise RequestError, "Circuit breaker is open for #{endpoint_name}: #{e.message}"
75
+ end
76
+
77
+ # Cache response
78
+ @cache_adapter.set(cache_key, response_data, endpoint_config[:cache][:ttl]) if use_cache && endpoint_config[:cache][:ttl].positive?
79
+
80
+ response_data
81
+ end
82
+
83
+ # Clear cache for endpoint
84
+ #
85
+ # @param endpoint_name [Symbol, nil] Endpoint identifier, or nil to clear all
86
+ def clear_cache(endpoint_name = nil)
87
+ if endpoint_name
88
+ # Clear cache entries for this endpoint (requires cache adapter support)
89
+ # For now, just clear all if endpoint-specific clearing isn't supported
90
+ end
91
+ @cache_adapter.clear
92
+ end
93
+
94
+ private
95
+
96
+ def execute_request(endpoint_name, endpoint_config, params)
97
+ uri = URI(endpoint_config[:url])
98
+ method = endpoint_config[:method]
99
+ timeout = endpoint_config[:timeout]
100
+
101
+ http = Net::HTTP.new(uri.host, uri.port)
102
+ http.use_ssl = (uri.scheme == "https")
103
+ http.read_timeout = timeout
104
+ http.open_timeout = timeout
105
+
106
+ request = build_request(uri, method, endpoint_config, params)
107
+
108
+ response = http.request(request)
109
+
110
+ handle_response(response, endpoint_name)
111
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
112
+ raise TimeoutError, "Request timeout for #{endpoint_name}: #{e.message}"
113
+ rescue StandardError => e
114
+ raise NetworkError, "Network error for #{endpoint_name}: #{e.message}"
115
+ end
116
+
117
+ def build_request(uri, method, endpoint_config, params)
118
+ headers = endpoint_config[:headers].dup || {}
119
+ apply_auth(headers, endpoint_config[:auth])
120
+
121
+ case method
122
+ when :get
123
+ # Add params to query string for GET requests
124
+ if params.any?
125
+ query_string = URI.encode_www_form(params)
126
+ uri_with_query = URI("#{uri}?#{query_string}")
127
+ request = Net::HTTP::Get.new(uri_with_query)
128
+ else
129
+ request = Net::HTTP::Get.new(uri)
130
+ end
131
+ when :post
132
+ request = Net::HTTP::Post.new(uri)
133
+ request.body = params.to_json
134
+ headers["Content-Type"] ||= "application/json"
135
+ when :put
136
+ request = Net::HTTP::Put.new(uri)
137
+ request.body = params.to_json
138
+ headers["Content-Type"] ||= "application/json"
139
+ when :delete
140
+ request = Net::HTTP::Delete.new(uri)
141
+ else
142
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
143
+ end
144
+
145
+ headers.each { |k, v| request[k] = v }
146
+ request
147
+ end
148
+
149
+ def apply_auth(headers, auth_config)
150
+ return unless auth_config
151
+
152
+ case auth_config[:type]
153
+ when :api_key
154
+ header_name = auth_config[:header] || "X-API-Key"
155
+ api_key = get_secret(auth_config[:secret_key] || "API_KEY")
156
+ headers[header_name] = api_key
157
+ when :basic
158
+ username = get_secret(auth_config[:username_key] || "USERNAME")
159
+ password = get_secret(auth_config[:password_key] || "PASSWORD")
160
+ credentials = Base64.strict_encode64("#{username}:#{password}")
161
+ headers["Authorization"] = "Basic #{credentials}"
162
+ when :bearer
163
+ token = get_secret(auth_config[:token_key] || "TOKEN")
164
+ headers["Authorization"] = "Bearer #{token}"
165
+ end
166
+ end
167
+
168
+ def get_secret(key)
169
+ # Try environment variable first
170
+ return ENV[key] if ENV.key?(key)
171
+ return ENV[key.upcase] if ENV.key?(key.upcase)
172
+
173
+ # Could extend to support vault integration here
174
+ raise ArgumentError, "Secret not found: #{key}. Set environment variable #{key.upcase}"
175
+ end
176
+
177
+ def handle_response(response, endpoint_name)
178
+ case response.code.to_i
179
+ when 200..299
180
+ parse_response(response)
181
+ when 400..499
182
+ raise RequestError.new(
183
+ "Client error for #{endpoint_name}: HTTP #{response.code}",
184
+ status_code: response.code.to_i,
185
+ response_body: response.body
186
+ )
187
+ when 500..599
188
+ raise RequestError.new(
189
+ "Server error for #{endpoint_name}: HTTP #{response.code}",
190
+ status_code: response.code.to_i,
191
+ response_body: response.body
192
+ )
193
+ else
194
+ raise RequestError.new(
195
+ "Unexpected response for #{endpoint_name}: HTTP #{response.code}",
196
+ status_code: response.code.to_i,
197
+ response_body: response.body
198
+ )
199
+ end
200
+ end
201
+
202
+ def parse_response(response)
203
+ content_type = response["Content-Type"] || ""
204
+ body = response.body
205
+
206
+ return {} if body.nil? || body.empty?
207
+
208
+ if content_type.include?("application/json")
209
+ JSON.parse(body, symbolize_names: true)
210
+ else
211
+ { body: body, content_type: content_type }
212
+ end
213
+ end
214
+
215
+ def get_circuit_breaker(endpoint_name)
216
+ @circuit_breakers[endpoint_name] ||= @circuit_breaker_default.dup
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module DataEnrichment
5
+ # Configuration for data enrichment endpoints
6
+ class Config
7
+ attr_accessor :endpoints, :default_timeout, :default_retry, :default_cache
8
+
9
+ def initialize
10
+ @endpoints = {}
11
+ @default_timeout = 5 # seconds
12
+ @default_retry = { max_attempts: 3, backoff: :exponential }
13
+ @default_cache = { ttl: 3600, adapter: :memory }
14
+ end
15
+
16
+ # Add or update an endpoint configuration
17
+ #
18
+ # @param name [Symbol] Endpoint identifier
19
+ # @param url [String] Base URL for the endpoint
20
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
21
+ # @param auth [Hash] Authentication configuration
22
+ # @param cache [Hash] Cache configuration
23
+ # @param retry_config [Hash] Retry configuration
24
+ # @param timeout [Integer] Request timeout in seconds
25
+ # @param headers [Hash] Default headers
26
+ # @param rate_limit [Hash] Rate limiting configuration
27
+ #
28
+ # @example
29
+ # config.add_endpoint(:credit_bureau,
30
+ # url: "https://api.creditbureau.com/v1/score",
31
+ # method: :post,
32
+ # auth: { type: :api_key, header: "X-API-Key" },
33
+ # cache: { ttl: 3600, adapter: :redis },
34
+ # retry_config: { max_attempts: 3, backoff: :exponential }
35
+ # )
36
+ def add_endpoint(name, url:, method: :get, auth: nil, cache: nil, retry_config: nil, timeout: nil, headers: {}, rate_limit: nil) # rubocop:disable Metrics/ParameterLists
37
+ @endpoints[name.to_sym] = {
38
+ url: url,
39
+ method: method.to_sym,
40
+ auth: auth,
41
+ cache: cache || @default_cache.dup,
42
+ retry: retry_config || @default_retry.dup,
43
+ timeout: timeout || @default_timeout,
44
+ headers: headers,
45
+ rate_limit: rate_limit
46
+ }
47
+ end
48
+
49
+ # Get endpoint configuration
50
+ #
51
+ # @param name [Symbol] Endpoint identifier
52
+ # @return [Hash, nil] Endpoint configuration or nil if not found
53
+ def endpoint(name)
54
+ @endpoints[name.to_sym]
55
+ end
56
+
57
+ # Check if endpoint exists
58
+ #
59
+ # @param name [Symbol] Endpoint identifier
60
+ # @return [Boolean]
61
+ def endpoint?(name)
62
+ @endpoints.key?(name.to_sym)
63
+ end
64
+
65
+ # Remove endpoint configuration
66
+ #
67
+ # @param name [Symbol] Endpoint identifier
68
+ def remove_endpoint(name)
69
+ @endpoints.delete(name.to_sym)
70
+ end
71
+
72
+ # Clear all endpoint configurations
73
+ def clear
74
+ @endpoints.clear
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module DataEnrichment
5
+ # Base error class for data enrichment
6
+ class Error < StandardError
7
+ end
8
+
9
+ # Error raised when endpoint is not configured
10
+ class EndpointNotFoundError < Error
11
+ end
12
+
13
+ # Error raised when request fails
14
+ class RequestError < Error
15
+ attr_reader :status_code, :response_body
16
+
17
+ def initialize(message, status_code: nil, response_body: nil)
18
+ super(message)
19
+ @status_code = status_code
20
+ @response_body = response_body
21
+ end
22
+ end
23
+
24
+ # Error raised when request times out
25
+ class TimeoutError < RequestError
26
+ end
27
+
28
+ # Error raised when network error occurs
29
+ class NetworkError < RequestError
30
+ end
31
+
32
+ # Error raised when circuit breaker is open
33
+ class CircuitOpenError < Error
34
+ end
35
+ end
36
+ end
@@ -14,16 +14,116 @@ module DecisionAgent
14
14
  freeze
15
15
  end
16
16
 
17
- def to_h
17
+ # Returns array of condition descriptions that led to this decision
18
+ # @param verbose [Boolean] If true, returns detailed condition information
19
+ # @return [Array<String>] Array of condition descriptions
20
+ def because(verbose: false)
21
+ all_explainability_results.flat_map { |er| er.because(verbose: verbose) }
22
+ end
23
+
24
+ # Returns array of condition descriptions that failed
25
+ # @param verbose [Boolean] If true, returns detailed condition information
26
+ # @return [Array<String>] Array of failed condition descriptions
27
+ def failed_conditions(verbose: false)
28
+ all_explainability_results.flat_map { |er| er.failed_conditions(verbose: verbose) }
29
+ end
30
+
31
+ # Returns explainability data in machine-readable format
32
+ # @param verbose [Boolean] If true, returns detailed explainability information
33
+ # @return [Hash] Explainability data
34
+ def explainability(verbose: false)
18
35
  {
19
36
  decision: @decision,
37
+ because: because(verbose: verbose),
38
+ failed_conditions: failed_conditions(verbose: verbose),
39
+ rule_traces: verbose ? all_explainability_results.map { |er| er.to_h(verbose: true) } : nil
40
+ }.compact
41
+ end
42
+
43
+ def to_h
44
+ # Structure decision result as explainability by default
45
+ # This makes explainability the primary format for decision results
46
+ explainability_data = explainability(verbose: false)
47
+
48
+ {
49
+ # Explainability fields (primary structure)
50
+ decision: explainability_data[:decision],
51
+ because: explainability_data[:because],
52
+ failed_conditions: explainability_data[:failed_conditions],
53
+ # Additional metadata for completeness
20
54
  confidence: @confidence,
21
55
  explanations: @explanations,
22
56
  evaluations: @evaluations.map(&:to_h),
23
- audit_payload: @audit_payload
57
+ audit_payload: @audit_payload,
58
+ # Full explainability data (includes rule_traces in verbose mode)
59
+ explainability: explainability_data
24
60
  }
25
61
  end
26
62
 
63
+ private
64
+
65
+ def all_explainability_results
66
+ @evaluations.flat_map { |evaluation| extract_explainability_from_evaluation(evaluation) }
67
+ end
68
+
69
+ def extract_explainability_from_evaluation(evaluation)
70
+ return [] unless evaluation.metadata.is_a?(Hash)
71
+ return [] unless evaluation.metadata[:explainability]
72
+
73
+ explainability_data = normalize_hash_keys(evaluation.metadata[:explainability])
74
+ rule_traces = reconstruct_rule_traces(explainability_data)
75
+ evaluator_name = explainability_data[:evaluator_name] || evaluation.evaluator_name
76
+
77
+ [Explainability::ExplainabilityResult.new(
78
+ evaluator_name: evaluator_name,
79
+ rule_traces: rule_traces
80
+ )]
81
+ end
82
+
83
+ def normalize_hash_keys(data)
84
+ return data unless data.is_a?(Hash)
85
+
86
+ data.transform_keys(&:to_sym)
87
+ end
88
+
89
+ def reconstruct_rule_traces(explainability_data)
90
+ rule_traces_data = explainability_data[:rule_traces] || []
91
+ rule_traces_data.map { |rt_data| reconstruct_rule_trace(rt_data) }
92
+ end
93
+
94
+ def reconstruct_rule_trace(rt_data)
95
+ normalized_rt = normalize_hash_keys(rt_data)
96
+ condition_traces = reconstruct_condition_traces(normalized_rt)
97
+
98
+ Explainability::RuleTrace.new(
99
+ rule_id: normalized_rt[:rule_id],
100
+ matched: normalized_rt[:matched],
101
+ condition_traces: condition_traces,
102
+ decision: normalized_rt[:decision],
103
+ weight: normalized_rt[:weight],
104
+ reason: normalized_rt[:reason]
105
+ )
106
+ end
107
+
108
+ def reconstruct_condition_traces(rule_trace_data)
109
+ condition_traces_data = rule_trace_data[:condition_traces] || []
110
+ condition_traces_data.map { |ct_data| reconstruct_condition_trace(ct_data) }
111
+ end
112
+
113
+ def reconstruct_condition_trace(ct_data)
114
+ normalized_ct = normalize_hash_keys(ct_data)
115
+
116
+ Explainability::ConditionTrace.new(
117
+ field: normalized_ct[:field],
118
+ operator: normalized_ct[:operator],
119
+ expected_value: normalized_ct[:expected_value],
120
+ actual_value: normalized_ct[:actual_value],
121
+ result: normalized_ct[:result]
122
+ )
123
+ end
124
+
125
+ public
126
+
27
127
  def ==(other)
28
128
  other.is_a?(Decision) &&
29
129
  @decision == other.decision &&
@@ -253,15 +253,37 @@ module DecisionAgent
253
253
  max_val = parse_value(range_match[3])
254
254
  inclusive_end = range_match[4] == "]"
255
255
 
256
- # For Phase 2A, we only support fully inclusive ranges
257
- # Map to 'between' operator
256
+ # For Phase 2A, we support fully inclusive ranges with 'between' operator
258
257
  if inclusive_start && inclusive_end
259
258
  { operator: "between", value: [min_val, max_val] }
260
259
  else
261
- # Fall back to complex condition (Phase 2B)
262
- raise FeelParseError,
263
- "Half-open ranges not yet supported: #{expr}. " \
264
- "Use [min..max] for inclusive ranges."
260
+ # For half-open ranges, convert to inclusive by adjusting bounds
261
+ # [min..max) becomes [min..max-1] (if max is integer) or use compound conditions
262
+ # For simplicity, we'll convert to inclusive ranges with adjusted bounds
263
+ # This is a pragmatic approach for Phase 2A
264
+ adjusted_min = if inclusive_start
265
+ min_val
266
+ elsif min_val.is_a?(Integer)
267
+ min_val + 1
268
+ else
269
+ min_val + 0.0001
270
+ end
271
+ adjusted_max = if inclusive_end
272
+ max_val
273
+ elsif max_val.is_a?(Integer)
274
+ max_val - 1
275
+ else
276
+ max_val - 0.0001
277
+ end
278
+
279
+ # Ensure adjusted range is valid
280
+ if adjusted_min <= adjusted_max
281
+ { operator: "between", value: [adjusted_min, adjusted_max] }
282
+ else
283
+ # Invalid range, fall back to error
284
+ raise FeelParseError,
285
+ "Invalid half-open range: #{expr}. Range would be empty after adjustment."
286
+ end
265
287
  end
266
288
  end
267
289