decision_agent 0.1.1

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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1060 -0
  4. data/bin/decision_agent +104 -0
  5. data/lib/decision_agent/agent.rb +147 -0
  6. data/lib/decision_agent/audit/adapter.rb +9 -0
  7. data/lib/decision_agent/audit/logger_adapter.rb +27 -0
  8. data/lib/decision_agent/audit/null_adapter.rb +8 -0
  9. data/lib/decision_agent/context.rb +42 -0
  10. data/lib/decision_agent/decision.rb +51 -0
  11. data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
  12. data/lib/decision_agent/dsl/rule_parser.rb +36 -0
  13. data/lib/decision_agent/dsl/schema_validator.rb +275 -0
  14. data/lib/decision_agent/errors.rb +62 -0
  15. data/lib/decision_agent/evaluation.rb +52 -0
  16. data/lib/decision_agent/evaluators/base.rb +15 -0
  17. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
  18. data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
  19. data/lib/decision_agent/replay/replay.rb +147 -0
  20. data/lib/decision_agent/scoring/base.rb +19 -0
  21. data/lib/decision_agent/scoring/consensus.rb +40 -0
  22. data/lib/decision_agent/scoring/max_weight.rb +16 -0
  23. data/lib/decision_agent/scoring/threshold.rb +40 -0
  24. data/lib/decision_agent/scoring/weighted_average.rb +26 -0
  25. data/lib/decision_agent/version.rb +3 -0
  26. data/lib/decision_agent/web/public/app.js +580 -0
  27. data/lib/decision_agent/web/public/index.html +190 -0
  28. data/lib/decision_agent/web/public/styles.css +558 -0
  29. data/lib/decision_agent/web/server.rb +255 -0
  30. data/lib/decision_agent.rb +29 -0
  31. data/spec/agent_spec.rb +249 -0
  32. data/spec/api_contract_spec.rb +430 -0
  33. data/spec/audit_adapters_spec.rb +74 -0
  34. data/spec/comprehensive_edge_cases_spec.rb +1777 -0
  35. data/spec/context_spec.rb +84 -0
  36. data/spec/dsl_validation_spec.rb +648 -0
  37. data/spec/edge_cases_spec.rb +353 -0
  38. data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
  39. data/spec/json_rule_evaluator_spec.rb +587 -0
  40. data/spec/replay_edge_cases_spec.rb +699 -0
  41. data/spec/replay_spec.rb +210 -0
  42. data/spec/scoring_spec.rb +225 -0
  43. data/spec/spec_helper.rb +28 -0
  44. metadata +133 -0
@@ -0,0 +1,648 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe "DSL Validation" do
4
+ describe DecisionAgent::Dsl::SchemaValidator do
5
+ describe "root structure validation" do
6
+ it "rejects non-hash input" do
7
+ expect {
8
+ DecisionAgent::Dsl::SchemaValidator.validate!([1, 2, 3])
9
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Root element must be a hash/)
10
+ end
11
+
12
+ it "rejects string input" do
13
+ expect {
14
+ DecisionAgent::Dsl::SchemaValidator.validate!("not a hash")
15
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Root element must be a hash/)
16
+ end
17
+
18
+ it "accepts valid hash input" do
19
+ valid_rules = {
20
+ "version" => "1.0",
21
+ "rules" => []
22
+ }
23
+
24
+ expect {
25
+ DecisionAgent::Dsl::SchemaValidator.validate!(valid_rules)
26
+ }.not_to raise_error
27
+ end
28
+ end
29
+
30
+ describe "version validation" do
31
+ it "requires version field" do
32
+ rules = {
33
+ "rules" => []
34
+ }
35
+
36
+ expect {
37
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
38
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'version'/)
39
+ end
40
+
41
+ it "accepts version as symbol key" do
42
+ rules = {
43
+ version: "1.0",
44
+ rules: []
45
+ }
46
+
47
+ expect {
48
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
49
+ }.not_to raise_error
50
+ end
51
+ end
52
+
53
+ describe "rules array validation" do
54
+ it "requires rules field" do
55
+ rules = {
56
+ "version" => "1.0"
57
+ }
58
+
59
+ expect {
60
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
61
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'rules'/)
62
+ end
63
+
64
+ it "rejects non-array rules" do
65
+ rules = {
66
+ "version" => "1.0",
67
+ "rules" => "not an array"
68
+ }
69
+
70
+ expect {
71
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
72
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /must be an array/)
73
+ end
74
+
75
+ it "accepts empty rules array" do
76
+ rules = {
77
+ "version" => "1.0",
78
+ "rules" => []
79
+ }
80
+
81
+ expect {
82
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
83
+ }.not_to raise_error
84
+ end
85
+ end
86
+
87
+ describe "rule structure validation" do
88
+ it "rejects non-hash rule" do
89
+ rules = {
90
+ "version" => "1.0",
91
+ "rules" => ["not a hash"]
92
+ }
93
+
94
+ expect {
95
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
96
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /rules\[0\].*must be a hash/)
97
+ end
98
+
99
+ it "requires rule id" do
100
+ rules = {
101
+ "version" => "1.0",
102
+ "rules" => [
103
+ {
104
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
105
+ "then" => { "decision" => "approve" }
106
+ }
107
+ ]
108
+ }
109
+
110
+ expect {
111
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
112
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'id'/)
113
+ end
114
+
115
+ it "requires rule if clause" do
116
+ rules = {
117
+ "version" => "1.0",
118
+ "rules" => [
119
+ {
120
+ "id" => "rule_1",
121
+ "then" => { "decision" => "approve" }
122
+ }
123
+ ]
124
+ }
125
+
126
+ expect {
127
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
128
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'if'/)
129
+ end
130
+
131
+ it "requires rule then clause" do
132
+ rules = {
133
+ "version" => "1.0",
134
+ "rules" => [
135
+ {
136
+ "id" => "rule_1",
137
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" }
138
+ }
139
+ ]
140
+ }
141
+
142
+ expect {
143
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
144
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'then'/)
145
+ end
146
+ end
147
+
148
+ describe "condition validation" do
149
+ it "rejects condition without field, all, or any" do
150
+ rules = {
151
+ "version" => "1.0",
152
+ "rules" => [
153
+ {
154
+ "id" => "rule_1",
155
+ "if" => { "invalid" => "condition" },
156
+ "then" => { "decision" => "approve" }
157
+ }
158
+ ]
159
+ }
160
+
161
+ expect {
162
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
163
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Condition must have one of: 'field', 'all', or 'any'/)
164
+ end
165
+
166
+ it "rejects non-hash condition" do
167
+ rules = {
168
+ "version" => "1.0",
169
+ "rules" => [
170
+ {
171
+ "id" => "rule_1",
172
+ "if" => "not a hash",
173
+ "then" => { "decision" => "approve" }
174
+ }
175
+ ]
176
+ }
177
+
178
+ expect {
179
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
180
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Condition must be a hash/)
181
+ end
182
+ end
183
+
184
+ describe "field condition validation" do
185
+ it "requires field key" do
186
+ rules = {
187
+ "version" => "1.0",
188
+ "rules" => [
189
+ {
190
+ "id" => "rule_1",
191
+ "if" => { "op" => "eq", "value" => "active" },
192
+ "then" => { "decision" => "approve" }
193
+ }
194
+ ]
195
+ }
196
+
197
+ expect {
198
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
199
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
200
+ expect(error.message).to match(/Condition must have one of: 'field', 'all', or 'any'/)
201
+ end
202
+ end
203
+
204
+ it "requires op (operator) key" do
205
+ rules = {
206
+ "version" => "1.0",
207
+ "rules" => [
208
+ {
209
+ "id" => "rule_1",
210
+ "if" => { "field" => "status", "value" => "active" },
211
+ "then" => { "decision" => "approve" }
212
+ }
213
+ ]
214
+ }
215
+
216
+ expect {
217
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
218
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'op'/)
219
+ end
220
+
221
+ it "validates operator is supported" do
222
+ rules = {
223
+ "version" => "1.0",
224
+ "rules" => [
225
+ {
226
+ "id" => "rule_1",
227
+ "if" => { "field" => "status", "op" => "invalid_op", "value" => "active" },
228
+ "then" => { "decision" => "approve" }
229
+ }
230
+ ]
231
+ }
232
+
233
+ expect {
234
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
235
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
236
+ expect(error.message).to include("Unsupported operator 'invalid_op'")
237
+ expect(error.message).to include("eq, neq, gt, gte, lt, lte, in, present, blank")
238
+ end
239
+ end
240
+
241
+ it "requires value for non-present/blank operators" do
242
+ rules = {
243
+ "version" => "1.0",
244
+ "rules" => [
245
+ {
246
+ "id" => "rule_1",
247
+ "if" => { "field" => "status", "op" => "eq" },
248
+ "then" => { "decision" => "approve" }
249
+ }
250
+ ]
251
+ }
252
+
253
+ expect {
254
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
255
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'value' key/)
256
+ end
257
+
258
+ it "allows missing value for present operator" do
259
+ rules = {
260
+ "version" => "1.0",
261
+ "rules" => [
262
+ {
263
+ "id" => "rule_1",
264
+ "if" => { "field" => "assignee", "op" => "present" },
265
+ "then" => { "decision" => "assigned" }
266
+ }
267
+ ]
268
+ }
269
+
270
+ expect {
271
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
272
+ }.not_to raise_error
273
+ end
274
+
275
+ it "allows missing value for blank operator" do
276
+ rules = {
277
+ "version" => "1.0",
278
+ "rules" => [
279
+ {
280
+ "id" => "rule_1",
281
+ "if" => { "field" => "description", "op" => "blank" },
282
+ "then" => { "decision" => "needs_info" }
283
+ }
284
+ ]
285
+ }
286
+
287
+ expect {
288
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
289
+ }.not_to raise_error
290
+ end
291
+
292
+ it "rejects empty field path" do
293
+ rules = {
294
+ "version" => "1.0",
295
+ "rules" => [
296
+ {
297
+ "id" => "rule_1",
298
+ "if" => { "field" => "", "op" => "eq", "value" => "test" },
299
+ "then" => { "decision" => "approve" }
300
+ }
301
+ ]
302
+ }
303
+
304
+ expect {
305
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
306
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Field path cannot be empty/)
307
+ end
308
+
309
+ it "rejects invalid dot-notation" do
310
+ rules = {
311
+ "version" => "1.0",
312
+ "rules" => [
313
+ {
314
+ "id" => "rule_1",
315
+ "if" => { "field" => "user..role", "op" => "eq", "value" => "admin" },
316
+ "then" => { "decision" => "approve" }
317
+ }
318
+ ]
319
+ }
320
+
321
+ expect {
322
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
323
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /cannot have empty segments/)
324
+ end
325
+
326
+ it "accepts valid dot-notation" do
327
+ rules = {
328
+ "version" => "1.0",
329
+ "rules" => [
330
+ {
331
+ "id" => "rule_1",
332
+ "if" => { "field" => "user.profile.role", "op" => "eq", "value" => "admin" },
333
+ "then" => { "decision" => "allow" }
334
+ }
335
+ ]
336
+ }
337
+
338
+ expect {
339
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
340
+ }.not_to raise_error
341
+ end
342
+ end
343
+
344
+ describe "all/any condition validation" do
345
+ it "requires array for all condition" do
346
+ rules = {
347
+ "version" => "1.0",
348
+ "rules" => [
349
+ {
350
+ "id" => "rule_1",
351
+ "if" => { "all" => "not an array" },
352
+ "then" => { "decision" => "approve" }
353
+ }
354
+ ]
355
+ }
356
+
357
+ expect {
358
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
359
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /'all' condition must contain an array/)
360
+ end
361
+
362
+ it "requires array for any condition" do
363
+ rules = {
364
+ "version" => "1.0",
365
+ "rules" => [
366
+ {
367
+ "id" => "rule_1",
368
+ "if" => { "any" => "not an array" },
369
+ "then" => { "decision" => "approve" }
370
+ }
371
+ ]
372
+ }
373
+
374
+ expect {
375
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
376
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /'any' condition must contain an array/)
377
+ end
378
+
379
+ it "validates nested conditions in all" do
380
+ rules = {
381
+ "version" => "1.0",
382
+ "rules" => [
383
+ {
384
+ "id" => "rule_1",
385
+ "if" => {
386
+ "all" => [
387
+ { "field" => "status", "op" => "invalid_op", "value" => "active" }
388
+ ]
389
+ },
390
+ "then" => { "decision" => "approve" }
391
+ }
392
+ ]
393
+ }
394
+
395
+ expect {
396
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
397
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Unsupported operator/)
398
+ end
399
+
400
+ it "validates nested conditions in any" do
401
+ rules = {
402
+ "version" => "1.0",
403
+ "rules" => [
404
+ {
405
+ "id" => "rule_1",
406
+ "if" => {
407
+ "any" => [
408
+ { "field" => "priority" } # Missing op
409
+ ]
410
+ },
411
+ "then" => { "decision" => "escalate" }
412
+ }
413
+ ]
414
+ }
415
+
416
+ expect {
417
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
418
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'op'/)
419
+ end
420
+ end
421
+
422
+ describe "then clause validation" do
423
+ it "requires then clause to be a hash" do
424
+ rules = {
425
+ "version" => "1.0",
426
+ "rules" => [
427
+ {
428
+ "id" => "rule_1",
429
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
430
+ "then" => "not a hash"
431
+ }
432
+ ]
433
+ }
434
+
435
+ expect {
436
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
437
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /then.*Must be a hash/)
438
+ end
439
+
440
+ it "requires decision field in then clause" do
441
+ rules = {
442
+ "version" => "1.0",
443
+ "rules" => [
444
+ {
445
+ "id" => "rule_1",
446
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
447
+ "then" => { "weight" => 0.8 }
448
+ }
449
+ ]
450
+ }
451
+
452
+ expect {
453
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
454
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'decision'/)
455
+ end
456
+
457
+ it "validates weight is numeric" do
458
+ rules = {
459
+ "version" => "1.0",
460
+ "rules" => [
461
+ {
462
+ "id" => "rule_1",
463
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
464
+ "then" => { "decision" => "approve", "weight" => "not a number" }
465
+ }
466
+ ]
467
+ }
468
+
469
+ expect {
470
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
471
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /weight.*Must be a number/)
472
+ end
473
+
474
+ it "validates weight is between 0 and 1" do
475
+ rules = {
476
+ "version" => "1.0",
477
+ "rules" => [
478
+ {
479
+ "id" => "rule_1",
480
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
481
+ "then" => { "decision" => "approve", "weight" => 1.5 }
482
+ }
483
+ ]
484
+ }
485
+
486
+ expect {
487
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
488
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /weight.*between 0.0 and 1.0/)
489
+ end
490
+
491
+ it "validates reason is a string" do
492
+ rules = {
493
+ "version" => "1.0",
494
+ "rules" => [
495
+ {
496
+ "id" => "rule_1",
497
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
498
+ "then" => { "decision" => "approve", "reason" => 123 }
499
+ }
500
+ ]
501
+ }
502
+
503
+ expect {
504
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
505
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /reason.*Must be a string/)
506
+ end
507
+
508
+ it "accepts valid then clause with all fields" do
509
+ rules = {
510
+ "version" => "1.0",
511
+ "rules" => [
512
+ {
513
+ "id" => "rule_1",
514
+ "if" => { "field" => "status", "op" => "eq", "value" => "active" },
515
+ "then" => {
516
+ "decision" => "approve",
517
+ "weight" => 0.8,
518
+ "reason" => "Status is active"
519
+ }
520
+ }
521
+ ]
522
+ }
523
+
524
+ expect {
525
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
526
+ }.not_to raise_error
527
+ end
528
+ end
529
+
530
+ describe "error message formatting" do
531
+ it "provides numbered error list for multiple errors" do
532
+ rules = {
533
+ "version" => "1.0",
534
+ "rules" => [
535
+ {
536
+ "id" => "rule_1",
537
+ # Missing if clause
538
+ # Missing then clause
539
+ }
540
+ ]
541
+ }
542
+
543
+ expect {
544
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
545
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
546
+ expect(error.message).to include("1.")
547
+ expect(error.message).to include("2.")
548
+ expect(error.message).to match(/validation failed with 2 errors/)
549
+ end
550
+ end
551
+
552
+ it "includes helpful context in error messages" do
553
+ rules = {
554
+ "version" => "1.0",
555
+ "rules" => [
556
+ {
557
+ "id" => "rule_1",
558
+ "if" => { "field" => "status", "op" => "invalid_op", "value" => "test" },
559
+ "then" => { "decision" => "approve" }
560
+ }
561
+ ]
562
+ }
563
+
564
+ expect {
565
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
566
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
567
+ expect(error.message).to include("rules[0].if")
568
+ expect(error.message).to include("Supported operators:")
569
+ end
570
+ end
571
+ end
572
+
573
+ describe "complex nested validation" do
574
+ it "validates deeply nested all/any structures" do
575
+ rules = {
576
+ "version" => "1.0",
577
+ "rules" => [
578
+ {
579
+ "id" => "rule_1",
580
+ "if" => {
581
+ "all" => [
582
+ {
583
+ "any" => [
584
+ { "field" => "a", "op" => "eq", "value" => 1 },
585
+ { "field" => "b", "op" => "invalid_op", "value" => 2 }
586
+ ]
587
+ }
588
+ ]
589
+ },
590
+ "then" => { "decision" => "approve" }
591
+ }
592
+ ]
593
+ }
594
+
595
+ expect {
596
+ DecisionAgent::Dsl::SchemaValidator.validate!(rules)
597
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
598
+ expect(error.message).to include("rules[0].if.all[0].any[1]")
599
+ expect(error.message).to include("Unsupported operator")
600
+ end
601
+ end
602
+ end
603
+ end
604
+
605
+ describe "RuleParser integration" do
606
+ it "uses SchemaValidator for validation" do
607
+ invalid_json = '{"version": "1.0", "rules": "not an array"}'
608
+
609
+ expect {
610
+ DecisionAgent::Dsl::RuleParser.parse(invalid_json)
611
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /must be an array/)
612
+ end
613
+
614
+ it "provides helpful error for malformed JSON" do
615
+ malformed_json = '{"version": "1.0", "rules": [,,,]}'
616
+
617
+ expect {
618
+ DecisionAgent::Dsl::RuleParser.parse(malformed_json)
619
+ }.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
620
+ expect(error.message).to include("Invalid JSON syntax")
621
+ expect(error.message).to include("Common issues")
622
+ end
623
+ end
624
+
625
+ it "accepts hash input" do
626
+ rules_hash = {
627
+ version: "1.0",
628
+ rules: [
629
+ {
630
+ id: "rule_1",
631
+ if: { field: "status", op: "eq", value: "active" },
632
+ then: { decision: "approve" }
633
+ }
634
+ ]
635
+ }
636
+
637
+ expect {
638
+ DecisionAgent::Dsl::RuleParser.parse(rules_hash)
639
+ }.not_to raise_error
640
+ end
641
+
642
+ it "rejects invalid input types" do
643
+ expect {
644
+ DecisionAgent::Dsl::RuleParser.parse(12345)
645
+ }.to raise_error(DecisionAgent::InvalidRuleDslError, /Expected JSON string or Hash/)
646
+ end
647
+ end
648
+ end