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,1003 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe "Advanced DSL Operators" do
4
+ # STRING OPERATORS
5
+ describe "string operators" do
6
+ describe "contains operator" do
7
+ it "matches when string contains substring" do
8
+ rules = {
9
+ version: "1.0",
10
+ ruleset: "test",
11
+ rules: [
12
+ {
13
+ id: "rule_1",
14
+ if: { field: "message", op: "contains", value: "error" },
15
+ then: { decision: "alert", reason: "Error found in message" }
16
+ }
17
+ ]
18
+ }
19
+
20
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
21
+ context = DecisionAgent::Context.new({ message: "An error occurred" })
22
+
23
+ evaluation = evaluator.evaluate(context)
24
+
25
+ expect(evaluation).not_to be_nil
26
+ expect(evaluation.decision).to eq("alert")
27
+ end
28
+
29
+ it "does not match when substring is not present" do
30
+ rules = {
31
+ version: "1.0",
32
+ ruleset: "test",
33
+ rules: [
34
+ {
35
+ id: "rule_1",
36
+ if: { field: "message", op: "contains", value: "error" },
37
+ then: { decision: "alert" }
38
+ }
39
+ ]
40
+ }
41
+
42
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
43
+ context = DecisionAgent::Context.new({ message: "Success" })
44
+
45
+ evaluation = evaluator.evaluate(context)
46
+
47
+ expect(evaluation).to be_nil
48
+ end
49
+
50
+ it "is case-sensitive" do
51
+ rules = {
52
+ version: "1.0",
53
+ ruleset: "test",
54
+ rules: [
55
+ {
56
+ id: "rule_1",
57
+ if: { field: "message", op: "contains", value: "ERROR" },
58
+ then: { decision: "alert" }
59
+ }
60
+ ]
61
+ }
62
+
63
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
64
+ context = DecisionAgent::Context.new({ message: "An error occurred" })
65
+
66
+ evaluation = evaluator.evaluate(context)
67
+
68
+ expect(evaluation).to be_nil
69
+ end
70
+ end
71
+
72
+ describe "starts_with operator" do
73
+ it "matches when string starts with prefix" do
74
+ rules = {
75
+ version: "1.0",
76
+ ruleset: "test",
77
+ rules: [
78
+ {
79
+ id: "rule_1",
80
+ if: { field: "code", op: "starts_with", value: "ERR" },
81
+ then: { decision: "error_handler" }
82
+ }
83
+ ]
84
+ }
85
+
86
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
87
+ context = DecisionAgent::Context.new({ code: "ERR_404" })
88
+
89
+ evaluation = evaluator.evaluate(context)
90
+
91
+ expect(evaluation).not_to be_nil
92
+ expect(evaluation.decision).to eq("error_handler")
93
+ end
94
+
95
+ it "does not match when prefix is not present" do
96
+ rules = {
97
+ version: "1.0",
98
+ ruleset: "test",
99
+ rules: [
100
+ {
101
+ id: "rule_1",
102
+ if: { field: "code", op: "starts_with", value: "ERR" },
103
+ then: { decision: "error_handler" }
104
+ }
105
+ ]
106
+ }
107
+
108
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
109
+ context = DecisionAgent::Context.new({ code: "SUCCESS_200" })
110
+
111
+ evaluation = evaluator.evaluate(context)
112
+
113
+ expect(evaluation).to be_nil
114
+ end
115
+ end
116
+
117
+ describe "ends_with operator" do
118
+ it "matches when string ends with suffix" do
119
+ rules = {
120
+ version: "1.0",
121
+ ruleset: "test",
122
+ rules: [
123
+ {
124
+ id: "rule_1",
125
+ if: { field: "filename", op: "ends_with", value: ".pdf" },
126
+ then: { decision: "process_pdf" }
127
+ }
128
+ ]
129
+ }
130
+
131
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
132
+ context = DecisionAgent::Context.new({ filename: "document.pdf" })
133
+
134
+ evaluation = evaluator.evaluate(context)
135
+
136
+ expect(evaluation).not_to be_nil
137
+ expect(evaluation.decision).to eq("process_pdf")
138
+ end
139
+
140
+ it "does not match when suffix is not present" do
141
+ rules = {
142
+ version: "1.0",
143
+ ruleset: "test",
144
+ rules: [
145
+ {
146
+ id: "rule_1",
147
+ if: { field: "filename", op: "ends_with", value: ".pdf" },
148
+ then: { decision: "process_pdf" }
149
+ }
150
+ ]
151
+ }
152
+
153
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
154
+ context = DecisionAgent::Context.new({ filename: "document.txt" })
155
+
156
+ evaluation = evaluator.evaluate(context)
157
+
158
+ expect(evaluation).to be_nil
159
+ end
160
+ end
161
+
162
+ describe "matches operator" do
163
+ it "matches string against regular expression" do
164
+ rules = {
165
+ version: "1.0",
166
+ ruleset: "test",
167
+ rules: [
168
+ {
169
+ id: "rule_1",
170
+ if: { field: "email", op: "matches", value: "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$" },
171
+ then: { decision: "valid_email" }
172
+ }
173
+ ]
174
+ }
175
+
176
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
177
+ context = DecisionAgent::Context.new({ email: "user@example.com" })
178
+
179
+ evaluation = evaluator.evaluate(context)
180
+
181
+ expect(evaluation).not_to be_nil
182
+ expect(evaluation.decision).to eq("valid_email")
183
+ end
184
+
185
+ it "does not match when regex fails" do
186
+ rules = {
187
+ version: "1.0",
188
+ ruleset: "test",
189
+ rules: [
190
+ {
191
+ id: "rule_1",
192
+ if: { field: "email", op: "matches", value: "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$" },
193
+ then: { decision: "valid_email" }
194
+ }
195
+ ]
196
+ }
197
+
198
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
199
+ context = DecisionAgent::Context.new({ email: "invalid-email" })
200
+
201
+ evaluation = evaluator.evaluate(context)
202
+
203
+ expect(evaluation).to be_nil
204
+ end
205
+
206
+ it "handles invalid regex gracefully" do
207
+ rules = {
208
+ version: "1.0",
209
+ ruleset: "test",
210
+ rules: [
211
+ {
212
+ id: "rule_1",
213
+ if: { field: "text", op: "matches", value: "[invalid(" },
214
+ then: { decision: "match" }
215
+ }
216
+ ]
217
+ }
218
+
219
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
220
+ context = DecisionAgent::Context.new({ text: "test" })
221
+
222
+ evaluation = evaluator.evaluate(context)
223
+
224
+ expect(evaluation).to be_nil
225
+ end
226
+ end
227
+ end
228
+
229
+ # NUMERIC OPERATORS
230
+ describe "numeric operators" do
231
+ describe "between operator" do
232
+ it "matches when value is between min and max (array format)" do
233
+ rules = {
234
+ version: "1.0",
235
+ ruleset: "test",
236
+ rules: [
237
+ {
238
+ id: "rule_1",
239
+ if: { field: "age", op: "between", value: [18, 65] },
240
+ then: { decision: "eligible" }
241
+ }
242
+ ]
243
+ }
244
+
245
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
246
+ context = DecisionAgent::Context.new({ age: 30 })
247
+
248
+ evaluation = evaluator.evaluate(context)
249
+
250
+ expect(evaluation).not_to be_nil
251
+ expect(evaluation.decision).to eq("eligible")
252
+ end
253
+
254
+ it "matches when value is between min and max (hash format)" do
255
+ rules = {
256
+ version: "1.0",
257
+ ruleset: "test",
258
+ rules: [
259
+ {
260
+ id: "rule_1",
261
+ if: { field: "score", op: "between", value: { min: 0, max: 100 } },
262
+ then: { decision: "valid_score" }
263
+ }
264
+ ]
265
+ }
266
+
267
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
268
+ context = DecisionAgent::Context.new({ score: 75 })
269
+
270
+ evaluation = evaluator.evaluate(context)
271
+
272
+ expect(evaluation).not_to be_nil
273
+ expect(evaluation.decision).to eq("valid_score")
274
+ end
275
+
276
+ it "includes boundary values" do
277
+ rules = {
278
+ version: "1.0",
279
+ ruleset: "test",
280
+ rules: [
281
+ {
282
+ id: "rule_1",
283
+ if: { field: "value", op: "between", value: [10, 20] },
284
+ then: { decision: "in_range" }
285
+ }
286
+ ]
287
+ }
288
+
289
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
290
+
291
+ context_min = DecisionAgent::Context.new({ value: 10 })
292
+ evaluation_min = evaluator.evaluate(context_min)
293
+ expect(evaluation_min).not_to be_nil
294
+
295
+ context_max = DecisionAgent::Context.new({ value: 20 })
296
+ evaluation_max = evaluator.evaluate(context_max)
297
+ expect(evaluation_max).not_to be_nil
298
+ end
299
+
300
+ it "does not match when value is outside range" do
301
+ rules = {
302
+ version: "1.0",
303
+ ruleset: "test",
304
+ rules: [
305
+ {
306
+ id: "rule_1",
307
+ if: { field: "value", op: "between", value: [10, 20] },
308
+ then: { decision: "in_range" }
309
+ }
310
+ ]
311
+ }
312
+
313
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
314
+ context = DecisionAgent::Context.new({ value: 25 })
315
+
316
+ evaluation = evaluator.evaluate(context)
317
+
318
+ expect(evaluation).to be_nil
319
+ end
320
+ end
321
+
322
+ describe "modulo operator" do
323
+ it "matches when modulo condition is satisfied (array format)" do
324
+ rules = {
325
+ version: "1.0",
326
+ ruleset: "test",
327
+ rules: [
328
+ {
329
+ id: "rule_1",
330
+ if: { field: "number", op: "modulo", value: [2, 0] },
331
+ then: { decision: "even" }
332
+ }
333
+ ]
334
+ }
335
+
336
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
337
+ context = DecisionAgent::Context.new({ number: 10 })
338
+
339
+ evaluation = evaluator.evaluate(context)
340
+
341
+ expect(evaluation).not_to be_nil
342
+ expect(evaluation.decision).to eq("even")
343
+ end
344
+
345
+ it "matches when modulo condition is satisfied (hash format)" do
346
+ rules = {
347
+ version: "1.0",
348
+ ruleset: "test",
349
+ rules: [
350
+ {
351
+ id: "rule_1",
352
+ if: { field: "number", op: "modulo", value: { divisor: 3, remainder: 1 } },
353
+ then: { decision: "match" }
354
+ }
355
+ ]
356
+ }
357
+
358
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
359
+ context = DecisionAgent::Context.new({ number: 7 })
360
+
361
+ evaluation = evaluator.evaluate(context)
362
+
363
+ expect(evaluation).not_to be_nil
364
+ expect(evaluation.decision).to eq("match")
365
+ end
366
+
367
+ it "does not match when modulo condition fails" do
368
+ rules = {
369
+ version: "1.0",
370
+ ruleset: "test",
371
+ rules: [
372
+ {
373
+ id: "rule_1",
374
+ if: { field: "number", op: "modulo", value: [2, 0] },
375
+ then: { decision: "even" }
376
+ }
377
+ ]
378
+ }
379
+
380
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
381
+ context = DecisionAgent::Context.new({ number: 11 })
382
+
383
+ evaluation = evaluator.evaluate(context)
384
+
385
+ expect(evaluation).to be_nil
386
+ end
387
+ end
388
+ end
389
+
390
+ # DATE/TIME OPERATORS
391
+ describe "date/time operators" do
392
+ describe "before_date operator" do
393
+ it "matches when date is before specified date" do
394
+ rules = {
395
+ version: "1.0",
396
+ ruleset: "test",
397
+ rules: [
398
+ {
399
+ id: "rule_1",
400
+ if: { field: "expires_at", op: "before_date", value: "2025-12-31" },
401
+ then: { decision: "not_expired" }
402
+ }
403
+ ]
404
+ }
405
+
406
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
407
+ context = DecisionAgent::Context.new({ expires_at: "2025-06-01" })
408
+
409
+ evaluation = evaluator.evaluate(context)
410
+
411
+ expect(evaluation).not_to be_nil
412
+ expect(evaluation.decision).to eq("not_expired")
413
+ end
414
+
415
+ it "does not match when date is after specified date" do
416
+ rules = {
417
+ version: "1.0",
418
+ ruleset: "test",
419
+ rules: [
420
+ {
421
+ id: "rule_1",
422
+ if: { field: "expires_at", op: "before_date", value: "2025-01-01" },
423
+ then: { decision: "not_expired" }
424
+ }
425
+ ]
426
+ }
427
+
428
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
429
+ context = DecisionAgent::Context.new({ expires_at: "2025-12-31" })
430
+
431
+ evaluation = evaluator.evaluate(context)
432
+
433
+ expect(evaluation).to be_nil
434
+ end
435
+ end
436
+
437
+ describe "after_date operator" do
438
+ it "matches when date is after specified date" do
439
+ rules = {
440
+ version: "1.0",
441
+ ruleset: "test",
442
+ rules: [
443
+ {
444
+ id: "rule_1",
445
+ if: { field: "created_at", op: "after_date", value: "2024-01-01" },
446
+ then: { decision: "recent" }
447
+ }
448
+ ]
449
+ }
450
+
451
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
452
+ context = DecisionAgent::Context.new({ created_at: "2025-06-01" })
453
+
454
+ evaluation = evaluator.evaluate(context)
455
+
456
+ expect(evaluation).not_to be_nil
457
+ expect(evaluation.decision).to eq("recent")
458
+ end
459
+
460
+ it "does not match when date is before specified date" do
461
+ rules = {
462
+ version: "1.0",
463
+ ruleset: "test",
464
+ rules: [
465
+ {
466
+ id: "rule_1",
467
+ if: { field: "created_at", op: "after_date", value: "2025-12-31" },
468
+ then: { decision: "recent" }
469
+ }
470
+ ]
471
+ }
472
+
473
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
474
+ context = DecisionAgent::Context.new({ created_at: "2025-01-01" })
475
+
476
+ evaluation = evaluator.evaluate(context)
477
+
478
+ expect(evaluation).to be_nil
479
+ end
480
+ end
481
+
482
+ describe "within_days operator" do
483
+ it "matches when date is within N days from now" do
484
+ rules = {
485
+ version: "1.0",
486
+ ruleset: "test",
487
+ rules: [
488
+ {
489
+ id: "rule_1",
490
+ if: { field: "event_date", op: "within_days", value: 7 },
491
+ then: { decision: "upcoming" }
492
+ }
493
+ ]
494
+ }
495
+
496
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
497
+ event_date = (Time.now + (3 * 24 * 60 * 60)).strftime("%Y-%m-%d")
498
+ context = DecisionAgent::Context.new({ event_date: event_date })
499
+
500
+ evaluation = evaluator.evaluate(context)
501
+
502
+ expect(evaluation).not_to be_nil
503
+ expect(evaluation.decision).to eq("upcoming")
504
+ end
505
+
506
+ it "does not match when date is outside N days" do
507
+ rules = {
508
+ version: "1.0",
509
+ ruleset: "test",
510
+ rules: [
511
+ {
512
+ id: "rule_1",
513
+ if: { field: "event_date", op: "within_days", value: 7 },
514
+ then: { decision: "upcoming" }
515
+ }
516
+ ]
517
+ }
518
+
519
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
520
+ event_date = (Time.now + (30 * 24 * 60 * 60)).strftime("%Y-%m-%d")
521
+ context = DecisionAgent::Context.new({ event_date: event_date })
522
+
523
+ evaluation = evaluator.evaluate(context)
524
+
525
+ expect(evaluation).to be_nil
526
+ end
527
+ end
528
+
529
+ describe "day_of_week operator" do
530
+ it "matches when day of week is correct (string format)" do
531
+ rules = {
532
+ version: "1.0",
533
+ ruleset: "test",
534
+ rules: [
535
+ {
536
+ id: "rule_1",
537
+ if: { field: "appointment", op: "day_of_week", value: "monday" },
538
+ then: { decision: "monday_appointment" }
539
+ }
540
+ ]
541
+ }
542
+
543
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
544
+ # Find next Monday
545
+ monday_date = Time.now
546
+ monday_date += 24 * 60 * 60 until monday_date.wday == 1
547
+ context = DecisionAgent::Context.new({ appointment: monday_date.strftime("%Y-%m-%d") })
548
+
549
+ evaluation = evaluator.evaluate(context)
550
+
551
+ expect(evaluation).not_to be_nil
552
+ expect(evaluation.decision).to eq("monday_appointment")
553
+ end
554
+
555
+ it "matches when day of week is correct (numeric format)" do
556
+ rules = {
557
+ version: "1.0",
558
+ ruleset: "test",
559
+ rules: [
560
+ {
561
+ id: "rule_1",
562
+ if: { field: "appointment", op: "day_of_week", value: 1 },
563
+ then: { decision: "monday_appointment" }
564
+ }
565
+ ]
566
+ }
567
+
568
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
569
+ monday_date = Time.now
570
+ monday_date += 24 * 60 * 60 until monday_date.wday == 1
571
+ context = DecisionAgent::Context.new({ appointment: monday_date.strftime("%Y-%m-%d") })
572
+
573
+ evaluation = evaluator.evaluate(context)
574
+
575
+ expect(evaluation).not_to be_nil
576
+ expect(evaluation.decision).to eq("monday_appointment")
577
+ end
578
+ end
579
+ end
580
+
581
+ # COLLECTION OPERATORS
582
+ describe "collection operators" do
583
+ describe "contains_all operator" do
584
+ it "matches when array contains all specified elements" do
585
+ rules = {
586
+ version: "1.0",
587
+ ruleset: "test",
588
+ rules: [
589
+ {
590
+ id: "rule_1",
591
+ if: { field: "permissions", op: "contains_all", value: %w[read write] },
592
+ then: { decision: "full_access" }
593
+ }
594
+ ]
595
+ }
596
+
597
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
598
+ context = DecisionAgent::Context.new({ permissions: %w[read write execute] })
599
+
600
+ evaluation = evaluator.evaluate(context)
601
+
602
+ expect(evaluation).not_to be_nil
603
+ expect(evaluation.decision).to eq("full_access")
604
+ end
605
+
606
+ it "does not match when array is missing some elements" do
607
+ rules = {
608
+ version: "1.0",
609
+ ruleset: "test",
610
+ rules: [
611
+ {
612
+ id: "rule_1",
613
+ if: { field: "permissions", op: "contains_all", value: %w[read write] },
614
+ then: { decision: "full_access" }
615
+ }
616
+ ]
617
+ }
618
+
619
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
620
+ context = DecisionAgent::Context.new({ permissions: ["read"] })
621
+
622
+ evaluation = evaluator.evaluate(context)
623
+
624
+ expect(evaluation).to be_nil
625
+ end
626
+ end
627
+
628
+ describe "contains_any operator" do
629
+ it "matches when array contains any of the specified elements" do
630
+ rules = {
631
+ version: "1.0",
632
+ ruleset: "test",
633
+ rules: [
634
+ {
635
+ id: "rule_1",
636
+ if: { field: "tags", op: "contains_any", value: %w[urgent critical] },
637
+ then: { decision: "prioritize" }
638
+ }
639
+ ]
640
+ }
641
+
642
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
643
+ context = DecisionAgent::Context.new({ tags: %w[normal urgent] })
644
+
645
+ evaluation = evaluator.evaluate(context)
646
+
647
+ expect(evaluation).not_to be_nil
648
+ expect(evaluation.decision).to eq("prioritize")
649
+ end
650
+
651
+ it "does not match when array contains none of the specified elements" do
652
+ rules = {
653
+ version: "1.0",
654
+ ruleset: "test",
655
+ rules: [
656
+ {
657
+ id: "rule_1",
658
+ if: { field: "tags", op: "contains_any", value: %w[urgent critical] },
659
+ then: { decision: "prioritize" }
660
+ }
661
+ ]
662
+ }
663
+
664
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
665
+ context = DecisionAgent::Context.new({ tags: %w[normal low] })
666
+
667
+ evaluation = evaluator.evaluate(context)
668
+
669
+ expect(evaluation).to be_nil
670
+ end
671
+ end
672
+
673
+ describe "intersects operator" do
674
+ it "matches when arrays have common elements" do
675
+ rules = {
676
+ version: "1.0",
677
+ ruleset: "test",
678
+ rules: [
679
+ {
680
+ id: "rule_1",
681
+ if: { field: "user_roles", op: "intersects", value: %w[admin moderator] },
682
+ then: { decision: "has_elevated_role" }
683
+ }
684
+ ]
685
+ }
686
+
687
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
688
+ context = DecisionAgent::Context.new({ user_roles: %w[user moderator] })
689
+
690
+ evaluation = evaluator.evaluate(context)
691
+
692
+ expect(evaluation).not_to be_nil
693
+ expect(evaluation.decision).to eq("has_elevated_role")
694
+ end
695
+
696
+ it "does not match when arrays have no common elements" do
697
+ rules = {
698
+ version: "1.0",
699
+ ruleset: "test",
700
+ rules: [
701
+ {
702
+ id: "rule_1",
703
+ if: { field: "user_roles", op: "intersects", value: %w[admin moderator] },
704
+ then: { decision: "has_elevated_role" }
705
+ }
706
+ ]
707
+ }
708
+
709
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
710
+ context = DecisionAgent::Context.new({ user_roles: %w[user guest] })
711
+
712
+ evaluation = evaluator.evaluate(context)
713
+
714
+ expect(evaluation).to be_nil
715
+ end
716
+ end
717
+
718
+ describe "subset_of operator" do
719
+ it "matches when array is a subset of another" do
720
+ rules = {
721
+ version: "1.0",
722
+ ruleset: "test",
723
+ rules: [
724
+ {
725
+ id: "rule_1",
726
+ if: { field: "selected_items", op: "subset_of", value: %w[a b c d] },
727
+ then: { decision: "valid_selection" }
728
+ }
729
+ ]
730
+ }
731
+
732
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
733
+ context = DecisionAgent::Context.new({ selected_items: %w[a c] })
734
+
735
+ evaluation = evaluator.evaluate(context)
736
+
737
+ expect(evaluation).not_to be_nil
738
+ expect(evaluation.decision).to eq("valid_selection")
739
+ end
740
+
741
+ it "does not match when array is not a subset" do
742
+ rules = {
743
+ version: "1.0",
744
+ ruleset: "test",
745
+ rules: [
746
+ {
747
+ id: "rule_1",
748
+ if: { field: "selected_items", op: "subset_of", value: %w[a b c] },
749
+ then: { decision: "valid_selection" }
750
+ }
751
+ ]
752
+ }
753
+
754
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
755
+ context = DecisionAgent::Context.new({ selected_items: %w[a d] })
756
+
757
+ evaluation = evaluator.evaluate(context)
758
+
759
+ expect(evaluation).to be_nil
760
+ end
761
+ end
762
+ end
763
+
764
+ # GEOSPATIAL OPERATORS
765
+ describe "geospatial operators" do
766
+ describe "within_radius operator" do
767
+ it "matches when point is within radius (hash format)" do
768
+ rules = {
769
+ version: "1.0",
770
+ ruleset: "test",
771
+ rules: [
772
+ {
773
+ id: "rule_1",
774
+ if: {
775
+ field: "location",
776
+ op: "within_radius",
777
+ value: { center: { lat: 40.7128, lon: -74.0060 }, radius: 10 }
778
+ },
779
+ then: { decision: "nearby" }
780
+ }
781
+ ]
782
+ }
783
+
784
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
785
+ # Point very close to center (within 1km)
786
+ context = DecisionAgent::Context.new({ location: { lat: 40.7200, lon: -74.0000 } })
787
+
788
+ evaluation = evaluator.evaluate(context)
789
+
790
+ expect(evaluation).not_to be_nil
791
+ expect(evaluation.decision).to eq("nearby")
792
+ end
793
+
794
+ it "matches when point is within radius (array format)" do
795
+ rules = {
796
+ version: "1.0",
797
+ ruleset: "test",
798
+ rules: [
799
+ {
800
+ id: "rule_1",
801
+ if: {
802
+ field: "location",
803
+ op: "within_radius",
804
+ value: { center: [40.7128, -74.0060], radius: 10 }
805
+ },
806
+ then: { decision: "nearby" }
807
+ }
808
+ ]
809
+ }
810
+
811
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
812
+ context = DecisionAgent::Context.new({ location: [40.7200, -74.0000] })
813
+
814
+ evaluation = evaluator.evaluate(context)
815
+
816
+ expect(evaluation).not_to be_nil
817
+ expect(evaluation.decision).to eq("nearby")
818
+ end
819
+
820
+ it "does not match when point is outside radius" do
821
+ rules = {
822
+ version: "1.0",
823
+ ruleset: "test",
824
+ rules: [
825
+ {
826
+ id: "rule_1",
827
+ if: {
828
+ field: "location",
829
+ op: "within_radius",
830
+ value: { center: { lat: 40.7128, lon: -74.0060 }, radius: 1 }
831
+ },
832
+ then: { decision: "nearby" }
833
+ }
834
+ ]
835
+ }
836
+
837
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
838
+ # Point far from center (more than 100km away)
839
+ context = DecisionAgent::Context.new({ location: { lat: 41.0, lon: -75.0 } })
840
+
841
+ evaluation = evaluator.evaluate(context)
842
+
843
+ expect(evaluation).to be_nil
844
+ end
845
+ end
846
+
847
+ describe "in_polygon operator" do
848
+ it "matches when point is inside polygon" do
849
+ # Square polygon around point (0,0)
850
+ polygon = [
851
+ { lat: -1, lon: -1 },
852
+ { lat: 1, lon: -1 },
853
+ { lat: 1, lon: 1 },
854
+ { lat: -1, lon: 1 }
855
+ ]
856
+
857
+ rules = {
858
+ version: "1.0",
859
+ ruleset: "test",
860
+ rules: [
861
+ {
862
+ id: "rule_1",
863
+ if: { field: "location", op: "in_polygon", value: polygon },
864
+ then: { decision: "inside_zone" }
865
+ }
866
+ ]
867
+ }
868
+
869
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
870
+ context = DecisionAgent::Context.new({ location: { lat: 0, lon: 0 } })
871
+
872
+ evaluation = evaluator.evaluate(context)
873
+
874
+ expect(evaluation).not_to be_nil
875
+ expect(evaluation.decision).to eq("inside_zone")
876
+ end
877
+
878
+ it "does not match when point is outside polygon" do
879
+ polygon = [
880
+ { lat: -1, lon: -1 },
881
+ { lat: 1, lon: -1 },
882
+ { lat: 1, lon: 1 },
883
+ { lat: -1, lon: 1 }
884
+ ]
885
+
886
+ rules = {
887
+ version: "1.0",
888
+ ruleset: "test",
889
+ rules: [
890
+ {
891
+ id: "rule_1",
892
+ if: { field: "location", op: "in_polygon", value: polygon },
893
+ then: { decision: "inside_zone" }
894
+ }
895
+ ]
896
+ }
897
+
898
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
899
+ context = DecisionAgent::Context.new({ location: { lat: 5, lon: 5 } })
900
+
901
+ evaluation = evaluator.evaluate(context)
902
+
903
+ expect(evaluation).to be_nil
904
+ end
905
+
906
+ it "works with array format coordinates" do
907
+ polygon = [
908
+ [-1, -1],
909
+ [1, -1],
910
+ [1, 1],
911
+ [-1, 1]
912
+ ]
913
+
914
+ rules = {
915
+ version: "1.0",
916
+ ruleset: "test",
917
+ rules: [
918
+ {
919
+ id: "rule_1",
920
+ if: { field: "location", op: "in_polygon", value: polygon },
921
+ then: { decision: "inside_zone" }
922
+ }
923
+ ]
924
+ }
925
+
926
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
927
+ context = DecisionAgent::Context.new({ location: [0, 0] })
928
+
929
+ evaluation = evaluator.evaluate(context)
930
+
931
+ expect(evaluation).not_to be_nil
932
+ expect(evaluation.decision).to eq("inside_zone")
933
+ end
934
+ end
935
+ end
936
+
937
+ # COMBINATION TESTS
938
+ describe "combining new operators with all/any" do
939
+ it "works with 'all' condition" do
940
+ rules = {
941
+ version: "1.0",
942
+ ruleset: "test",
943
+ rules: [
944
+ {
945
+ id: "rule_1",
946
+ if: {
947
+ all: [
948
+ { field: "email", op: "ends_with", value: "@company.com" },
949
+ { field: "age", op: "between", value: [18, 65] },
950
+ { field: "roles", op: "contains_any", value: %w[admin manager] }
951
+ ]
952
+ },
953
+ then: { decision: "approve" }
954
+ }
955
+ ]
956
+ }
957
+
958
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
959
+ context = DecisionAgent::Context.new({
960
+ email: "user@company.com",
961
+ age: 30,
962
+ roles: %w[user admin]
963
+ })
964
+
965
+ evaluation = evaluator.evaluate(context)
966
+
967
+ expect(evaluation).not_to be_nil
968
+ expect(evaluation.decision).to eq("approve")
969
+ end
970
+
971
+ it "works with 'any' condition" do
972
+ rules = {
973
+ version: "1.0",
974
+ ruleset: "test",
975
+ rules: [
976
+ {
977
+ id: "rule_1",
978
+ if: {
979
+ any: [
980
+ { field: "status", op: "contains", value: "urgent" },
981
+ { field: "priority", op: "modulo", value: [2, 1] },
982
+ { field: "tags", op: "intersects", value: %w[critical emergency] }
983
+ ]
984
+ },
985
+ then: { decision: "escalate" }
986
+ }
987
+ ]
988
+ }
989
+
990
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
991
+ context = DecisionAgent::Context.new({
992
+ status: "normal",
993
+ priority: 7,
994
+ tags: ["normal"]
995
+ })
996
+
997
+ evaluation = evaluator.evaluate(context)
998
+
999
+ expect(evaluation).not_to be_nil
1000
+ expect(evaluation.decision).to eq("escalate")
1001
+ end
1002
+ end
1003
+ end