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.
- checksums.yaml +4 -4
- data/README.md +234 -919
- data/bin/decision_agent +5 -5
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +21 -6
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- 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([
|
|
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
|