decision_agent 1.0.1 → 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 +61 -106
- 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 +49 -51
- 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 +13 -0
- data/lib/decision_agent/decision.rb +11 -2
- 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 +102 -108
- 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 +197 -1473
- 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 +9 -24
- 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 +2 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +2 -17
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +2 -0
- data/lib/decision_agent/explainability/explainability_result.rb +2 -4
- data/lib/decision_agent/explainability/rule_trace.rb +2 -0
- data/lib/decision_agent/explainability/trace_collector.rb +2 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
- 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 +2 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +3 -1
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -8
- data/lib/decision_agent/simulation/replay_engine.rb +3 -1
- data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
- data/lib/decision_agent/simulation/scenario_library.rb +2 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
- data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -11
- data/lib/decision_agent/simulation.rb +2 -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 +97 -47
- 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 +67 -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 +20 -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 +23 -7
- data/lib/decision_agent/web/public/simulation_impact.html +37 -20
- data/lib/decision_agent/web/public/simulation_replay.html +19 -23
- data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
- data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
- 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 +2032 -1851
- data/lib/decision_agent.rb +3 -43
- 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 +57 -23
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
- data/lib/decision_agent/data_enrichment/client.rb +0 -220
- data/lib/decision_agent/data_enrichment/config.rb +0 -78
- data/lib/decision_agent/data_enrichment/errors.rb +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6c760652b22f7cf3e4524aec1757f1fba1fc21c206c960ad3836eed18279f13
|
|
4
|
+
data.tar.gz: e0c2f21dfac36d7a4ce7935d6ea38375265c64e15eec4c276c514a1bf759dd6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9a402f62fa86a4cf83d727f048b6de8a8d67ab317cbc3dd2d681cd537a2a1a68a1bbe77b2f34a4c6f4a805babace2e7c6d449435d8fef9831dff884152b441a
|
|
7
|
+
data.tar.gz: 2b00ae31f1e89caa62c004c6e649296e1c6a193faf90ded7ef2fd6e930b3c5547113d028349de2b6904bfb9e2371fd37335251b6391bac4315cda1ca6407c4e8
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/decision_agent)
|
|
4
4
|
[](https://github.com/samaswin/decision_agent/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE.txt)
|
|
6
|
-
[](https://www.ruby-lang.org)
|
|
7
7
|
|
|
8
8
|
A production-grade, deterministic, explainable, and auditable decision engine for Ruby.
|
|
9
9
|
|
|
@@ -72,7 +72,6 @@ See [Code Examples](docs/CODE_EXAMPLES.md) for more comprehensive examples.
|
|
|
72
72
|
- **Conflict Resolution** - Weighted average, consensus, threshold, max weight
|
|
73
73
|
- **Rich Context** - Nested data, dot notation, flexible operators
|
|
74
74
|
- **Advanced Operators** - String, numeric, date/time, collection, and geospatial operators
|
|
75
|
-
- **REST API Data Enrichment** - Fetch external data during decision-making with caching and circuit breaker
|
|
76
75
|
|
|
77
76
|
### Auditability & Compliance
|
|
78
77
|
- **Complete Audit Trails** - Every decision fully logged
|
|
@@ -105,7 +104,7 @@ See [Code Examples](docs/CODE_EXAMPLES.md) for more comprehensive examples.
|
|
|
105
104
|
|
|
106
105
|
### Developer Experience
|
|
107
106
|
- **Pluggable Architecture** - Custom evaluators, scoring, audit adapters
|
|
108
|
-
- **Framework Agnostic** - Works with Rails,
|
|
107
|
+
- **Framework Agnostic** - Works with Rails, Rack, or standalone
|
|
109
108
|
- **JSON Rule DSL** - Non-technical users can write rules
|
|
110
109
|
- **DMN 1.3 Support** - Industry-standard Decision Model and Notation with full FEEL expression language
|
|
111
110
|
- **Visual Rule Builder** - Web UI for rule management and DMN modeler
|
|
@@ -131,6 +130,8 @@ decision_agent web
|
|
|
131
130
|
|
|
132
131
|
Open [http://localhost:4567](http://localhost:4567) in your browser.
|
|
133
132
|
|
|
133
|
+
The Web UI includes a **DMN visual modeler** at `/dmn/editor` for building and editing decision tables.
|
|
134
|
+
|
|
134
135
|
### Integration
|
|
135
136
|
|
|
136
137
|
**Rails:**
|
|
@@ -141,7 +142,7 @@ Rails.application.routes.draw do
|
|
|
141
142
|
end
|
|
142
143
|
```
|
|
143
144
|
|
|
144
|
-
**Rack
|
|
145
|
+
**Rack:**
|
|
145
146
|
```ruby
|
|
146
147
|
require 'decision_agent/web/server'
|
|
147
148
|
map '/decision_agent' do
|
|
@@ -149,7 +150,7 @@ map '/decision_agent' do
|
|
|
149
150
|
end
|
|
150
151
|
```
|
|
151
152
|
|
|
152
|
-
See [Web UI Integration Guide](docs/
|
|
153
|
+
See [Web UI Integration Guide](docs/WEB_UI_INTEGRATION.md) for detailed setup with Rails, Sinatra, Hanami, and other frameworks.
|
|
153
154
|
|
|
154
155
|
## DMN (Decision Model and Notation) Support
|
|
155
156
|
|
|
@@ -185,57 +186,6 @@ result = agent.decide(context: { amount: 50000, credit_score: 750 })
|
|
|
185
186
|
|
|
186
187
|
See [DMN Guide](docs/DMN_GUIDE.md) for complete documentation and [DMN Examples](examples/dmn/README.md) for working examples.
|
|
187
188
|
|
|
188
|
-
## REST API Data Enrichment
|
|
189
|
-
|
|
190
|
-
DecisionAgent supports fetching external data during decision-making without manual context assembly:
|
|
191
|
-
|
|
192
|
-
```ruby
|
|
193
|
-
require 'decision_agent'
|
|
194
|
-
|
|
195
|
-
# Configure data enrichment endpoints
|
|
196
|
-
DecisionAgent.configure_data_enrichment do |config|
|
|
197
|
-
config.add_endpoint(:credit_bureau,
|
|
198
|
-
url: "https://api.creditbureau.com/v1/score",
|
|
199
|
-
method: :post,
|
|
200
|
-
auth: { type: :api_key, header: "X-API-Key" },
|
|
201
|
-
cache: { ttl: 3600, adapter: :memory }
|
|
202
|
-
)
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Use in rules with fetch_from_api operator
|
|
206
|
-
rules = {
|
|
207
|
-
version: "1.0",
|
|
208
|
-
ruleset: "loan_approval",
|
|
209
|
-
rules: [{
|
|
210
|
-
id: "check_credit",
|
|
211
|
-
if: {
|
|
212
|
-
field: "credit_score",
|
|
213
|
-
op: "fetch_from_api",
|
|
214
|
-
value: {
|
|
215
|
-
endpoint: "credit_bureau",
|
|
216
|
-
params: { ssn: "{{customer.ssn}}" },
|
|
217
|
-
mapping: { score: "credit_score" }
|
|
218
|
-
}
|
|
219
|
-
},
|
|
220
|
-
then: { decision: "approve", weight: 0.8 }
|
|
221
|
-
}]
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
225
|
-
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
226
|
-
result = agent.decide(context: { customer: { ssn: "123-45-6789" } })
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
**Features:**
|
|
230
|
-
- **HTTP Client** - Support for GET, POST, PUT, DELETE methods
|
|
231
|
-
- **Response Caching** - Configurable TTL per endpoint with memory adapter
|
|
232
|
-
- **Circuit Breaker** - Fail-fast after N failures to prevent cascading failures
|
|
233
|
-
- **Authentication** - API key, Basic Auth, and Bearer token support
|
|
234
|
-
- **Template Parameters** - Use `{{path}}` syntax to reference context values
|
|
235
|
-
- **Error Handling** - Graceful degradation with cached data fallback
|
|
236
|
-
|
|
237
|
-
See [Data Enrichment Guide](docs/DATA_ENRICHMENT.md) for complete documentation and [Data Enrichment Example](examples/data_enrichment_example.rb) for working examples.
|
|
238
|
-
|
|
239
189
|
## Monitoring & Analytics
|
|
240
190
|
|
|
241
191
|
Real-time monitoring, metrics, and alerting for production environments.
|
|
@@ -268,31 +218,32 @@ DecisionAgent provides comprehensive simulation capabilities to test rule change
|
|
|
268
218
|
|
|
269
219
|
```ruby
|
|
270
220
|
require 'decision_agent/simulation/replay_engine'
|
|
221
|
+
require 'decision_agent/simulation/what_if_analyzer'
|
|
222
|
+
require 'decision_agent/simulation/impact_analyzer'
|
|
271
223
|
|
|
272
224
|
# Replay historical decisions with new rules
|
|
273
225
|
replay_engine = DecisionAgent::Simulation::ReplayEngine.new(
|
|
274
226
|
agent: agent,
|
|
275
227
|
version_manager: version_manager
|
|
276
228
|
)
|
|
277
|
-
|
|
278
229
|
results = replay_engine.replay(historical_data: "decisions.csv")
|
|
279
230
|
|
|
280
|
-
# What-if analysis
|
|
231
|
+
# What-if analysis (scenarios = array of context hashes)
|
|
281
232
|
whatif = DecisionAgent::Simulation::WhatIfAnalyzer.new(agent: agent)
|
|
282
233
|
analysis = whatif.analyze(
|
|
283
|
-
base_context: { credit_score: 700, amount: 50000 },
|
|
284
234
|
scenarios: [
|
|
285
|
-
{ credit_score:
|
|
286
|
-
{ credit_score:
|
|
235
|
+
{ credit_score: 700, amount: 50000 },
|
|
236
|
+
{ credit_score: 750, amount: 50000 },
|
|
237
|
+
{ credit_score: 650, amount: 50000 }
|
|
287
238
|
]
|
|
288
239
|
)
|
|
289
240
|
|
|
290
|
-
# Impact analysis
|
|
291
|
-
impact = DecisionAgent::Simulation::ImpactAnalyzer.new
|
|
292
|
-
comparison = impact.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
241
|
+
# Impact analysis (compare two rule versions on test data)
|
|
242
|
+
impact = DecisionAgent::Simulation::ImpactAnalyzer.new(version_manager: version_manager)
|
|
243
|
+
comparison = impact.analyze(
|
|
244
|
+
baseline_version: baseline_version_id,
|
|
245
|
+
proposed_version: proposed_version_id,
|
|
246
|
+
test_data: test_contexts
|
|
296
247
|
)
|
|
297
248
|
```
|
|
298
249
|
|
|
@@ -348,23 +299,22 @@ Test rules against large datasets with comprehensive analysis:
|
|
|
348
299
|
|
|
349
300
|
```ruby
|
|
350
301
|
require 'decision_agent/testing/batch_test_runner'
|
|
302
|
+
require 'decision_agent/testing/batch_test_importer'
|
|
351
303
|
|
|
352
|
-
runner = DecisionAgent::Testing::BatchTestRunner.new(agent
|
|
304
|
+
runner = DecisionAgent::Testing::BatchTestRunner.new(agent)
|
|
353
305
|
|
|
354
|
-
# Import from CSV or Excel
|
|
306
|
+
# Import from CSV or Excel (context columns default to all except id/expected_*)
|
|
355
307
|
importer = DecisionAgent::Testing::BatchTestImporter.new
|
|
356
|
-
scenarios = importer.import_csv("test_data.csv"
|
|
357
|
-
context_fields: ["credit_score", "amount"],
|
|
358
|
-
expected_fields: ["expected_decision"]
|
|
359
|
-
})
|
|
308
|
+
scenarios = importer.import_csv("test_data.csv")
|
|
360
309
|
|
|
361
310
|
# Run batch test
|
|
362
|
-
results = runner.run(scenarios
|
|
311
|
+
results = runner.run(scenarios)
|
|
363
312
|
|
|
364
|
-
|
|
365
|
-
puts "
|
|
366
|
-
puts "
|
|
367
|
-
puts "
|
|
313
|
+
stats = runner.statistics
|
|
314
|
+
puts "Total: #{stats[:total]}"
|
|
315
|
+
puts "Passed: #{stats[:successful]}"
|
|
316
|
+
puts "Failed: #{stats[:failed]}"
|
|
317
|
+
puts "Success rate: #{(stats[:success_rate] * 100).round(2)}%"
|
|
368
318
|
```
|
|
369
319
|
|
|
370
320
|
**Features:**
|
|
@@ -382,19 +332,23 @@ See [Batch Testing Guide](docs/BATCH_TESTING.md) for complete documentation.
|
|
|
382
332
|
Compare rule versions with statistical analysis:
|
|
383
333
|
|
|
384
334
|
```ruby
|
|
385
|
-
require 'decision_agent/
|
|
335
|
+
require 'decision_agent/ab_testing/ab_test_manager'
|
|
386
336
|
|
|
387
|
-
ab_manager = DecisionAgent::
|
|
337
|
+
ab_manager = DecisionAgent::ABTesting::ABTestManager.new(version_manager: version_manager)
|
|
388
338
|
|
|
389
339
|
test = ab_manager.create_test(
|
|
390
340
|
name: "loan_approval_v2",
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
traffic_split:
|
|
341
|
+
champion_version_id: champion_version_id,
|
|
342
|
+
challenger_version_id: challenger_version_id,
|
|
343
|
+
traffic_split: { champion: 90, challenger: 10 }
|
|
394
344
|
)
|
|
395
345
|
|
|
396
|
-
|
|
397
|
-
ab_manager.
|
|
346
|
+
# Assign variant per request, then record decisions; when done, get results
|
|
347
|
+
assignment = ab_manager.assign_variant(test_id: test.id, user_id: "user_123")
|
|
348
|
+
# ... run agent with assignment[:version_id], then:
|
|
349
|
+
ab_manager.record_decision(assignment_id: assignment[:assignment_id], decision: "approve", confidence: 0.9)
|
|
350
|
+
|
|
351
|
+
results = ab_manager.get_results(test.id)
|
|
398
352
|
```
|
|
399
353
|
|
|
400
354
|
**Features:**
|
|
@@ -430,7 +384,6 @@ See [A/B Testing Guide](docs/AB_TESTING.md) for complete documentation.
|
|
|
430
384
|
### Core Features
|
|
431
385
|
- [Explainability Layer](docs/EXPLAINABILITY.md) - Machine-readable decision explanations with condition-level tracing
|
|
432
386
|
- [Advanced Operators](docs/ADVANCED_OPERATORS.md) - String, numeric, date/time, collection, and geospatial operators
|
|
433
|
-
- [Data Enrichment](docs/DATA_ENRICHMENT.md) - REST API data enrichment with caching and circuit breaker
|
|
434
387
|
- [DMN Guide](docs/DMN_GUIDE.md) - Complete DMN 1.3 support guide
|
|
435
388
|
- [DMN API Reference](docs/DMN_API.md) - DMN API documentation
|
|
436
389
|
- [FEEL Reference](docs/FEEL_REFERENCE.md) - FEEL expression language reference
|
|
@@ -444,7 +397,7 @@ See [A/B Testing Guide](docs/AB_TESTING.md) for complete documentation.
|
|
|
444
397
|
- [RBAC Quick Reference](docs/RBAC_QUICK_REFERENCE.md) - Quick reference for RBAC configuration
|
|
445
398
|
- [Web UI](docs/WEB_UI.md) - Visual rule builder
|
|
446
399
|
- [Web UI Setup](docs/WEB_UI_SETUP.md) - Setup guide
|
|
447
|
-
- [Web UI
|
|
400
|
+
- [Web UI Integration](docs/WEB_UI_INTEGRATION.md) - Mount in Rails, Sinatra, Hanami, and other Rack frameworks
|
|
448
401
|
- [Monitoring & Analytics](docs/MONITORING_AND_ANALYTICS.md) - Real-time monitoring, metrics, and alerting
|
|
449
402
|
- [Monitoring Architecture](docs/MONITORING_ARCHITECTURE.md) - System architecture and design
|
|
450
403
|
- [Persistent Monitoring](docs/PERSISTENT_MONITORING.md) - Database storage for long-term analytics
|
|
@@ -454,6 +407,9 @@ See [A/B Testing Guide](docs/AB_TESTING.md) for complete documentation.
|
|
|
454
407
|
- [Thread-Safety Implementation](docs/THREAD_SAFETY.md) - Technical implementation guide
|
|
455
408
|
- [Benchmarks](benchmarks/README.md) - Comprehensive benchmark suite and performance testing
|
|
456
409
|
|
|
410
|
+
### Development
|
|
411
|
+
- [Development Setup](docs/DEVELOPMENT_SETUP.md) - Development environment setup, testing, and tools
|
|
412
|
+
|
|
457
413
|
### Reference
|
|
458
414
|
- [API Contract](docs/API_CONTRACT.md) - Full API reference
|
|
459
415
|
- [Changelog](docs/CHANGELOG.md) - Version history
|
|
@@ -478,7 +434,7 @@ See [Thread-Safety Guide](docs/THREAD_SAFETY.md) and [Performance Analysis](docs
|
|
|
478
434
|
|
|
479
435
|
**Run Benchmarks:**
|
|
480
436
|
```bash
|
|
481
|
-
# Run all benchmarks
|
|
437
|
+
# Run all benchmarks (single Ruby version)
|
|
482
438
|
rake benchmark:all
|
|
483
439
|
|
|
484
440
|
# Run specific benchmarks
|
|
@@ -486,35 +442,34 @@ rake benchmark:basic # Basic decision performance
|
|
|
486
442
|
rake benchmark:threads # Thread-safety and scalability
|
|
487
443
|
rake benchmark:regression # Compare against baseline
|
|
488
444
|
|
|
445
|
+
# Run benchmarks across all Ruby versions (3.0.7, 3.1.6, 3.2.5, 3.3.5)
|
|
446
|
+
./scripts/benchmark_all_ruby_versions.sh
|
|
447
|
+
|
|
489
448
|
# See [Benchmarks Guide](benchmarks/README.md) for complete documentation
|
|
490
449
|
```
|
|
491
450
|
|
|
492
451
|
### Latest Benchmark Results
|
|
493
452
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
#### Performance Comparison
|
|
453
|
+
Run `rake benchmark:regression` to generate results for your environment. Example (Ruby 3.3, typical hardware):
|
|
497
454
|
|
|
498
|
-
| Metric |
|
|
499
|
-
|
|
500
|
-
| Basic
|
|
501
|
-
| Basic
|
|
502
|
-
| Multi-threaded (50 threads)
|
|
503
|
-
| Multi-threaded (50 threads) Latency | 0.1168 ms | 0.113 ms | ↑ 3.36% (degraded) |
|
|
455
|
+
| Metric | Typical range |
|
|
456
|
+
|--------|----------------|
|
|
457
|
+
| Basic throughput | ~9,000+ decisions/sec |
|
|
458
|
+
| Basic latency | ~0.1 ms |
|
|
459
|
+
| Multi-threaded (50 threads) | ~8,500+ decisions/sec |
|
|
504
460
|
|
|
505
|
-
**
|
|
506
|
-
- Ruby Version: 3.3.5
|
|
507
|
-
- Hardware: x86_64
|
|
508
|
-
- OS: Darwin
|
|
509
|
-
- Git Commit: `aba46af5`
|
|
510
|
-
|
|
511
|
-
> 💡 **Note:** Run `rake benchmark:regression` to generate new benchmark results. This section is automatically updated with the last 2 benchmark runs.
|
|
461
|
+
> 💡 **Note:** See [Benchmarks Guide](benchmarks/README.md) and run `rake benchmark:all` or `rake benchmark:regression` for current numbers.
|
|
512
462
|
## Contributing
|
|
513
463
|
|
|
514
464
|
1. Fork the repository
|
|
515
465
|
2. Create a feature branch
|
|
516
|
-
3.
|
|
517
|
-
4.
|
|
466
|
+
3. Set up development environment (see [Development Setup](docs/DEVELOPMENT_SETUP.md))
|
|
467
|
+
4. Add tests (maintain 90%+ coverage)
|
|
468
|
+
5. Run tests across all Ruby versions: `./scripts/test_all_ruby_versions.sh`
|
|
469
|
+
6. Run benchmarks across all Ruby versions: `./scripts/benchmark_all_ruby_versions.sh`
|
|
470
|
+
7. Submit a pull request
|
|
471
|
+
|
|
472
|
+
See [Development Setup Guide](docs/DEVELOPMENT_SETUP.md) for detailed setup instructions, testing workflows, and development best practices.
|
|
518
473
|
|
|
519
474
|
## Support
|
|
520
475
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
1
5
|
module DecisionAgent
|
|
2
6
|
module ABTesting
|
|
3
7
|
# Represents an A/B test configuration for comparing rule versions
|
|
@@ -41,7 +45,7 @@ module DecisionAgent
|
|
|
41
45
|
|
|
42
46
|
if user_id
|
|
43
47
|
# Consistent hashing: same user always gets same variant
|
|
44
|
-
hash_value = Digest::SHA256.hexdigest("#{@id}:#{user_id}").to_i(16)
|
|
48
|
+
hash_value = OpenSSL::Digest::SHA256.hexdigest("#{@id}:#{user_id}").to_i(16)
|
|
45
49
|
percentage = hash_value % 100
|
|
46
50
|
else
|
|
47
51
|
# Random assignment
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "active_record"
|
|
2
4
|
|
|
3
5
|
module DecisionAgent
|
|
@@ -86,19 +88,6 @@ module DecisionAgent
|
|
|
86
88
|
end
|
|
87
89
|
# rubocop:enable Naming/PredicateMethod
|
|
88
90
|
|
|
89
|
-
# Get statistics from database
|
|
90
|
-
def get_test_statistics(test_id)
|
|
91
|
-
assignments = ::ABTestAssignmentModel.where(ab_test_id: test_id)
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
total_assignments: assignments.count,
|
|
95
|
-
champion_count: assignments.where(variant: "champion").count,
|
|
96
|
-
challenger_count: assignments.where(variant: "challenger").count,
|
|
97
|
-
with_decisions: assignments.where.not(decision_result: nil).count,
|
|
98
|
-
avg_confidence: assignments.where.not(confidence: nil).average(:confidence)&.to_f
|
|
99
|
-
}
|
|
100
|
-
end
|
|
101
|
-
|
|
102
91
|
private
|
|
103
92
|
|
|
104
93
|
def to_ab_test(record)
|
data/lib/decision_agent/agent.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "json"
|
|
3
4
|
require "json/canonicalization"
|
|
5
|
+
require "openssl"
|
|
4
6
|
|
|
5
7
|
module DecisionAgent
|
|
8
|
+
# Agent runs multiple evaluators over a context, scores their evaluations,
|
|
9
|
+
# and returns a single {Decision} with the chosen outcome and confidence.
|
|
6
10
|
class Agent
|
|
7
11
|
attr_reader :evaluators, :scoring_strategy, :audit_adapter
|
|
8
12
|
|
|
@@ -17,6 +21,10 @@ module DecisionAgent
|
|
|
17
21
|
attr_reader :hash_cache, :hash_cache_mutex, :hash_cache_max_size
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# @param evaluators [Array<#evaluate>] Objects that respond to #evaluate(context, feedback:)
|
|
25
|
+
# @param scoring_strategy [Scoring::Base, nil] Strategy to score evaluations (default: WeightedAverage)
|
|
26
|
+
# @param audit_adapter [Audit::Base, nil] Adapter for recording decisions (default: NullAdapter)
|
|
27
|
+
# @param validate_evaluations [Boolean, nil] If true, validate evaluations; nil = validate unless production
|
|
20
28
|
def initialize(evaluators:, scoring_strategy: nil, audit_adapter: nil, validate_evaluations: nil)
|
|
21
29
|
@evaluators = Array(evaluators)
|
|
22
30
|
@scoring_strategy = scoring_strategy || Scoring::WeightedAverage.new
|
|
@@ -30,6 +38,12 @@ module DecisionAgent
|
|
|
30
38
|
@evaluators.freeze
|
|
31
39
|
end
|
|
32
40
|
|
|
41
|
+
# Runs all evaluators on the context, scores results, and returns a single decision.
|
|
42
|
+
#
|
|
43
|
+
# @param context [Context, Hash] Input data; converted to {Context} if a Hash
|
|
44
|
+
# @param feedback [Hash] Optional feedback passed to each evaluator
|
|
45
|
+
# @return [Decision] The chosen decision with confidence, explanations, and audit payload
|
|
46
|
+
# @raise [NoEvaluationsError] when no evaluator returns a valid evaluation
|
|
33
47
|
def decide(context:, feedback: {})
|
|
34
48
|
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
35
49
|
|
|
@@ -87,7 +101,8 @@ module DecisionAgent
|
|
|
87
101
|
def collect_evaluations(context, feedback)
|
|
88
102
|
@evaluators.map do |evaluator|
|
|
89
103
|
evaluator.evaluate(context, feedback: feedback)
|
|
90
|
-
rescue StandardError
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
warn "[DecisionAgent] Evaluator #{evaluator.class} failed: #{e.message}"
|
|
91
106
|
nil
|
|
92
107
|
end.compact
|
|
93
108
|
end
|
|
@@ -100,20 +115,20 @@ module DecisionAgent
|
|
|
100
115
|
explanations << "Decision: #{final_decision} (confidence: #{confidence.round(2)})"
|
|
101
116
|
|
|
102
117
|
if matching_evals.size == 1
|
|
103
|
-
|
|
104
|
-
explanations << "#{
|
|
118
|
+
evaluation = matching_evals.first
|
|
119
|
+
explanations << "#{evaluation.evaluator_name}: #{evaluation.reason} (weight: #{evaluation.weight})"
|
|
105
120
|
elsif matching_evals.size > 1
|
|
106
121
|
explanations << "Based on #{matching_evals.size} evaluators:"
|
|
107
|
-
matching_evals.each do |
|
|
108
|
-
explanations << " - #{
|
|
122
|
+
matching_evals.each do |evaluation|
|
|
123
|
+
explanations << " - #{evaluation.evaluator_name}: #{evaluation.reason} (weight: #{evaluation.weight})"
|
|
109
124
|
end
|
|
110
125
|
end
|
|
111
126
|
|
|
112
127
|
conflicting_evals = evaluations.reject { |e| e.decision == final_decision }
|
|
113
128
|
if conflicting_evals.any?
|
|
114
129
|
explanations << "Conflicting evaluations resolved by #{@scoring_strategy.class.name.split('::').last}:"
|
|
115
|
-
conflicting_evals.each do |
|
|
116
|
-
explanations << " - #{
|
|
130
|
+
conflicting_evals.each do |evaluation|
|
|
131
|
+
explanations << " - #{evaluation.evaluator_name}: suggested '#{evaluation.decision}' (weight: #{evaluation.weight})"
|
|
117
132
|
end
|
|
118
133
|
end
|
|
119
134
|
|
|
@@ -142,66 +157,49 @@ module DecisionAgent
|
|
|
142
157
|
hashable = payload.slice(:context, :evaluations, :decision, :confidence, :scoring_strategy)
|
|
143
158
|
|
|
144
159
|
# Use fast hash (MD5) as cache key to avoid expensive canonicalization on cache hits
|
|
145
|
-
# Optimized: Use direct JSON.to_json instead of recursive sorting for speed
|
|
146
160
|
# The cache key doesn't need perfect determinism, just good enough to catch duplicates
|
|
147
|
-
#
|
|
161
|
+
# Use OpenSSL::Digest to avoid "Digest::Base cannot be directly inherited" on some Ruby/digest setups
|
|
148
162
|
json_str = hashable.to_json
|
|
149
|
-
fast_key = Digest::MD5.hexdigest(json_str)
|
|
163
|
+
fast_key = OpenSSL::Digest::MD5.hexdigest(json_str)
|
|
150
164
|
|
|
151
165
|
# Fast path: check cache without lock first (unsafe read, but acceptable for cache)
|
|
152
|
-
|
|
153
|
-
cache = self.class.hash_cache
|
|
154
|
-
cached_hash = cache[fast_key]
|
|
166
|
+
cached_hash = lookup_cached_hash(fast_key)
|
|
155
167
|
return cached_hash if cached_hash
|
|
156
168
|
|
|
157
|
-
# Cache miss - compute canonical JSON
|
|
158
|
-
|
|
159
|
-
canonical = canonical_json(hashable)
|
|
160
|
-
|
|
161
|
-
# Compute SHA256 hash (also expensive, but only on cache misses)
|
|
162
|
-
computed_hash = Digest::SHA256.hexdigest(canonical)
|
|
169
|
+
# Cache miss - compute canonical JSON and hash
|
|
170
|
+
computed_hash = compute_canonical_hash(hashable)
|
|
163
171
|
|
|
164
172
|
# Store in cache (thread-safe, with size limit)
|
|
165
|
-
|
|
173
|
+
cache_hash(fast_key, computed_hash)
|
|
174
|
+
|
|
175
|
+
computed_hash
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def lookup_cached_hash(fast_key)
|
|
179
|
+
self.class.hash_cache[fast_key]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def compute_canonical_hash(hashable)
|
|
183
|
+
canonical = canonical_json(hashable)
|
|
184
|
+
OpenSSL::Digest::SHA256.hexdigest(canonical)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def cache_hash(fast_key, computed_hash)
|
|
166
188
|
self.class.hash_cache_mutex.synchronize do
|
|
167
189
|
# Double-check after acquiring lock (another thread may have added it)
|
|
168
190
|
return self.class.hash_cache[fast_key] if self.class.hash_cache[fast_key]
|
|
169
191
|
|
|
170
|
-
|
|
171
|
-
if self.class.hash_cache.size >= self.class.hash_cache_max_size
|
|
172
|
-
# Remove oldest 10% of entries (simple approximation)
|
|
173
|
-
keys_to_remove = self.class.hash_cache.keys.first(self.class.hash_cache_max_size / 10)
|
|
174
|
-
keys_to_remove.each { |key| self.class.hash_cache.delete(key) }
|
|
175
|
-
end
|
|
192
|
+
evict_cache_if_needed
|
|
176
193
|
self.class.hash_cache[fast_key] = computed_hash
|
|
177
194
|
end
|
|
178
|
-
|
|
179
|
-
computed_hash
|
|
180
195
|
end
|
|
181
196
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# MD5 is sufficient for cache keys (collision resistance not critical, speed is)
|
|
185
|
-
def fast_hash_key(hashable)
|
|
186
|
-
# Create a deterministic string representation for hashing
|
|
187
|
-
# Use sorted JSON to ensure determinism (though not RFC 8785 canonical)
|
|
188
|
-
json_str = sort_hash_keys(hashable).to_json
|
|
189
|
-
Digest::MD5.hexdigest(json_str)
|
|
190
|
-
end
|
|
197
|
+
def evict_cache_if_needed
|
|
198
|
+
return unless self.class.hash_cache.size >= self.class.hash_cache_max_size
|
|
191
199
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def sort_hash_keys(obj)
|
|
196
|
-
case obj
|
|
197
|
-
when Hash
|
|
198
|
-
sorted = obj.sort.to_h
|
|
199
|
-
sorted.transform_values { |v| sort_hash_keys(v) }
|
|
200
|
-
when Array
|
|
201
|
-
obj.map { |v| sort_hash_keys(v) }
|
|
202
|
-
else
|
|
203
|
-
obj
|
|
204
|
-
end
|
|
200
|
+
# Remove oldest 10% of entries (simple FIFO eviction)
|
|
201
|
+
keys_to_remove = self.class.hash_cache.keys.first(self.class.hash_cache_max_size / 10)
|
|
202
|
+
keys_to_remove.each { |key| self.class.hash_cache.delete(key) }
|
|
205
203
|
end
|
|
206
204
|
|
|
207
205
|
# Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic JSON serialization
|