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
@@ -28,6 +28,15 @@ require_relative "../auth/access_audit_logger"
28
28
  require_relative "middleware/auth_middleware"
29
29
  require_relative "middleware/permission_middleware"
30
30
 
31
+ # Simulation components
32
+ require_relative "../simulation/replay_engine"
33
+ require_relative "../simulation/what_if_analyzer"
34
+ require_relative "../simulation/impact_analyzer"
35
+ require_relative "../simulation/shadow_test_engine"
36
+ require_relative "../simulation/scenario_engine"
37
+ require_relative "../simulation/scenario_library"
38
+ require_relative "../versioning/version_manager"
39
+
31
40
  module DecisionAgent
32
41
  module Web
33
42
  # rubocop:disable Metrics/ClassLength
@@ -41,25 +50,27 @@ module DecisionAgent
41
50
  @batch_test_storage = {}
42
51
  @batch_test_storage_mutex = Mutex.new
43
52
 
53
+ # In-memory storage for simulation runs
54
+ @simulation_storage = {}
55
+ @simulation_storage_mutex = Mutex.new
56
+
44
57
  # Auth components
45
58
  @authenticator = nil
46
59
  @permission_checker = nil
47
60
  @access_audit_logger = nil
48
-
49
- def self.batch_test_storage
50
- @batch_test_storage ||= {}
51
- end
52
-
53
- def self.batch_test_storage_mutex
54
- @batch_test_storage_mutex ||= Mutex.new
55
- end
61
+ @auth_mutex = Mutex.new
56
62
 
57
63
  class << self
64
+ attr_reader :batch_test_storage, :batch_test_storage_mutex, :simulation_storage, :simulation_storage_mutex
58
65
  attr_writer :authenticator
59
66
  end
60
67
 
61
68
  def self.authenticator
62
- @authenticator ||= Auth::Authenticator.new
69
+ return @authenticator if @authenticator
70
+
71
+ @auth_mutex.synchronize do
72
+ @authenticator ||= Auth::Authenticator.new
73
+ end
63
74
  end
64
75
 
65
76
  class << self
@@ -67,7 +78,11 @@ module DecisionAgent
67
78
  end
68
79
 
69
80
  def self.permission_checker
70
- @permission_checker ||= Auth::PermissionChecker.new(adapter: DecisionAgent.rbac_config.adapter)
81
+ return @permission_checker if @permission_checker
82
+
83
+ @auth_mutex.synchronize do
84
+ @permission_checker ||= Auth::PermissionChecker.new(adapter: DecisionAgent.rbac_config.adapter)
85
+ end
71
86
  end
72
87
 
73
88
  class << self
@@ -75,7 +90,11 @@ module DecisionAgent
75
90
  end
76
91
 
77
92
  def self.access_audit_logger
78
- @access_audit_logger ||= Auth::AccessAuditLogger.new
93
+ return @access_audit_logger if @access_audit_logger
94
+
95
+ @auth_mutex.synchronize do
96
+ @access_audit_logger ||= Auth::AccessAuditLogger.new
97
+ end
79
98
  end
80
99
 
81
100
  # Enable CORS for API calls
@@ -204,19 +223,55 @@ module DecisionAgent
204
223
  result = evaluator.evaluate(DecisionAgent::Context.new(context))
205
224
 
206
225
  if result
207
- {
208
- success: true,
209
- decision: result.decision,
210
- weight: result.weight,
211
- reason: result.reason,
212
- evaluator_name: result.evaluator_name,
213
- metadata: result.metadata
214
- }.to_json
226
+ # Get explainability data from metadata if available
227
+ explainability = result.metadata[:explainability] if result.metadata.is_a?(Hash)
228
+
229
+ # Structure response as explainability by default
230
+ # This makes explainability the primary format for decision results
231
+ response = if explainability
232
+ {
233
+ success: true,
234
+ decision: explainability[:decision] || result.decision,
235
+ because: explainability[:because] || [],
236
+ failed_conditions: explainability[:failed_conditions] || [],
237
+ # Include additional metadata for completeness
238
+ confidence: result.weight,
239
+ reason: result.reason,
240
+ evaluator_name: result.evaluator_name,
241
+ # Full explainability data (includes rule_traces in verbose mode)
242
+ explainability: explainability
243
+ }
244
+ else
245
+ # Fallback if explainability is not available
246
+ {
247
+ success: true,
248
+ decision: result.decision,
249
+ because: [],
250
+ failed_conditions: [],
251
+ confidence: result.weight,
252
+ reason: result.reason,
253
+ evaluator_name: result.evaluator_name,
254
+ explainability: {
255
+ decision: result.decision,
256
+ because: [],
257
+ failed_conditions: []
258
+ }
259
+ }
260
+ end
261
+
262
+ response.to_json
215
263
  else
216
264
  {
217
265
  success: true,
218
266
  decision: nil,
219
- message: "No rules matched the given context"
267
+ because: [],
268
+ failed_conditions: [],
269
+ message: "No rules matched the given context",
270
+ explainability: {
271
+ decision: nil,
272
+ because: [],
273
+ failed_conditions: []
274
+ }
220
275
  }.to_json
221
276
  end
222
277
  rescue StandardError => e
@@ -1145,6 +1200,516 @@ module DecisionAgent
1145
1200
  "Batch testing page not found"
1146
1201
  end
1147
1202
 
1203
+ # Simulation API Endpoints
1204
+
1205
+ # POST /api/simulation/replay - Historical replay/backtesting
1206
+ post "/api/simulation/replay" do
1207
+ content_type :json
1208
+
1209
+ begin
1210
+ request_body = request.body.read
1211
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1212
+
1213
+ historical_data = data["historical_data"]
1214
+ rule_version = data["rule_version"]
1215
+ compare_with = data["compare_with"]
1216
+ options = data["options"] || {}
1217
+
1218
+ unless historical_data
1219
+ status 400
1220
+ return { error: "historical_data is required" }.to_json
1221
+ end
1222
+
1223
+ # Get rules for agent creation
1224
+ rules_json = data["rules"]
1225
+ unless rules_json
1226
+ status 400
1227
+ return { error: "rules JSON is required" }.to_json
1228
+ end
1229
+
1230
+ # Create agent
1231
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
1232
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
1233
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1234
+
1235
+ # Create replay engine
1236
+ replay_engine = DecisionAgent::Simulation::ReplayEngine.new(
1237
+ agent: agent,
1238
+ version_manager: version_manager
1239
+ )
1240
+
1241
+ # Convert historical data if it's a file path (for future file upload support)
1242
+ contexts = if historical_data.is_a?(Array)
1243
+ historical_data
1244
+ else
1245
+ # Assume it's a file path - load it
1246
+ raise ArgumentError, "File not found: #{historical_data}" unless File.exist?(historical_data)
1247
+
1248
+ if historical_data.end_with?(".json")
1249
+ JSON.parse(File.read(historical_data))
1250
+ elsif historical_data.end_with?(".csv")
1251
+ # Simple CSV parsing
1252
+ require "csv"
1253
+ csv_data = CSV.read(historical_data, headers: true)
1254
+ csv_data.map(&:to_h)
1255
+ else
1256
+ raise ArgumentError, "Unsupported file format"
1257
+ end
1258
+
1259
+ end
1260
+
1261
+ # Execute replay
1262
+ results = if compare_with
1263
+ replay_engine.replay(
1264
+ historical_data: contexts,
1265
+ rule_version: rule_version,
1266
+ compare_with: compare_with
1267
+ )
1268
+ else
1269
+ replay_engine.replay(
1270
+ historical_data: contexts,
1271
+ rule_version: rule_version,
1272
+ options: options
1273
+ )
1274
+ end
1275
+
1276
+ # Store results
1277
+ replay_id = SecureRandom.uuid
1278
+ self.class.simulation_storage_mutex.synchronize do
1279
+ self.class.simulation_storage[replay_id] = {
1280
+ id: replay_id,
1281
+ type: "replay",
1282
+ status: "completed",
1283
+ created_at: Time.now.utc.iso8601,
1284
+ results: results
1285
+ }
1286
+ end
1287
+
1288
+ {
1289
+ replay_id: replay_id,
1290
+ results: results
1291
+ }.to_json
1292
+ rescue StandardError => e
1293
+ status 500
1294
+ { error: "Replay failed: #{e.message}" }.to_json
1295
+ end
1296
+ end
1297
+
1298
+ # POST /api/simulation/whatif - What-if analysis
1299
+ post "/api/simulation/whatif" do
1300
+ content_type :json
1301
+
1302
+ begin
1303
+ request_body = request.body.read
1304
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1305
+
1306
+ scenarios = data["scenarios"]
1307
+ rule_version = data["rule_version"]
1308
+ options = data["options"] || {}
1309
+
1310
+ unless scenarios.is_a?(Array)
1311
+ status 400
1312
+ return { error: "scenarios array is required" }.to_json
1313
+ end
1314
+
1315
+ # Get rules for agent creation
1316
+ rules_json = data["rules"]
1317
+ unless rules_json
1318
+ status 400
1319
+ return { error: "rules JSON is required" }.to_json
1320
+ end
1321
+
1322
+ # Create agent
1323
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
1324
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
1325
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1326
+
1327
+ # Create what-if analyzer
1328
+ analyzer = DecisionAgent::Simulation::WhatIfAnalyzer.new(
1329
+ agent: agent,
1330
+ version_manager: version_manager
1331
+ )
1332
+
1333
+ # Execute analysis
1334
+ results = analyzer.analyze(
1335
+ scenarios: scenarios,
1336
+ rule_version: rule_version,
1337
+ options: options
1338
+ )
1339
+
1340
+ # Store results
1341
+ analysis_id = SecureRandom.uuid
1342
+ self.class.simulation_storage_mutex.synchronize do
1343
+ self.class.simulation_storage[analysis_id] = {
1344
+ id: analysis_id,
1345
+ type: "whatif",
1346
+ status: "completed",
1347
+ created_at: Time.now.utc.iso8601,
1348
+ results: results
1349
+ }
1350
+ end
1351
+
1352
+ {
1353
+ analysis_id: analysis_id,
1354
+ results: results
1355
+ }.to_json
1356
+ rescue StandardError => e
1357
+ status 500
1358
+ { error: "What-if analysis failed: #{e.message}" }.to_json
1359
+ end
1360
+ end
1361
+
1362
+ # POST /api/simulation/whatif/sensitivity - Sensitivity analysis
1363
+ post "/api/simulation/whatif/sensitivity" do
1364
+ content_type :json
1365
+
1366
+ begin
1367
+ request_body = request.body.read
1368
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1369
+
1370
+ base_scenario = data["base_scenario"]
1371
+ variations = data["variations"]
1372
+ rule_version = data["rule_version"]
1373
+
1374
+ unless base_scenario && variations
1375
+ status 400
1376
+ return { error: "base_scenario and variations are required" }.to_json
1377
+ end
1378
+
1379
+ # Get rules for agent creation
1380
+ rules_json = data["rules"]
1381
+ unless rules_json
1382
+ status 400
1383
+ return { error: "rules JSON is required" }.to_json
1384
+ end
1385
+
1386
+ # Create agent
1387
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
1388
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
1389
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1390
+
1391
+ # Create what-if analyzer
1392
+ analyzer = DecisionAgent::Simulation::WhatIfAnalyzer.new(
1393
+ agent: agent,
1394
+ version_manager: version_manager
1395
+ )
1396
+
1397
+ # Execute sensitivity analysis
1398
+ results = analyzer.sensitivity_analysis(
1399
+ base_scenario: base_scenario,
1400
+ variations: variations,
1401
+ rule_version: rule_version
1402
+ )
1403
+
1404
+ {
1405
+ results: results
1406
+ }.to_json
1407
+ rescue StandardError => e
1408
+ status 500
1409
+ { error: "Sensitivity analysis failed: #{e.message}" }.to_json
1410
+ end
1411
+ end
1412
+
1413
+ # POST /api/simulation/impact - Impact analysis
1414
+ post "/api/simulation/impact" do
1415
+ content_type :json
1416
+
1417
+ begin
1418
+ request_body = request.body.read
1419
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1420
+
1421
+ baseline_version = data["baseline_version"]
1422
+ proposed_version = data["proposed_version"]
1423
+ test_data = data["test_data"]
1424
+ options = data["options"] || {}
1425
+
1426
+ unless baseline_version && proposed_version && test_data
1427
+ status 400
1428
+ return { error: "baseline_version, proposed_version, and test_data are required" }.to_json
1429
+ end
1430
+
1431
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1432
+
1433
+ # Create impact analyzer
1434
+ analyzer = DecisionAgent::Simulation::ImpactAnalyzer.new(
1435
+ version_manager: version_manager
1436
+ )
1437
+
1438
+ # Execute impact analysis
1439
+ results = analyzer.analyze(
1440
+ baseline_version: baseline_version,
1441
+ proposed_version: proposed_version,
1442
+ test_data: test_data,
1443
+ options: options
1444
+ )
1445
+
1446
+ # Store results
1447
+ impact_id = SecureRandom.uuid
1448
+ self.class.simulation_storage_mutex.synchronize do
1449
+ self.class.simulation_storage[impact_id] = {
1450
+ id: impact_id,
1451
+ type: "impact",
1452
+ status: "completed",
1453
+ created_at: Time.now.utc.iso8601,
1454
+ results: results
1455
+ }
1456
+ end
1457
+
1458
+ {
1459
+ impact_id: impact_id,
1460
+ results: results
1461
+ }.to_json
1462
+ rescue StandardError => e
1463
+ status 500
1464
+ { error: "Impact analysis failed: #{e.message}" }.to_json
1465
+ end
1466
+ end
1467
+
1468
+ # POST /api/simulation/shadow - Shadow testing
1469
+ post "/api/simulation/shadow" do
1470
+ content_type :json
1471
+
1472
+ begin
1473
+ request_body = request.body.read
1474
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1475
+
1476
+ context = data["context"]
1477
+ shadow_version = data["shadow_version"]
1478
+ production_rules = data["production_rules"]
1479
+ shadow_rules = data["shadow_rules"]
1480
+ options = data["options"] || {}
1481
+
1482
+ unless context
1483
+ status 400
1484
+ return { error: "context is required" }.to_json
1485
+ end
1486
+
1487
+ unless (production_rules && shadow_rules) || shadow_version
1488
+ status 400
1489
+ return { error: "Either (production_rules and shadow_rules) or shadow_version is required" }.to_json
1490
+ end
1491
+
1492
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1493
+
1494
+ # Create production agent
1495
+ if production_rules
1496
+ prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: production_rules)
1497
+ production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
1498
+ else
1499
+ # Use active version
1500
+ active_version = version_manager.get_active_version
1501
+ if active_version
1502
+ prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: active_version[:content])
1503
+ production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
1504
+ else
1505
+ status 400
1506
+ return { error: "No active version found and production_rules not provided" }.to_json
1507
+ end
1508
+ end
1509
+
1510
+ # Create shadow test engine
1511
+ shadow_engine = DecisionAgent::Simulation::ShadowTestEngine.new(
1512
+ production_agent: production_agent,
1513
+ version_manager: version_manager
1514
+ )
1515
+
1516
+ # Execute shadow test
1517
+ if shadow_rules
1518
+ # Create a temporary version for shadow rules
1519
+ temp_version = {
1520
+ content: shadow_rules,
1521
+ rule_id: "shadow_temp",
1522
+ version_number: 1
1523
+ }
1524
+ result = shadow_engine.test(
1525
+ context: context,
1526
+ shadow_version: temp_version,
1527
+ options: options
1528
+ )
1529
+ else
1530
+ result = shadow_engine.test(
1531
+ context: context,
1532
+ shadow_version: shadow_version,
1533
+ options: options
1534
+ )
1535
+ end
1536
+
1537
+ {
1538
+ result: result
1539
+ }.to_json
1540
+ rescue StandardError => e
1541
+ status 500
1542
+ { error: "Shadow test failed: #{e.message}" }.to_json
1543
+ end
1544
+ end
1545
+
1546
+ # POST /api/simulation/shadow/batch - Batch shadow testing
1547
+ post "/api/simulation/shadow/batch" do
1548
+ content_type :json
1549
+
1550
+ begin
1551
+ request_body = request.body.read
1552
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1553
+
1554
+ contexts = data["contexts"]
1555
+ shadow_version = data["shadow_version"]
1556
+ production_rules = data["production_rules"]
1557
+ shadow_rules = data["shadow_rules"]
1558
+ options = data["options"] || {}
1559
+
1560
+ unless contexts.is_a?(Array)
1561
+ status 400
1562
+ return { error: "contexts array is required" }.to_json
1563
+ end
1564
+
1565
+ unless (production_rules && shadow_rules) || shadow_version
1566
+ status 400
1567
+ return { error: "Either (production_rules and shadow_rules) or shadow_version is required" }.to_json
1568
+ end
1569
+
1570
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1571
+
1572
+ # Create production agent
1573
+ if production_rules
1574
+ prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: production_rules)
1575
+ production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
1576
+ else
1577
+ # Use active version
1578
+ active_version = version_manager.get_active_version
1579
+ if active_version
1580
+ prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: active_version[:content])
1581
+ production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
1582
+ else
1583
+ status 400
1584
+ return { error: "No active version found and production_rules not provided" }.to_json
1585
+ end
1586
+ end
1587
+
1588
+ # Create shadow test engine
1589
+ shadow_engine = DecisionAgent::Simulation::ShadowTestEngine.new(
1590
+ production_agent: production_agent,
1591
+ version_manager: version_manager
1592
+ )
1593
+
1594
+ # Execute batch shadow test
1595
+ if shadow_rules
1596
+ # Create a temporary version for shadow rules
1597
+ temp_version = {
1598
+ content: shadow_rules,
1599
+ rule_id: "shadow_temp",
1600
+ version_number: 1
1601
+ }
1602
+ results = shadow_engine.batch_test(
1603
+ contexts: contexts,
1604
+ shadow_version: temp_version,
1605
+ options: options
1606
+ )
1607
+ else
1608
+ results = shadow_engine.batch_test(
1609
+ contexts: contexts,
1610
+ shadow_version: shadow_version,
1611
+ options: options
1612
+ )
1613
+ end
1614
+
1615
+ {
1616
+ results: results
1617
+ }.to_json
1618
+ rescue StandardError => e
1619
+ status 500
1620
+ { error: "Batch shadow test failed: #{e.message}" }.to_json
1621
+ end
1622
+ end
1623
+
1624
+ # GET /api/simulation/:id - Get simulation results
1625
+ get "/api/simulation/:id" do
1626
+ content_type :json
1627
+
1628
+ begin
1629
+ sim_id = params[:id]
1630
+
1631
+ sim_data = nil
1632
+ self.class.simulation_storage_mutex.synchronize do
1633
+ sim_data = self.class.simulation_storage[sim_id]
1634
+ end
1635
+
1636
+ unless sim_data
1637
+ status 404
1638
+ return { error: "Simulation not found" }.to_json
1639
+ end
1640
+
1641
+ sim_data.to_json
1642
+ rescue StandardError => e
1643
+ status 500
1644
+ { error: e.message }.to_json
1645
+ end
1646
+ end
1647
+
1648
+ # GET /api/versions - List all versions (for simulation dropdowns)
1649
+ get "/api/versions" do
1650
+ content_type :json
1651
+
1652
+ begin
1653
+ version_manager = DecisionAgent::Versioning::VersionManager.new
1654
+ versions = version_manager.list_versions
1655
+
1656
+ {
1657
+ versions: versions.map do |v|
1658
+ {
1659
+ id: v[:id] || v["id"],
1660
+ rule_id: v[:rule_id] || v["rule_id"],
1661
+ version_number: v[:version_number] || v["version_number"],
1662
+ status: v[:status] || v["status"],
1663
+ created_at: v[:created_at] || v["created_at"]
1664
+ }
1665
+ end
1666
+ }.to_json
1667
+ rescue StandardError => e
1668
+ status 500
1669
+ { error: e.message }.to_json
1670
+ end
1671
+ end
1672
+
1673
+ # GET /simulation - Simulation dashboard UI page
1674
+ get "/simulation" do
1675
+ send_file File.join(settings.public_folder, "simulation.html")
1676
+ rescue StandardError
1677
+ status 404
1678
+ "Simulation page not found"
1679
+ end
1680
+
1681
+ # GET /simulation/replay - Historical replay UI page
1682
+ get "/simulation/replay" do
1683
+ send_file File.join(settings.public_folder, "simulation_replay.html")
1684
+ rescue StandardError
1685
+ status 404
1686
+ "Historical replay page not found"
1687
+ end
1688
+
1689
+ # GET /simulation/whatif - What-if analysis UI page
1690
+ get "/simulation/whatif" do
1691
+ send_file File.join(settings.public_folder, "simulation_whatif.html")
1692
+ rescue StandardError
1693
+ status 404
1694
+ "What-if analysis page not found"
1695
+ end
1696
+
1697
+ # GET /simulation/impact - Impact analysis UI page
1698
+ get "/simulation/impact" do
1699
+ send_file File.join(settings.public_folder, "simulation_impact.html")
1700
+ rescue StandardError
1701
+ status 404
1702
+ "Impact analysis page not found"
1703
+ end
1704
+
1705
+ # GET /simulation/shadow - Shadow testing UI page
1706
+ get "/simulation/shadow" do
1707
+ send_file File.join(settings.public_folder, "simulation_shadow.html")
1708
+ rescue StandardError
1709
+ status 404
1710
+ "Shadow testing page not found"
1711
+ end
1712
+
1148
1713
  # GET /auth/login - Login page
1149
1714
  get "/auth/login" do
1150
1715
  send_file File.join(settings.public_folder, "login.html")
@@ -1660,10 +2225,14 @@ module DecisionAgent
1660
2225
  return true if permissions_disabled?
1661
2226
 
1662
2227
  checker = self.class.permission_checker
1663
- unless checker.can?(@current_user, permission, resource)
2228
+ granted = checker.can?(@current_user, permission, resource)
2229
+
2230
+ unless granted
2231
+ # Log the permission denial, but don't let logging failures prevent the denial
1664
2232
  begin
2233
+ user_id = checker.user_id(@current_user)
1665
2234
  self.class.access_audit_logger.log_permission_check(
1666
- user_id: checker.user_id(@current_user),
2235
+ user_id: user_id,
1667
2236
  permission: permission,
1668
2237
  resource_type: resource&.class&.name,
1669
2238
  resource_id: resource&.id,
@@ -1679,9 +2248,11 @@ module DecisionAgent
1679
2248
  halt 403, { error: "Permission denied: #{permission}" }.to_json
1680
2249
  end
1681
2250
 
2251
+ # Log successful permission check, but don't let logging failures prevent access
1682
2252
  begin
2253
+ user_id = checker.user_id(@current_user)
1683
2254
  self.class.access_audit_logger.log_permission_check(
1684
- user_id: checker.user_id(@current_user),
2255
+ user_id: user_id,
1685
2256
  permission: permission,
1686
2257
  resource_type: resource&.class&.name,
1687
2258
  resource_id: resource&.id,