decision_agent 0.1.3 → 0.1.4

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  3. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  4. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  5. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  6. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  7. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  8. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  9. data/lib/decision_agent/monitoring/metrics_collector.rb +148 -3
  10. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  11. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  12. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  13. data/lib/decision_agent/version.rb +1 -1
  14. data/lib/decision_agent.rb +7 -0
  15. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  16. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  17. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  18. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  19. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  20. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  21. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  22. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  23. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  24. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  25. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  26. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  27. data/spec/ab_testing/ab_test_spec.rb +270 -0
  28. data/spec/examples.txt +612 -548
  29. data/spec/issue_verification_spec.rb +95 -21
  30. data/spec/monitoring/metrics_collector_spec.rb +2 -2
  31. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  32. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  33. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  34. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  35. metadata +26 -2
@@ -0,0 +1,152 @@
1
+ module DecisionAgent
2
+ module ABTesting
3
+ # Agent wrapper that adds A/B testing capabilities to the standard Agent
4
+ # Automatically handles variant assignment and decision tracking
5
+ class ABTestingAgent
6
+ attr_reader :ab_test_manager, :version_manager
7
+
8
+ # @param ab_test_manager [ABTestManager] The A/B test manager
9
+ # @param version_manager [Versioning::VersionManager] Version manager for rules
10
+ # @param evaluators [Array] Base evaluators (can be overridden by versioned rules)
11
+ # @param scoring_strategy [Scoring::Base] Scoring strategy
12
+ # @param audit_adapter [Audit::Adapter] Audit adapter
13
+ def initialize(
14
+ ab_test_manager:,
15
+ version_manager: nil,
16
+ evaluators: [],
17
+ scoring_strategy: nil,
18
+ audit_adapter: nil
19
+ )
20
+ @ab_test_manager = ab_test_manager
21
+ @version_manager = version_manager || ab_test_manager.version_manager
22
+ @base_evaluators = evaluators
23
+ @scoring_strategy = scoring_strategy
24
+ @audit_adapter = audit_adapter
25
+ end
26
+
27
+ # Make a decision with A/B testing support
28
+ # @param context [Hash, Context] The decision context
29
+ # @param feedback [Hash] Optional feedback
30
+ # @param ab_test_id [String, Integer, nil] Optional A/B test ID
31
+ # @param user_id [String, nil] Optional user ID for consistent assignment
32
+ # @return [Hash] Decision result with A/B test metadata
33
+ def decide(context:, feedback: {}, ab_test_id: nil, user_id: nil)
34
+ ctx = context.is_a?(Context) ? context : Context.new(context)
35
+
36
+ # If A/B test is specified, use variant assignment
37
+ if ab_test_id
38
+ decide_with_ab_test(ctx, feedback, ab_test_id, user_id)
39
+ else
40
+ # Standard decision without A/B testing
41
+ agent = build_agent(@base_evaluators)
42
+ decision = agent.decide(context: ctx, feedback: feedback)
43
+
44
+ {
45
+ decision: decision.decision,
46
+ confidence: decision.confidence,
47
+ explanations: decision.explanations,
48
+ evaluations: decision.evaluations,
49
+ ab_test: nil
50
+ }
51
+ end
52
+ end
53
+
54
+ # Get A/B test results
55
+ # @param test_id [String, Integer] The test ID
56
+ # @return [Hash] Test results and statistics
57
+ def get_test_results(test_id)
58
+ @ab_test_manager.get_results(test_id)
59
+ end
60
+
61
+ # List active A/B tests
62
+ # @return [Array<ABTest>] Active tests
63
+ def active_tests
64
+ @ab_test_manager.active_tests
65
+ end
66
+
67
+ private
68
+
69
+ def decide_with_ab_test(context, feedback, ab_test_id, user_id)
70
+ # Assign variant
71
+ assignment = @ab_test_manager.assign_variant(test_id: ab_test_id, user_id: user_id)
72
+
73
+ # Get the version for the assigned variant
74
+ version = @version_manager.get_version(version_id: assignment[:version_id])
75
+ raise VersionNotFoundError, "Version not found: #{assignment[:version_id]}" unless version
76
+
77
+ # Build evaluators from the versioned rule content
78
+ evaluators = build_evaluators_from_version(version)
79
+
80
+ # Create agent with version-specific evaluators
81
+ agent = build_agent(evaluators)
82
+
83
+ # Make decision
84
+ decision = agent.decide(context: context, feedback: feedback)
85
+
86
+ # Record the decision result
87
+ @ab_test_manager.record_decision(
88
+ assignment_id: assignment[:assignment_id],
89
+ decision: decision.decision,
90
+ confidence: decision.confidence
91
+ )
92
+
93
+ # Return decision with A/B test metadata
94
+ {
95
+ decision: decision.decision,
96
+ confidence: decision.confidence,
97
+ explanations: decision.explanations,
98
+ evaluations: decision.evaluations,
99
+ ab_test: {
100
+ test_id: ab_test_id,
101
+ variant: assignment[:variant],
102
+ version_id: assignment[:version_id],
103
+ assignment_id: assignment[:assignment_id]
104
+ }
105
+ }
106
+ end
107
+
108
+ def build_agent(evaluators)
109
+ Agent.new(
110
+ evaluators: evaluators.empty? ? @base_evaluators : evaluators,
111
+ scoring_strategy: @scoring_strategy,
112
+ audit_adapter: @audit_adapter
113
+ )
114
+ end
115
+
116
+ def build_evaluators_from_version(version)
117
+ content = version[:content]
118
+
119
+ # If the version content contains evaluator configurations, build them
120
+ # Otherwise, use base evaluators
121
+ if content.is_a?(Hash) && content[:evaluators]
122
+ content[:evaluators].map do |eval_config|
123
+ build_evaluator_from_config(eval_config)
124
+ end
125
+ elsif content.is_a?(Hash) && content[:rules]
126
+ # Build a JsonRuleEvaluator from the full content (ruleset + rules)
127
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
128
+ else
129
+ # Fallback to base evaluators
130
+ @base_evaluators
131
+ end
132
+ end
133
+
134
+ def build_evaluator_from_config(config)
135
+ case config[:type]
136
+ when "json_rule"
137
+ Evaluators::JsonRuleEvaluator.new(
138
+ rules_json: config[:rules]
139
+ )
140
+ when "static"
141
+ Evaluators::StaticEvaluator.new(
142
+ decision: config[:decision],
143
+ weight: config[:weight] || 1.0,
144
+ reason: config[:reason]
145
+ )
146
+ else
147
+ raise "Unknown evaluator type: #{config[:type]}"
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,155 @@
1
+ require "active_record"
2
+
3
+ module DecisionAgent
4
+ module ABTesting
5
+ module Storage
6
+ # ActiveRecord storage adapter for A/B tests
7
+ # Requires Rails models: ABTestModel, ABTestAssignmentModel
8
+ class ActiveRecordAdapter < Adapter
9
+ # Check if ActiveRecord models are available
10
+ def self.available?
11
+ defined?(::ABTestModel) && defined?(::ABTestAssignmentModel)
12
+ end
13
+
14
+ def initialize
15
+ super
16
+ raise "ActiveRecord models not available. Run the generator to create them." unless self.class.available?
17
+ end
18
+
19
+ def save_test(test)
20
+ record = ::ABTestModel.create!(
21
+ name: test.name,
22
+ champion_version_id: test.champion_version_id,
23
+ challenger_version_id: test.challenger_version_id,
24
+ traffic_split: test.traffic_split,
25
+ start_date: test.start_date,
26
+ end_date: test.end_date,
27
+ status: test.status
28
+ )
29
+
30
+ to_ab_test(record)
31
+ end
32
+
33
+ def get_test(test_id)
34
+ record = ::ABTestModel.find_by(id: test_id)
35
+ record ? to_ab_test(record) : nil
36
+ end
37
+
38
+ def update_test(test_id, attributes)
39
+ record = ::ABTestModel.find(test_id)
40
+ record.update!(attributes)
41
+ to_ab_test(record)
42
+ end
43
+
44
+ def list_tests(status: nil, limit: nil)
45
+ query = ::ABTestModel.order(created_at: :desc)
46
+ query = query.where(status: status) if status
47
+ query = query.limit(limit) if limit
48
+
49
+ query.map { |record| to_ab_test(record) }
50
+ end
51
+
52
+ def save_assignment(assignment)
53
+ record = ::ABTestAssignmentModel.create!(
54
+ ab_test_id: assignment.ab_test_id,
55
+ user_id: assignment.user_id,
56
+ variant: assignment.variant.to_s,
57
+ version_id: assignment.version_id,
58
+ decision_result: assignment.decision_result,
59
+ confidence: assignment.confidence,
60
+ context: assignment.context,
61
+ timestamp: assignment.timestamp
62
+ )
63
+
64
+ to_assignment(record)
65
+ end
66
+
67
+ def update_assignment(assignment_id, attributes)
68
+ record = ::ABTestAssignmentModel.find(assignment_id)
69
+ record.update!(attributes)
70
+ to_assignment(record)
71
+ end
72
+
73
+ def get_assignments(test_id)
74
+ ::ABTestAssignmentModel
75
+ .where(ab_test_id: test_id)
76
+ .order(timestamp: :desc)
77
+ .map { |record| to_assignment(record) }
78
+ end
79
+
80
+ # rubocop:disable Naming/PredicateMethod
81
+ def delete_test(test_id)
82
+ record = ::ABTestModel.find(test_id)
83
+ ::ABTestAssignmentModel.where(ab_test_id: test_id).delete_all
84
+ record.destroy
85
+ true
86
+ end
87
+ # rubocop:enable Naming/PredicateMethod
88
+
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
+ private
103
+
104
+ def to_ab_test(record)
105
+ ABTest.new(
106
+ id: record.id,
107
+ name: record.name,
108
+ champion_version_id: record.champion_version_id,
109
+ challenger_version_id: record.challenger_version_id,
110
+ traffic_split: parse_traffic_split(record.traffic_split),
111
+ start_date: record.start_date,
112
+ end_date: record.end_date,
113
+ status: record.status
114
+ )
115
+ end
116
+
117
+ def to_assignment(record)
118
+ ABTestAssignment.new(
119
+ id: record.id,
120
+ ab_test_id: record.ab_test_id,
121
+ user_id: record.user_id,
122
+ variant: record.variant.to_sym,
123
+ version_id: record.version_id,
124
+ timestamp: record.timestamp,
125
+ decision_result: record.decision_result,
126
+ confidence: record.confidence,
127
+ context: parse_context(record.context)
128
+ )
129
+ end
130
+
131
+ def parse_traffic_split(value)
132
+ case value
133
+ when Hash
134
+ value.symbolize_keys
135
+ when String
136
+ JSON.parse(value).symbolize_keys
137
+ else
138
+ value
139
+ end
140
+ end
141
+
142
+ def parse_context(value)
143
+ case value
144
+ when Hash
145
+ value
146
+ when String
147
+ JSON.parse(value)
148
+ else
149
+ {}
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,67 @@
1
+ module DecisionAgent
2
+ module ABTesting
3
+ module Storage
4
+ # Base adapter interface for A/B test persistence
5
+ class Adapter
6
+ # Save an A/B test
7
+ # @param test [ABTest] The test to save
8
+ # @return [ABTest] The saved test with ID
9
+ def save_test(test)
10
+ raise NotImplementedError, "#{self.class} must implement #save_test"
11
+ end
12
+
13
+ # Get an A/B test by ID
14
+ # @param test_id [String, Integer] The test ID
15
+ # @return [ABTest, nil] The test or nil
16
+ def get_test(test_id)
17
+ raise NotImplementedError, "#{self.class} must implement #get_test"
18
+ end
19
+
20
+ # Update an A/B test
21
+ # @param test_id [String, Integer] The test ID
22
+ # @param attributes [Hash] Attributes to update
23
+ # @return [ABTest] The updated test
24
+ def update_test(test_id, attributes)
25
+ raise NotImplementedError, "#{self.class} must implement #update_test"
26
+ end
27
+
28
+ # List A/B tests
29
+ # @param status [String, nil] Filter by status
30
+ # @param limit [Integer, nil] Limit results
31
+ # @return [Array<ABTest>] Array of tests
32
+ def list_tests(status: nil, limit: nil)
33
+ raise NotImplementedError, "#{self.class} must implement #list_tests"
34
+ end
35
+
36
+ # Save an assignment
37
+ # @param assignment [ABTestAssignment] The assignment to save
38
+ # @return [ABTestAssignment] The saved assignment with ID
39
+ def save_assignment(assignment)
40
+ raise NotImplementedError, "#{self.class} must implement #save_assignment"
41
+ end
42
+
43
+ # Update an assignment
44
+ # @param assignment_id [String, Integer] The assignment ID
45
+ # @param attributes [Hash] Attributes to update
46
+ # @return [ABTestAssignment] The updated assignment
47
+ def update_assignment(assignment_id, attributes)
48
+ raise NotImplementedError, "#{self.class} must implement #update_assignment"
49
+ end
50
+
51
+ # Get assignments for a test
52
+ # @param test_id [String, Integer] The test ID
53
+ # @return [Array<ABTestAssignment>] Array of assignments
54
+ def get_assignments(test_id)
55
+ raise NotImplementedError, "#{self.class} must implement #get_assignments"
56
+ end
57
+
58
+ # Delete a test and its assignments
59
+ # @param test_id [String, Integer] The test ID
60
+ # @return [Boolean] True if deleted
61
+ def delete_test(test_id)
62
+ raise NotImplementedError, "#{self.class} must implement #delete_test"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,116 @@
1
+ require "monitor"
2
+
3
+ module DecisionAgent
4
+ module ABTesting
5
+ module Storage
6
+ # In-memory storage adapter for A/B tests
7
+ # Useful for testing and development
8
+ class MemoryAdapter < Adapter
9
+ include MonitorMixin
10
+
11
+ def initialize
12
+ super
13
+ @tests = {}
14
+ @assignments = {}
15
+ @test_id_counter = 0
16
+ @assignment_id_counter = 0
17
+ end
18
+
19
+ def save_test(test)
20
+ synchronize do
21
+ @test_id_counter += 1
22
+ test_data = test.to_h.merge(id: @test_id_counter)
23
+ @tests[@test_id_counter] = test_data
24
+
25
+ ABTest.new(**test_data)
26
+ end
27
+ end
28
+
29
+ def get_test(test_id)
30
+ synchronize do
31
+ test_data = @tests[test_id.to_i]
32
+ test_data ? ABTest.new(**test_data) : nil
33
+ end
34
+ end
35
+
36
+ def update_test(test_id, attributes)
37
+ synchronize do
38
+ test_data = @tests[test_id.to_i]
39
+ raise TestNotFoundError, "Test not found: #{test_id}" unless test_data
40
+
41
+ test_data.merge!(attributes)
42
+ @tests[test_id.to_i] = test_data
43
+
44
+ ABTest.new(**test_data)
45
+ end
46
+ end
47
+
48
+ def list_tests(status: nil, limit: nil)
49
+ synchronize do
50
+ tests = @tests.values
51
+
52
+ tests = tests.select { |t| t[:status] == status } if status
53
+ tests = tests.last(limit) if limit
54
+
55
+ tests.map { |t| ABTest.new(**t) }
56
+ end
57
+ end
58
+
59
+ def save_assignment(assignment)
60
+ synchronize do
61
+ @assignment_id_counter += 1
62
+ assignment_data = assignment.to_h.merge(id: @assignment_id_counter)
63
+ @assignments[@assignment_id_counter] = assignment_data
64
+
65
+ ABTestAssignment.new(**assignment_data)
66
+ end
67
+ end
68
+
69
+ def update_assignment(assignment_id, attributes)
70
+ synchronize do
71
+ assignment_data = @assignments[assignment_id.to_i]
72
+ raise "Assignment not found: #{assignment_id}" unless assignment_data
73
+
74
+ assignment_data.merge!(attributes)
75
+ @assignments[assignment_id.to_i] = assignment_data
76
+
77
+ ABTestAssignment.new(**assignment_data)
78
+ end
79
+ end
80
+
81
+ def get_assignments(test_id)
82
+ synchronize do
83
+ assignments = @assignments.values.select { |a| a[:ab_test_id] == test_id }
84
+ assignments.map { |a| ABTestAssignment.new(**a) }
85
+ end
86
+ end
87
+
88
+ def delete_test(test_id)
89
+ synchronize do
90
+ @tests.delete(test_id.to_i)
91
+ @assignments.delete_if { |_id, a| a[:ab_test_id] == test_id }
92
+ true
93
+ end
94
+ end
95
+
96
+ # Additional helper methods
97
+ def clear!
98
+ synchronize do
99
+ @tests.clear
100
+ @assignments.clear
101
+ @test_id_counter = 0
102
+ @assignment_id_counter = 0
103
+ end
104
+ end
105
+
106
+ def test_count
107
+ synchronize { @tests.size }
108
+ end
109
+
110
+ def assignment_count
111
+ synchronize { @assignments.size }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end