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.
- checksums.yaml +4 -4
- data/README.md +84 -233
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +37 -9
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +59 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/examples.txt +1542 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +221 -3
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- metadata +123 -6
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EvaluationMetric < ApplicationRecord
|
|
4
|
+
belongs_to :decision_log, optional: true
|
|
5
|
+
|
|
6
|
+
validates :evaluator_name, presence: true
|
|
7
|
+
validates :score, numericality: true, allow_nil: true
|
|
8
|
+
|
|
9
|
+
scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
|
|
10
|
+
scope :by_evaluator, ->(evaluator) { where(evaluator_name: evaluator) }
|
|
11
|
+
scope :successful, -> { where(success: true) }
|
|
12
|
+
scope :failed, -> { where(success: false) }
|
|
13
|
+
|
|
14
|
+
# Aggregation helpers
|
|
15
|
+
def self.average_score_by_evaluator(time_range: 3600)
|
|
16
|
+
recent(time_range)
|
|
17
|
+
.where.not(score: nil)
|
|
18
|
+
.group(:evaluator_name)
|
|
19
|
+
.average(:score)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.success_rate_by_evaluator(time_range: 3600)
|
|
23
|
+
recent(time_range)
|
|
24
|
+
.where.not(success: nil)
|
|
25
|
+
.group(:evaluator_name)
|
|
26
|
+
.select("evaluator_name, AVG(CASE WHEN success THEN 1.0 ELSE 0.0 END) as success_rate")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.count_by_evaluator(time_range: 3600)
|
|
30
|
+
recent(time_range)
|
|
31
|
+
.group(:evaluator_name)
|
|
32
|
+
.count
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse JSON details field
|
|
36
|
+
def parsed_details
|
|
37
|
+
return {} if details.nil?
|
|
38
|
+
|
|
39
|
+
JSON.parse(details, symbolize_names: true)
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateDecisionAgentMonitoringTables < ActiveRecord::Migration[7.0]
|
|
4
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
5
|
+
def change
|
|
6
|
+
# Decision logs table
|
|
7
|
+
create_table :decision_logs do |t|
|
|
8
|
+
t.string :decision, null: false
|
|
9
|
+
t.float :confidence
|
|
10
|
+
t.integer :evaluations_count, default: 0
|
|
11
|
+
t.float :duration_ms
|
|
12
|
+
t.string :status # success, failure, error
|
|
13
|
+
t.text :context # JSON
|
|
14
|
+
t.text :metadata # JSON
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :decision_logs, :decision
|
|
20
|
+
add_index :decision_logs, :status
|
|
21
|
+
add_index :decision_logs, :confidence
|
|
22
|
+
add_index :decision_logs, :created_at
|
|
23
|
+
add_index :decision_logs, %i[decision created_at]
|
|
24
|
+
add_index :decision_logs, %i[status created_at]
|
|
25
|
+
|
|
26
|
+
# Evaluation metrics table
|
|
27
|
+
create_table :evaluation_metrics do |t|
|
|
28
|
+
t.references :decision_log, foreign_key: true, index: true
|
|
29
|
+
t.string :evaluator_name, null: false
|
|
30
|
+
t.float :score
|
|
31
|
+
t.boolean :success
|
|
32
|
+
t.float :duration_ms
|
|
33
|
+
t.text :details # JSON
|
|
34
|
+
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :evaluation_metrics, :evaluator_name
|
|
39
|
+
add_index :evaluation_metrics, :success
|
|
40
|
+
add_index :evaluation_metrics, :created_at
|
|
41
|
+
add_index :evaluation_metrics, %i[evaluator_name created_at]
|
|
42
|
+
add_index :evaluation_metrics, %i[evaluator_name success]
|
|
43
|
+
|
|
44
|
+
# Performance metrics table
|
|
45
|
+
create_table :performance_metrics do |t|
|
|
46
|
+
t.string :operation, null: false
|
|
47
|
+
t.float :duration_ms
|
|
48
|
+
t.string :status # success, failure, error
|
|
49
|
+
t.text :metadata # JSON
|
|
50
|
+
|
|
51
|
+
t.timestamps
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
add_index :performance_metrics, :operation
|
|
55
|
+
add_index :performance_metrics, :status
|
|
56
|
+
add_index :performance_metrics, :duration_ms
|
|
57
|
+
add_index :performance_metrics, :created_at
|
|
58
|
+
add_index :performance_metrics, %i[operation created_at]
|
|
59
|
+
add_index :performance_metrics, %i[status created_at]
|
|
60
|
+
|
|
61
|
+
# Error metrics table
|
|
62
|
+
create_table :error_metrics do |t|
|
|
63
|
+
t.string :error_type, null: false
|
|
64
|
+
t.text :message
|
|
65
|
+
t.text :stack_trace # JSON array
|
|
66
|
+
t.string :severity # low, medium, high, critical
|
|
67
|
+
t.text :context # JSON
|
|
68
|
+
|
|
69
|
+
t.timestamps
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
add_index :error_metrics, :error_type
|
|
73
|
+
add_index :error_metrics, :severity
|
|
74
|
+
add_index :error_metrics, :created_at
|
|
75
|
+
add_index :error_metrics, %i[error_type created_at]
|
|
76
|
+
add_index :error_metrics, %i[severity created_at]
|
|
77
|
+
|
|
78
|
+
# PostgreSQL-specific optimizations (optional)
|
|
79
|
+
return unless adapter_name == "PostgreSQL"
|
|
80
|
+
|
|
81
|
+
# Partial indexes for active records (recent data)
|
|
82
|
+
execute <<-SQL
|
|
83
|
+
CREATE INDEX index_decision_logs_on_recent
|
|
84
|
+
ON decision_logs (created_at DESC)
|
|
85
|
+
WHERE created_at >= NOW() - INTERVAL '7 days';
|
|
86
|
+
SQL
|
|
87
|
+
|
|
88
|
+
execute <<-SQL
|
|
89
|
+
CREATE INDEX index_performance_metrics_on_recent
|
|
90
|
+
ON performance_metrics (created_at DESC)
|
|
91
|
+
WHERE created_at >= NOW() - INTERVAL '7 days';
|
|
92
|
+
SQL
|
|
93
|
+
|
|
94
|
+
execute <<-SQL
|
|
95
|
+
CREATE INDEX index_error_metrics_on_recent_critical
|
|
96
|
+
ON error_metrics (created_at DESC)
|
|
97
|
+
WHERE severity IN ('high', 'critical') AND created_at >= NOW() - INTERVAL '7 days';
|
|
98
|
+
SQL
|
|
99
|
+
|
|
100
|
+
# Consider table partitioning for large-scale deployments
|
|
101
|
+
# Example: Partition by month for decision_logs
|
|
102
|
+
# This is commented out by default - enable if needed
|
|
103
|
+
# execute <<-SQL
|
|
104
|
+
# CREATE TABLE decision_logs_partitioned (LIKE decision_logs INCLUDING ALL)
|
|
105
|
+
# PARTITION BY RANGE (created_at);
|
|
106
|
+
# SQL
|
|
107
|
+
end
|
|
108
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
109
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class PerformanceMetric < ApplicationRecord
|
|
4
|
+
validates :operation, presence: true
|
|
5
|
+
validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
6
|
+
validates :status, inclusion: { in: %w[success failure error] }, allow_nil: true
|
|
7
|
+
|
|
8
|
+
scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
|
|
9
|
+
scope :by_operation, ->(operation) { where(operation: operation) }
|
|
10
|
+
scope :successful, -> { where(status: "success") }
|
|
11
|
+
scope :failed, -> { where(status: "failure") }
|
|
12
|
+
scope :with_errors, -> { where(status: "error") }
|
|
13
|
+
scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
|
|
14
|
+
|
|
15
|
+
# Performance statistics
|
|
16
|
+
def self.average_duration(time_range: 3600)
|
|
17
|
+
recent(time_range).where.not(duration_ms: nil).average(:duration_ms).to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.percentile(pct, time_range: 3600)
|
|
21
|
+
durations = recent(time_range).where.not(duration_ms: nil).order(:duration_ms).pluck(:duration_ms)
|
|
22
|
+
return 0.0 if durations.empty?
|
|
23
|
+
|
|
24
|
+
index = ((durations.length - 1) * pct).ceil
|
|
25
|
+
durations[index].to_f
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.p50(time_range: 3600)
|
|
29
|
+
percentile(0.50, time_range: time_range)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.p95(time_range: 3600)
|
|
33
|
+
percentile(0.95, time_range: time_range)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.p99(time_range: 3600)
|
|
37
|
+
percentile(0.99, time_range: time_range)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.max_duration(time_range: 3600)
|
|
41
|
+
recent(time_range).maximum(:duration_ms).to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.min_duration(time_range: 3600)
|
|
45
|
+
recent(time_range).minimum(:duration_ms).to_f
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.success_rate(time_range: 3600)
|
|
49
|
+
total = recent(time_range).where.not(status: nil).count
|
|
50
|
+
return 0.0 if total.zero?
|
|
51
|
+
|
|
52
|
+
successful_count = recent(time_range).successful.count
|
|
53
|
+
successful_count.to_f / total
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.count_by_operation(time_range: 3600)
|
|
57
|
+
recent(time_range).group(:operation).count
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Time series aggregation
|
|
61
|
+
def self.average_duration_by_time(bucket_size: 60, time_range: 3600)
|
|
62
|
+
recent(time_range)
|
|
63
|
+
.where.not(duration_ms: nil)
|
|
64
|
+
.group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
|
|
65
|
+
.average(:duration_ms)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Parse JSON metadata field
|
|
69
|
+
def parsed_metadata
|
|
70
|
+
return {} if metadata.nil?
|
|
71
|
+
|
|
72
|
+
JSON.parse(metadata, symbolize_names: true)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
{}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/ab_testing/ab_test_assignment"
|
|
3
|
+
|
|
4
|
+
RSpec.describe DecisionAgent::ABTesting::ABTestAssignment do
|
|
5
|
+
describe "#initialize" do
|
|
6
|
+
it "creates an assignment with required fields" do
|
|
7
|
+
assignment = described_class.new(
|
|
8
|
+
ab_test_id: "test_1",
|
|
9
|
+
variant: :champion,
|
|
10
|
+
version_id: "v1"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
expect(assignment.ab_test_id).to eq("test_1")
|
|
14
|
+
expect(assignment.variant).to eq(:champion)
|
|
15
|
+
expect(assignment.version_id).to eq("v1")
|
|
16
|
+
expect(assignment.timestamp).to be_a(Time)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "accepts optional user_id" do
|
|
20
|
+
assignment = described_class.new(
|
|
21
|
+
ab_test_id: "test_1",
|
|
22
|
+
variant: :champion,
|
|
23
|
+
version_id: "v1",
|
|
24
|
+
user_id: "user_123"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(assignment.user_id).to eq("user_123")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "accepts optional timestamp" do
|
|
31
|
+
custom_time = Time.new(2024, 1, 1, 12, 0, 0, "+00:00")
|
|
32
|
+
assignment = described_class.new(
|
|
33
|
+
ab_test_id: "test_1",
|
|
34
|
+
variant: :champion,
|
|
35
|
+
version_id: "v1",
|
|
36
|
+
timestamp: custom_time
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(assignment.timestamp).to eq(custom_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "accepts optional decision_result and confidence" do
|
|
43
|
+
assignment = described_class.new(
|
|
44
|
+
ab_test_id: "test_1",
|
|
45
|
+
variant: :champion,
|
|
46
|
+
version_id: "v1",
|
|
47
|
+
decision_result: "approve",
|
|
48
|
+
confidence: 0.95
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(assignment.decision_result).to eq("approve")
|
|
52
|
+
expect(assignment.confidence).to eq(0.95)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "accepts optional context" do
|
|
56
|
+
context = { user_type: "premium", region: "us" }
|
|
57
|
+
assignment = described_class.new(
|
|
58
|
+
ab_test_id: "test_1",
|
|
59
|
+
variant: :champion,
|
|
60
|
+
version_id: "v1",
|
|
61
|
+
context: context
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(assignment.context).to eq(context)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "defaults context to empty hash" do
|
|
68
|
+
assignment = described_class.new(
|
|
69
|
+
ab_test_id: "test_1",
|
|
70
|
+
variant: :champion,
|
|
71
|
+
version_id: "v1"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(assignment.context).to eq({})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "accepts optional id" do
|
|
78
|
+
assignment = described_class.new(
|
|
79
|
+
ab_test_id: "test_1",
|
|
80
|
+
variant: :champion,
|
|
81
|
+
version_id: "v1",
|
|
82
|
+
id: "assign_123"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(assignment.id).to eq("assign_123")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "raises error if ab_test_id is nil" do
|
|
89
|
+
expect do
|
|
90
|
+
described_class.new(
|
|
91
|
+
ab_test_id: nil,
|
|
92
|
+
variant: :champion,
|
|
93
|
+
version_id: "v1"
|
|
94
|
+
)
|
|
95
|
+
end.to raise_error(DecisionAgent::ValidationError, /AB test ID is required/)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises error if variant is nil" do
|
|
99
|
+
expect do
|
|
100
|
+
described_class.new(
|
|
101
|
+
ab_test_id: "test_1",
|
|
102
|
+
variant: nil,
|
|
103
|
+
version_id: "v1"
|
|
104
|
+
)
|
|
105
|
+
end.to raise_error(DecisionAgent::ValidationError, /Variant is required/)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "raises error if version_id is nil" do
|
|
109
|
+
expect do
|
|
110
|
+
described_class.new(
|
|
111
|
+
ab_test_id: "test_1",
|
|
112
|
+
variant: :champion,
|
|
113
|
+
version_id: nil
|
|
114
|
+
)
|
|
115
|
+
end.to raise_error(DecisionAgent::ValidationError, /Version ID is required/)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "raises error if variant is not :champion or :challenger" do
|
|
119
|
+
expect do
|
|
120
|
+
described_class.new(
|
|
121
|
+
ab_test_id: "test_1",
|
|
122
|
+
variant: :invalid,
|
|
123
|
+
version_id: "v1"
|
|
124
|
+
)
|
|
125
|
+
end.to raise_error(DecisionAgent::ValidationError, /Variant must be :champion or :challenger/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "raises error if confidence is negative" do
|
|
129
|
+
expect do
|
|
130
|
+
described_class.new(
|
|
131
|
+
ab_test_id: "test_1",
|
|
132
|
+
variant: :champion,
|
|
133
|
+
version_id: "v1",
|
|
134
|
+
confidence: -0.1
|
|
135
|
+
)
|
|
136
|
+
end.to raise_error(DecisionAgent::ValidationError, /Confidence must be between 0 and 1/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "raises error if confidence is greater than 1" do
|
|
140
|
+
expect do
|
|
141
|
+
described_class.new(
|
|
142
|
+
ab_test_id: "test_1",
|
|
143
|
+
variant: :champion,
|
|
144
|
+
version_id: "v1",
|
|
145
|
+
confidence: 1.5
|
|
146
|
+
)
|
|
147
|
+
end.to raise_error(DecisionAgent::ValidationError, /Confidence must be between 0 and 1/)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "accepts confidence of 0" do
|
|
151
|
+
assignment = described_class.new(
|
|
152
|
+
ab_test_id: "test_1",
|
|
153
|
+
variant: :champion,
|
|
154
|
+
version_id: "v1",
|
|
155
|
+
confidence: 0.0
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
expect(assignment.confidence).to eq(0.0)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "accepts confidence of 1" do
|
|
162
|
+
assignment = described_class.new(
|
|
163
|
+
ab_test_id: "test_1",
|
|
164
|
+
variant: :champion,
|
|
165
|
+
version_id: "v1",
|
|
166
|
+
confidence: 1.0
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(assignment.confidence).to eq(1.0)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "accepts challenger variant" do
|
|
173
|
+
assignment = described_class.new(
|
|
174
|
+
ab_test_id: "test_1",
|
|
175
|
+
variant: :challenger,
|
|
176
|
+
version_id: "v2"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(assignment.variant).to eq(:challenger)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "#record_decision" do
|
|
184
|
+
let(:assignment) do
|
|
185
|
+
described_class.new(
|
|
186
|
+
ab_test_id: "test_1",
|
|
187
|
+
variant: :champion,
|
|
188
|
+
version_id: "v1"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "updates decision_result and confidence" do
|
|
193
|
+
assignment.record_decision("approve", 0.95)
|
|
194
|
+
|
|
195
|
+
expect(assignment.decision_result).to eq("approve")
|
|
196
|
+
expect(assignment.confidence).to eq(0.95)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "can update multiple times" do
|
|
200
|
+
assignment.record_decision("approve", 0.95)
|
|
201
|
+
assignment.record_decision("reject", 0.85)
|
|
202
|
+
|
|
203
|
+
expect(assignment.decision_result).to eq("reject")
|
|
204
|
+
expect(assignment.confidence).to eq(0.85)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe "#to_h" do
|
|
209
|
+
it "converts assignment to hash with all fields" do
|
|
210
|
+
assignment = described_class.new(
|
|
211
|
+
ab_test_id: "test_1",
|
|
212
|
+
variant: :champion,
|
|
213
|
+
version_id: "v1",
|
|
214
|
+
id: "assign_123",
|
|
215
|
+
user_id: "user_456",
|
|
216
|
+
decision_result: "approve",
|
|
217
|
+
confidence: 0.95,
|
|
218
|
+
context: { region: "us" },
|
|
219
|
+
timestamp: Time.new(2024, 1, 1, 12, 0, 0, "+00:00")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
hash = assignment.to_h
|
|
223
|
+
|
|
224
|
+
expect(hash).to eq({
|
|
225
|
+
id: "assign_123",
|
|
226
|
+
ab_test_id: "test_1",
|
|
227
|
+
user_id: "user_456",
|
|
228
|
+
variant: :champion,
|
|
229
|
+
version_id: "v1",
|
|
230
|
+
timestamp: Time.new(2024, 1, 1, 12, 0, 0, "+00:00"),
|
|
231
|
+
decision_result: "approve",
|
|
232
|
+
confidence: 0.95,
|
|
233
|
+
context: { region: "us" }
|
|
234
|
+
})
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it "includes nil values in hash" do
|
|
238
|
+
assignment = described_class.new(
|
|
239
|
+
ab_test_id: "test_1",
|
|
240
|
+
variant: :champion,
|
|
241
|
+
version_id: "v1"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
hash = assignment.to_h
|
|
245
|
+
|
|
246
|
+
expect(hash[:id]).to be_nil
|
|
247
|
+
expect(hash[:user_id]).to be_nil
|
|
248
|
+
expect(hash[:decision_result]).to be_nil
|
|
249
|
+
expect(hash[:confidence]).to be_nil
|
|
250
|
+
expect(hash[:context]).to eq({})
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|