decision_agent 0.1.1 → 0.1.3

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +21 -6
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. metadata +84 -11
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "RFC 8785 JSON Canonicalization" do
6
+ let(:evaluator) do
7
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
8
+ rules_json: {
9
+ version: "1.0",
10
+ ruleset: "test",
11
+ rules: [
12
+ {
13
+ id: "always_approve",
14
+ if: { field: "amount", op: "gte", value: 0 },
15
+ then: { decision: "approve", weight: 1.0, reason: "Test rule" }
16
+ }
17
+ ]
18
+ }
19
+ )
20
+ end
21
+
22
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
23
+
24
+ describe "canonical JSON serialization" do
25
+ it "produces deterministic hashes using RFC 8785" do
26
+ # Same context should produce same hash every time
27
+ context = { amount: 100, user: { id: 123, name: "Alice" } }
28
+
29
+ decision1 = agent.decide(context: context)
30
+ decision2 = agent.decide(context: context)
31
+
32
+ hash1 = decision1.audit_payload[:deterministic_hash]
33
+ hash2 = decision2.audit_payload[:deterministic_hash]
34
+
35
+ expect(hash1).to eq(hash2)
36
+ expect(hash1).to be_a(String)
37
+ expect(hash1.length).to eq(64) # SHA256 produces 64 hex characters
38
+ end
39
+
40
+ it "produces different hashes for different contexts" do
41
+ context1 = { amount: 100, user: { id: 123 } }
42
+ context2 = { amount: 200, user: { id: 456 } }
43
+
44
+ decision1 = agent.decide(context: context1)
45
+ decision2 = agent.decide(context: context2)
46
+
47
+ hash1 = decision1.audit_payload[:deterministic_hash]
48
+ hash2 = decision2.audit_payload[:deterministic_hash]
49
+
50
+ expect(hash1).not_to eq(hash2)
51
+ end
52
+
53
+ it "is insensitive to property order (canonicalization)" do
54
+ # Different property order should produce same hash
55
+ context1 = { amount: 100, user: { id: 123, name: "Alice" } }
56
+ context2 = { user: { name: "Alice", id: 123 }, amount: 100 }
57
+
58
+ decision1 = agent.decide(context: context1)
59
+ decision2 = agent.decide(context: context2)
60
+
61
+ hash1 = decision1.audit_payload[:deterministic_hash]
62
+ hash2 = decision2.audit_payload[:deterministic_hash]
63
+
64
+ expect(hash1).to eq(hash2), "RFC 8785 canonicalization should sort properties"
65
+ end
66
+
67
+ it "handles special characters correctly" do
68
+ # Test Unicode, quotes, and control characters
69
+ context = {
70
+ amount: 100,
71
+ note: "Test with \"quotes\", €uro, and \n newline"
72
+ }
73
+
74
+ decision = agent.decide(context: context)
75
+ hash = decision.audit_payload[:deterministic_hash]
76
+
77
+ expect(hash).to be_a(String)
78
+ expect(hash.length).to eq(64)
79
+ end
80
+
81
+ it "handles floating point numbers deterministically" do
82
+ # RFC 8785 specifies exact float serialization per IEEE 754
83
+ # Note: 99.99 cannot be exactly represented in binary floating point
84
+ context = { amount: 100, price: 99.99, tax: 0.075 }
85
+
86
+ decision1 = agent.decide(context: context)
87
+ decision2 = agent.decide(context: context)
88
+
89
+ hash1 = decision1.audit_payload[:deterministic_hash]
90
+ hash2 = decision2.audit_payload[:deterministic_hash]
91
+
92
+ # Same context should always produce same hash
93
+ expect(hash1).to eq(hash2), "RFC 8785 should produce consistent hashes for same values"
94
+
95
+ # Verify RFC 8785 uses ECMAScript number serialization
96
+ canonical = agent.send(:canonical_json, context)
97
+ # RFC 8785 may represent 99.99 as 99.98999999999999 due to IEEE 754
98
+ expect(canonical).to match(/99\.\d+/)
99
+ expect(canonical).to include("0.075")
100
+ end
101
+
102
+ it "handles nested structures correctly" do
103
+ context = {
104
+ amount: 100,
105
+ user: {
106
+ id: 123,
107
+ profile: {
108
+ name: "Alice",
109
+ tags: %w[premium verified]
110
+ }
111
+ }
112
+ }
113
+
114
+ decision = agent.decide(context: context)
115
+ hash = decision.audit_payload[:deterministic_hash]
116
+
117
+ expect(hash).to be_a(String)
118
+ expect(hash.length).to eq(64)
119
+ end
120
+
121
+ it "handles arrays consistently" do
122
+ # Array order should be preserved (not sorted)
123
+ context1 = { amount: 100, tags: %w[a b c] }
124
+ context2 = { amount: 100, tags: %w[c b a] }
125
+
126
+ decision1 = agent.decide(context: context1)
127
+ decision2 = agent.decide(context: context2)
128
+
129
+ hash1 = decision1.audit_payload[:deterministic_hash]
130
+ hash2 = decision2.audit_payload[:deterministic_hash]
131
+
132
+ expect(hash1).not_to eq(hash2), "RFC 8785 preserves array order"
133
+ end
134
+
135
+ it "handles nil values correctly" do
136
+ context = { amount: 100, optional_field: nil }
137
+
138
+ decision = agent.decide(context: context)
139
+ hash = decision.audit_payload[:deterministic_hash]
140
+
141
+ expect(hash).to be_a(String)
142
+ expect(hash.length).to eq(64)
143
+ end
144
+
145
+ it "handles boolean values correctly" do
146
+ context = { amount: 100, is_verified: true, is_blocked: false }
147
+
148
+ decision = agent.decide(context: context)
149
+ hash = decision.audit_payload[:deterministic_hash]
150
+
151
+ expect(hash).to be_a(String)
152
+ expect(hash.length).to eq(64)
153
+ end
154
+
155
+ it "is thread-safe with concurrent hash computations" do
156
+ contexts = 10.times.map { |i| { amount: i * 100, id: i } }
157
+ results = []
158
+ mutex = Mutex.new
159
+
160
+ threads = contexts.map do |ctx|
161
+ Thread.new do
162
+ decision = agent.decide(context: ctx)
163
+ hash = decision.audit_payload[:deterministic_hash]
164
+ mutex.synchronize { results << hash }
165
+ end
166
+ end
167
+
168
+ threads.each(&:join)
169
+
170
+ expect(results.size).to eq(10)
171
+ expect(results.uniq.size).to eq(10), "Each context should produce unique hash"
172
+ results.each do |hash|
173
+ expect(hash.length).to eq(64)
174
+ end
175
+ end
176
+ end
177
+
178
+ describe "RFC 8785 compliance" do
179
+ it "uses json-canonicalization gem for canonicalization" do
180
+ # Verify we're using the RFC 8785 implementation
181
+ test_data = { b: 2, a: 1 }
182
+ canonical = agent.send(:canonical_json, test_data)
183
+
184
+ # RFC 8785 should sort keys: {"a":1,"b":2}
185
+ expect(canonical).to include('"a":1')
186
+ expect(canonical).to include('"b":2')
187
+ expect(canonical.index('"a"')).to be < canonical.index('"b"')
188
+ end
189
+
190
+ it "produces compact JSON without whitespace" do
191
+ test_data = { amount: 100, user: { id: 123 } }
192
+ canonical = agent.send(:canonical_json, test_data)
193
+
194
+ # RFC 8785 produces compact JSON
195
+ expect(canonical).not_to include("\n")
196
+ expect(canonical).not_to include(" ")
197
+ end
198
+ end
199
+
200
+ describe "performance characteristics" do
201
+ it "computes hashes efficiently" do
202
+ context = {
203
+ amount: 100,
204
+ user: { id: 123, name: "Alice", tags: (1..100).to_a }
205
+ }
206
+
207
+ # Should complete quickly even with larger payloads
208
+ start_time = Time.now
209
+ 100.times { agent.decide(context: context) }
210
+ elapsed = Time.now - start_time
211
+
212
+ expect(elapsed).to be < 1.0, "100 decisions should complete in under 1 second"
213
+ end
214
+ end
215
+ end
data/spec/scoring_spec.rb CHANGED
@@ -91,7 +91,7 @@ RSpec.describe "Scoring Strategies" do
91
91
  strategy = DecisionAgent::Scoring::MaxWeight.new
92
92
  result = strategy.score([eval_a, eval_b])
93
93
 
94
- expect(["option_a", "option_b"]).to include(result[:decision])
94
+ expect(%w[option_a option_b]).to include(result[:decision])
95
95
  expect(result[:confidence]).to eq(0.7)
96
96
  end
97
97
 
data/spec/spec_helper.rb CHANGED
@@ -6,6 +6,15 @@ end
6
6
 
7
7
  require "decision_agent"
8
8
 
9
+ # Load ActiveRecord for thread-safety and integration tests
10
+ begin
11
+ require "active_record"
12
+ require "sqlite3"
13
+ require "decision_agent/versioning/activerecord_adapter"
14
+ rescue LoadError
15
+ # ActiveRecord is optional - tests will be skipped if not available
16
+ end
17
+
9
18
  RSpec.configure do |config|
10
19
  config.expect_with :rspec do |expectations|
11
20
  expectations.include_chain_clauses_in_custom_matcher_descriptions = true