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
@@ -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
@@ -33,7 +33,7 @@ class RuleVersion < ApplicationRecord
33
33
  self.class.where(rule_id: rule_id, status: "active")
34
34
  .where.not(id: id)
35
35
  .find_each do |v|
36
- v.update!(status: "archived")
36
+ v.update!(status: "archived")
37
37
  end
38
38
 
39
39
  # Activate this version
@@ -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