decision_agent 0.1.3 → 0.1.6

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -254,6 +254,54 @@ RSpec.describe "Issue Verification Tests" do
254
254
  )
255
255
  end.to raise_error(ActiveRecord::RecordNotUnique)
256
256
  end
257
+
258
+ it "verifies application-level constraint for single active version (all databases)" do
259
+ # For databases that don't support partial unique indexes (like SQLite),
260
+ # the application should enforce only one active version per rule
261
+
262
+ ActiveRecord::Schema.define do
263
+ create_table :rule_versions, force: true do |t|
264
+ t.string :rule_id, null: false
265
+ t.integer :version_number, null: false
266
+ t.text :content, null: false
267
+ t.string :status, default: "active", null: false
268
+ t.timestamps
269
+ end
270
+ add_index :rule_versions, %i[rule_id version_number], unique: true
271
+ end
272
+
273
+ class TestRuleVersion6 < ActiveRecord::Base
274
+ self.table_name = "rule_versions"
275
+
276
+ # Application-level validation (works on all databases)
277
+ validate :only_one_active_per_rule, if: -> { status == "active" }
278
+
279
+ def only_one_active_per_rule
280
+ existing = self.class.where(rule_id: rule_id, status: "active")
281
+ existing = existing.where.not(id: id) if persisted?
282
+ return unless existing.exists?
283
+
284
+ errors.add(:base, "Only one active version allowed per rule")
285
+ end
286
+ end
287
+
288
+ TestRuleVersion6.create!(
289
+ rule_id: "test_rule",
290
+ version_number: 1,
291
+ content: { test: "v1" }.to_json,
292
+ status: "active"
293
+ )
294
+
295
+ # Try to create second active version - should fail with validation error
296
+ expect do
297
+ TestRuleVersion6.create!(
298
+ rule_id: "test_rule",
299
+ version_number: 2,
300
+ content: { test: "v2" }.to_json,
301
+ status: "active"
302
+ )
303
+ end.to raise_error(ActiveRecord::RecordInvalid, /Only one active version allowed/)
304
+ end
257
305
  end
258
306
  end
259
307
  end
@@ -466,7 +514,11 @@ RSpec.describe "Issue Verification Tests" do
466
514
  add_index :rule_versions, %i[rule_id version_number], unique: true
467
515
  end
468
516
 
469
- unless defined?(RuleVersion)
517
+ if defined?(RuleVersion)
518
+ # Clear existing validations if RuleVersion was defined by another spec
519
+ RuleVersion.clear_validators!
520
+ RuleVersion.reset_callbacks(:validate)
521
+ else
470
522
  class ::RuleVersion < ActiveRecord::Base
471
523
  end
472
524
  end
@@ -495,33 +547,55 @@ RSpec.describe "Issue Verification Tests" do
495
547
  end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
496
548
  end
497
549
 
498
- it "raises ValidationError when content is empty string" do
499
- # ActiveRecord validation prevents empty string content
500
- skip "ActiveRecord validation prevents empty string content"
550
+ it "handles empty string content in JSON parsing" do
551
+ # Even if the database allows empty strings (no NOT NULL + no validation),
552
+ # the adapter should handle it gracefully when parsing JSON
553
+ version = RuleVersion.create!(
554
+ rule_id: "test_rule",
555
+ version_number: 1,
556
+ content: "", # EMPTY STRING!
557
+ created_by: "test",
558
+ status: "active"
559
+ )
501
560
 
502
- # This test would only be relevant if the model allowed empty strings
503
- # The RuleVersion model has `validates :content, presence: true`
504
- # which rejects empty strings before record creation
561
+ # serialize_version should catch JSON parsing errors
562
+ expect do
563
+ adapter.send(:serialize_version, version)
564
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
505
565
  end
506
566
 
507
- it "raises ValidationError when content is nil (if allowed by DB)" do
508
- # Skip this test because the schema has NOT NULL constraint on content
509
- # The database won't allow nil content to be saved in the first place
510
- skip "Schema has NOT NULL constraint on content column"
567
+ it "enforces NOT NULL constraint on content column" do
568
+ # The schema has NOT NULL constraint on content column
569
+ # The database should raise an error when trying to create with nil content
511
570
 
512
- # This test would only be relevant if the schema allowed NULL content
513
- # In that case, the serialize_version method already handles it with:
514
- # rescue TypeError, NoMethodError
515
- # raise DecisionAgent::ValidationError, "content is nil or not a string"
571
+ expect do
572
+ RuleVersion.create!(
573
+ rule_id: "test_rule",
574
+ version_number: 1,
575
+ content: nil, # NIL!
576
+ created_by: "test",
577
+ status: "active"
578
+ )
579
+ end.to raise_error(ActiveRecord::NotNullViolation)
516
580
  end
517
581
 
518
- it "raises ValidationError when content contains malformed UTF-8" do
519
- # ActiveRecord validation rejects malformed UTF-8 before record creation
520
- skip "ActiveRecord validation rejects malformed UTF-8 strings"
582
+ it "handles content with special UTF-8 characters correctly" do
583
+ # Instead of testing malformed UTF-8 (which ActiveRecord rejects),
584
+ # test that valid UTF-8 special characters are handled correctly
585
+ special_content = {
586
+ "unicode" => "Hello \u4E16\u754C",
587
+ "emoji" => "\u{1F44D}",
588
+ "special" => "\n\t\r"
589
+ }
521
590
 
522
- # This test would only be relevant if ActiveRecord allowed malformed UTF-8
523
- # In practice, ActiveRecord's blank? check fails on invalid UTF-8
524
- # which prevents the record from being created in the first place
591
+ version = adapter.create_version(
592
+ rule_id: "test_rule",
593
+ content: special_content,
594
+ metadata: { created_by: "test" }
595
+ )
596
+
597
+ loaded = adapter.get_version(version_id: version[:id])
598
+ expect(loaded[:content]).to eq(special_content)
525
599
  end
526
600
 
527
601
  it "raises ValidationError when content is truncated JSON" do
@@ -2,7 +2,7 @@ require "spec_helper"
2
2
  require "decision_agent/monitoring/metrics_collector"
3
3
 
4
4
  RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
5
- let(:collector) { described_class.new(window_size: 60) }
5
+ let(:collector) { described_class.new(window_size: 60, storage: :memory) }
6
6
  let(:decision) do
7
7
  double(
8
8
  "Decision",
@@ -266,7 +266,7 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
266
266
 
267
267
  describe "metric cleanup" do
268
268
  it "removes old metrics outside window" do
269
- collector = described_class.new(window_size: 1)
269
+ collector = described_class.new(window_size: 1, storage: :memory, cleanup_threshold: 1)
270
270
 
271
271
  collector.record_decision(decision, context)
272
272
  expect(collector.metrics_count[:decisions]).to eq(1)
@@ -274,8 +274,226 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
274
274
  sleep 1.5
275
275
 
276
276
  collector.record_decision(decision, context)
277
- # Old metric should be cleaned up
277
+ # Old metric should be cleaned up (threshold=1 means cleanup on every record)
278
278
  expect(collector.metrics_count[:decisions]).to eq(1)
279
279
  end
280
280
  end
281
+
282
+ describe "#record_evaluation" do
283
+ let(:evaluation) do
284
+ double(
285
+ "Evaluation",
286
+ decision: "approve",
287
+ weight: 0.9,
288
+ evaluator_name: "test_evaluator"
289
+ )
290
+ end
291
+
292
+ it "notifies observers" do
293
+ observed = []
294
+ collector.add_observer do |type, metric|
295
+ observed << [type, metric]
296
+ end
297
+
298
+ collector.record_evaluation(evaluation)
299
+
300
+ expect(observed.size).to eq(1)
301
+ expect(observed[0][0]).to eq(:evaluation)
302
+ expect(observed[0][1][:decision]).to eq("approve")
303
+ end
304
+ end
305
+
306
+ describe "#record_performance" do
307
+ it "notifies observers" do
308
+ observed = []
309
+ collector.add_observer do |type, metric|
310
+ observed << [type, metric]
311
+ end
312
+
313
+ collector.record_performance(operation: "test", duration_ms: 10.0, success: true)
314
+
315
+ expect(observed.size).to eq(1)
316
+ expect(observed[0][0]).to eq(:performance)
317
+ expect(observed[0][1][:operation]).to eq("test")
318
+ end
319
+ end
320
+
321
+ describe "#record_error" do
322
+ it "notifies observers" do
323
+ observed = []
324
+ collector.add_observer do |type, metric|
325
+ observed << [type, metric]
326
+ end
327
+
328
+ collector.record_error(StandardError.new("Test"))
329
+
330
+ expect(observed.size).to eq(1)
331
+ expect(observed[0][0]).to eq(:error)
332
+ expect(observed[0][1][:error_class]).to eq("StandardError")
333
+ end
334
+
335
+ it "handles different error types" do
336
+ expect { collector.record_error(ArgumentError.new("Arg error")) }.not_to raise_error
337
+ expect { collector.record_error(TypeError.new("Type error")) }.not_to raise_error
338
+ expect { collector.record_error(Exception.new("Exception")) }.not_to raise_error
339
+ end
340
+ end
341
+
342
+ describe "#add_observer" do
343
+ it "adds an observer callback" do
344
+ callback = proc { |type, metric| }
345
+ collector.add_observer(&callback)
346
+ # Observer should be stored
347
+ expect(collector.instance_variable_get(:@observers)).to include(callback)
348
+ end
349
+
350
+ it "handles observer errors gracefully" do
351
+ # Add observer that raises error
352
+ collector.add_observer do |_type, _metric|
353
+ raise "Observer error"
354
+ end
355
+
356
+ # Should not raise, just warn
357
+ expect { collector.record_decision(decision, context) }.not_to raise_error
358
+ end
359
+ end
360
+
361
+ describe "#statistics" do
362
+ before do
363
+ 3.times do
364
+ evaluation = double("Evaluation", decision: "approve", weight: 0.8, evaluator_name: "eval1")
365
+ collector.record_evaluation(evaluation)
366
+ end
367
+ 2.times do
368
+ evaluation = double("Evaluation", decision: "reject", weight: 0.6, evaluator_name: "eval2")
369
+ collector.record_evaluation(evaluation)
370
+ end
371
+ end
372
+
373
+ it "computes evaluation statistics" do
374
+ stats = collector.statistics
375
+ expect(stats[:evaluations][:total]).to eq(5)
376
+ expect(stats[:evaluations][:avg_weight]).to be_within(0.01).of(0.72)
377
+ end
378
+
379
+ it "handles empty decisions gracefully" do
380
+ empty_collector = described_class.new(storage: :memory)
381
+ stats = empty_collector.statistics
382
+ expect(stats[:decisions]).to eq({})
383
+ end
384
+
385
+ it "handles decisions without duration_ms" do
386
+ decision_no_duration = double(
387
+ "Decision",
388
+ decision: "approve",
389
+ confidence: 0.5,
390
+ evaluations: []
391
+ )
392
+ collector.record_decision(decision_no_duration, context)
393
+ stats = collector.statistics
394
+ expect(stats[:decisions][:avg_duration_ms]).to be_nil
395
+ end
396
+ end
397
+
398
+ describe "#time_series" do
399
+ it "handles empty metric types" do
400
+ series = collector.time_series(metric_type: :nonexistent, bucket_size: 60, time_range: 3600)
401
+ expect(series).to eq([])
402
+ end
403
+
404
+ it "filters metrics by time range" do
405
+ # Record some old metrics (simulated)
406
+ old_time = Time.now.utc - 7200
407
+ allow(Time).to receive(:now).and_return(Time.at(old_time.to_i))
408
+ 5.times { collector.record_decision(decision, context) }
409
+
410
+ # Record new metrics
411
+ allow(Time).to receive(:now).and_call_original
412
+ 3.times { collector.record_decision(decision, context) }
413
+
414
+ series = collector.time_series(metric_type: :decisions, bucket_size: 60, time_range: 3600)
415
+ # Should only include recent metrics
416
+ total = series.sum { |s| s[:count] }
417
+ expect(total).to be <= 3
418
+ end
419
+ end
420
+
421
+ describe "#cleanup_old_metrics_from_storage" do
422
+ it "delegates to storage adapter if it has cleanup method" do
423
+ # Using memory adapter which doesn't have cleanup
424
+ expect(collector.cleanup_old_metrics_from_storage(older_than: 3600)).to eq(0)
425
+ end
426
+ end
427
+
428
+ describe "#initialize_storage_adapter" do
429
+ it "uses memory storage when :memory specified" do
430
+ collector = described_class.new(storage: :memory)
431
+ expect(collector.storage_adapter).to be_a(DecisionAgent::Monitoring::Storage::MemoryAdapter)
432
+ end
433
+
434
+ it "raises error for unknown storage option" do
435
+ expect do
436
+ described_class.new(storage: :unknown)
437
+ end.to raise_error(ArgumentError, /Unknown storage option/)
438
+ end
439
+ end
440
+
441
+ describe "error severity determination" do
442
+ it "determines severity for ArgumentError as medium" do
443
+ error = ArgumentError.new("test")
444
+ collector.record_error(error)
445
+ # Just verify it doesn't raise
446
+ expect(collector.metrics_count[:errors]).to eq(1)
447
+ end
448
+
449
+ it "determines severity for TypeError as medium" do
450
+ error = TypeError.new("test")
451
+ collector.record_error(error)
452
+ expect(collector.metrics_count[:errors]).to eq(1)
453
+ end
454
+
455
+ it "determines severity for Exception as critical" do
456
+ error = Exception.new("test")
457
+ collector.record_error(error)
458
+ expect(collector.metrics_count[:errors]).to eq(1)
459
+ end
460
+ end
461
+
462
+ describe "decision status determination" do
463
+ it "determines status for high confidence decisions" do
464
+ high_conf_decision = double(
465
+ "Decision",
466
+ decision: "approve",
467
+ confidence: 0.9,
468
+ evaluations: []
469
+ )
470
+ collector.record_decision(high_conf_decision, context)
471
+ # Just verify it records successfully
472
+ expect(collector.metrics_count[:decisions]).to eq(1)
473
+ end
474
+
475
+ it "determines status for low confidence decisions" do
476
+ low_conf_decision = double(
477
+ "Decision",
478
+ decision: "approve",
479
+ confidence: 0.2,
480
+ evaluations: []
481
+ )
482
+ collector.record_decision(low_conf_decision, context)
483
+ expect(collector.metrics_count[:decisions]).to eq(1)
484
+ end
485
+ end
486
+
487
+ describe "#compute_performance_stats" do
488
+ it "computes percentile statistics" do
489
+ durations = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
490
+ durations.each do |duration|
491
+ collector.record_performance(operation: "test", duration_ms: duration, success: true)
492
+ end
493
+
494
+ stats = collector.statistics
495
+ expect(stats[:performance][:p95_duration_ms]).to be >= 90
496
+ expect(stats[:performance][:p99_duration_ms]).to be >= 95
497
+ end
498
+ end
281
499
  end
@@ -3,7 +3,7 @@ require "decision_agent/monitoring/metrics_collector"
3
3
  require "decision_agent/monitoring/monitored_agent"
4
4
 
5
5
  RSpec.describe DecisionAgent::Monitoring::MonitoredAgent do
6
- let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new }
6
+ let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new(storage: :memory) }
7
7
  let(:evaluator) do
8
8
  double(
9
9
  "Evaluator",
@@ -3,7 +3,7 @@ require "decision_agent/monitoring/metrics_collector"
3
3
  require "decision_agent/monitoring/prometheus_exporter"
4
4
 
5
5
  RSpec.describe DecisionAgent::Monitoring::PrometheusExporter do
6
- let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new }
6
+ let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new(storage: :memory) }
7
7
  let(:exporter) { described_class.new(metrics_collector: collector, namespace: "test") }
8
8
 
9
9
  let(:decision) do