kbs 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +85 -57
- data/docs/advanced/performance.md +109 -76
- data/docs/advanced/testing.md +399 -263
- data/docs/api/blackboard.md +1 -1
- data/docs/api/engine.md +77 -8
- data/docs/api/facts.md +3 -3
- data/docs/api/rules.md +110 -40
- data/docs/architecture/blackboard.md +108 -117
- data/docs/assets/images/fact-rule-relationship.svg +65 -0
- data/docs/assets/images/fact-structure.svg +42 -0
- data/docs/assets/images/inference-cycle.svg +47 -0
- data/docs/assets/images/kb-components.svg +43 -0
- data/docs/assets/images/rule-structure.svg +44 -0
- data/docs/assets/images/trading-signal-network.svg +1 -1
- data/docs/examples/index.md +219 -5
- data/docs/guides/blackboard-memory.md +89 -58
- data/docs/guides/dsl.md +24 -24
- data/docs/guides/getting-started.md +109 -107
- data/docs/guides/writing-rules.md +470 -311
- data/docs/index.md +16 -18
- data/docs/quick-start.md +92 -99
- data/docs/what-is-a-fact.md +694 -0
- data/docs/what-is-a-knowledge-base.md +350 -0
- data/docs/what-is-a-rule.md +833 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_example_dsl.rb +1 -1
- data/examples/ai_enhanced_kbs_dsl.rb +1 -1
- data/examples/car_diagnostic_dsl.rb +1 -1
- data/examples/concurrent_inference_demo.rb +0 -1
- data/examples/concurrent_inference_demo_dsl.rb +0 -1
- data/examples/csv_trading_system_dsl.rb +1 -1
- data/examples/iot_demo_using_dsl.rb +1 -1
- data/examples/portfolio_rebalancing_system_dsl.rb +1 -1
- data/examples/rule_source_demo.rb +123 -0
- data/examples/stock_trading_advanced_dsl.rb +1 -1
- data/examples/temp_dsl.txt +6214 -5269
- data/examples/timestamped_trading_dsl.rb +1 -1
- data/examples/trading_demo_dsl.rb +1 -1
- data/examples/working_demo_dsl.rb +1 -1
- data/lib/kbs/decompiler.rb +204 -0
- data/lib/kbs/dsl/knowledge_base.rb +100 -1
- data/lib/kbs/dsl.rb +3 -1
- data/lib/kbs/engine.rb +41 -0
- data/lib/kbs/version.rb +1 -1
- data/lib/kbs.rb +14 -12
- data/mkdocs.yml +30 -30
- metadata +15 -10
- data/docs/DOCUMENTATION_STATUS.md +0 -158
- data/docs/examples/expert-systems.md +0 -1031
- data/docs/examples/multi-agent.md +0 -1335
- data/docs/examples/stock-trading.md +0 -488
- data/examples/knowledge_base.db +0 -0
- data/examples/temp.txt +0 -7693
|
@@ -7,13 +7,15 @@ Master the art of authoring production rules. This guide covers best practices,
|
|
|
7
7
|
Every rule consists of three parts:
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
|
-
KBS
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
KBS.knowledge_base do
|
|
11
|
+
rule "rule_name", priority: 0 do
|
|
12
|
+
# 1. CONDITIONS - Pattern matching
|
|
13
|
+
on :fact_type, attr: value
|
|
14
|
+
|
|
15
|
+
# 2. ACTION - What to do when conditions match
|
|
16
|
+
perform do |facts, bindings|
|
|
17
|
+
# Execute logic
|
|
18
|
+
end
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
```
|
|
@@ -45,9 +47,19 @@ Choose descriptive, actionable names:
|
|
|
45
47
|
Control execution order when multiple rules match:
|
|
46
48
|
|
|
47
49
|
```ruby
|
|
48
|
-
KBS
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
KBS.knowledge_base do
|
|
51
|
+
rule "critical_safety_check", priority: 100 do # Fires first
|
|
52
|
+
# ...
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
rule "normal_processing", priority: 50 do
|
|
56
|
+
# ...
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
rule "cleanup_task", priority: 10 do # Fires last
|
|
60
|
+
# ...
|
|
61
|
+
end
|
|
62
|
+
end
|
|
51
63
|
```
|
|
52
64
|
|
|
53
65
|
**Priority Guidelines:**
|
|
@@ -61,13 +73,19 @@ KBS::Rule.new("cleanup_task", priority: 10) # Fires last
|
|
|
61
73
|
Patterns that must match for the rule to fire. Order matters for performance.
|
|
62
74
|
|
|
63
75
|
```ruby
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
KBS.knowledge_base do
|
|
77
|
+
rule "example" do
|
|
78
|
+
# Most selective first (fewest matches)
|
|
79
|
+
on :critical_alert, severity: "critical"
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
# Less selective last (more matches)
|
|
82
|
+
on :sensor, id: :sensor_id?
|
|
83
|
+
|
|
84
|
+
perform do |facts, bindings|
|
|
85
|
+
# Action
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
71
89
|
```
|
|
72
90
|
|
|
73
91
|
### 4. Action
|
|
@@ -75,16 +93,23 @@ r.conditions = [
|
|
|
75
93
|
Code executed when all conditions match:
|
|
76
94
|
|
|
77
95
|
```ruby
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
KBS.knowledge_base do
|
|
97
|
+
rule "example" do
|
|
98
|
+
on :alert, message: :msg?
|
|
99
|
+
on :sensor, id: :sensor_id?
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
perform do |facts, bindings|
|
|
102
|
+
# Access matched facts
|
|
103
|
+
alert = facts[0]
|
|
104
|
+
sensor = facts[1]
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
# Access variable bindings
|
|
107
|
+
sensor_id = bindings[:sensor_id?]
|
|
108
|
+
|
|
109
|
+
# Perform action
|
|
110
|
+
notify_operator(sensor_id, alert[:message])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
88
113
|
end
|
|
89
114
|
```
|
|
90
115
|
|
|
@@ -96,17 +121,23 @@ end
|
|
|
96
121
|
|
|
97
122
|
```ruby
|
|
98
123
|
# Bad: General condition first
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
KBS.knowledge_base do
|
|
125
|
+
rule "inefficient" do
|
|
126
|
+
on :sensor, {} # 1000 matches
|
|
127
|
+
on :critical_alert, {} # 1 match
|
|
128
|
+
perform { }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
103
131
|
# Creates 1000 partial matches, wastes memory
|
|
104
132
|
|
|
105
133
|
# Good: Specific condition first
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
134
|
+
KBS.knowledge_base do
|
|
135
|
+
rule "efficient" do
|
|
136
|
+
on :critical_alert, {} # 1 match
|
|
137
|
+
on :sensor, {} # Joins with 1000
|
|
138
|
+
perform { }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
110
141
|
# Creates 1 partial match, efficient joins
|
|
111
142
|
```
|
|
112
143
|
|
|
@@ -114,31 +145,31 @@ r.conditions = [
|
|
|
114
145
|
|
|
115
146
|
```ruby
|
|
116
147
|
# Most selective (few facts)
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
on :emergency, level: "critical"
|
|
149
|
+
on :user, role: "admin"
|
|
119
150
|
|
|
120
151
|
# Moderate selectivity
|
|
121
|
-
|
|
122
|
-
|
|
152
|
+
on :order, status: "pending"
|
|
153
|
+
on :stock, exchange: "NYSE"
|
|
123
154
|
|
|
124
155
|
# Least selective (many facts)
|
|
125
|
-
|
|
126
|
-
|
|
156
|
+
on :sensor, {}
|
|
157
|
+
on :log_entry, {}
|
|
127
158
|
```
|
|
128
159
|
|
|
129
160
|
### Measuring Selectivity
|
|
130
161
|
|
|
131
162
|
```ruby
|
|
132
|
-
def measure_selectivity(
|
|
133
|
-
engine.facts.count { |f|
|
|
163
|
+
def measure_selectivity(kb, type, pattern)
|
|
164
|
+
kb.engine.facts.count { |f|
|
|
134
165
|
f.type == type &&
|
|
135
166
|
pattern.all? { |k, v| f[k] == v }
|
|
136
167
|
}
|
|
137
168
|
end
|
|
138
169
|
|
|
139
170
|
# Compare
|
|
140
|
-
puts measure_selectivity(
|
|
141
|
-
puts measure_selectivity(
|
|
171
|
+
puts measure_selectivity(kb, :critical_alert, {}) # => 1
|
|
172
|
+
puts measure_selectivity(kb, :sensor, {}) # => 1000
|
|
142
173
|
|
|
143
174
|
# Order: critical_alert first, sensor second
|
|
144
175
|
```
|
|
@@ -151,52 +182,58 @@ One action, one purpose:
|
|
|
151
182
|
|
|
152
183
|
```ruby
|
|
153
184
|
# Good: Focused action
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
KBS.knowledge_base do
|
|
186
|
+
rule "send_email" do
|
|
187
|
+
on :alert, email: :email?, message: :message?
|
|
188
|
+
perform do |facts, bindings|
|
|
189
|
+
send_email_alert(bindings[:email?], bindings[:message?])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
156
192
|
end
|
|
157
193
|
|
|
158
194
|
# Bad: Multiple responsibilities
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
195
|
+
KBS.knowledge_base do
|
|
196
|
+
rule "do_everything" do
|
|
197
|
+
on :trigger, email: :email?, id: :id?, data: :data?, msg: :msg?
|
|
198
|
+
perform do |facts, bindings|
|
|
199
|
+
send_email_alert(bindings[:email?])
|
|
200
|
+
update_database(bindings[:id?])
|
|
201
|
+
call_external_api(bindings[:data?])
|
|
202
|
+
write_log_file(bindings[:msg?])
|
|
203
|
+
end
|
|
204
|
+
end
|
|
164
205
|
end
|
|
165
206
|
```
|
|
166
207
|
|
|
167
208
|
Split complex actions into multiple rules:
|
|
168
209
|
|
|
169
210
|
```ruby
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
]
|
|
211
|
+
KBS.knowledge_base do
|
|
212
|
+
# Rule 1: Detect condition
|
|
213
|
+
rule "detect_high_temp", priority: 50 do
|
|
214
|
+
on :sensor, temp: :temp?, predicate: greater_than(30)
|
|
175
215
|
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
perform do |facts, bindings|
|
|
217
|
+
fact :high_temp_detected, temp: bindings[:temp?]
|
|
218
|
+
end
|
|
178
219
|
end
|
|
179
|
-
end
|
|
180
220
|
|
|
181
|
-
# Rule 2: Send alert
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
KBS::Condition.new(:high_temp_detected, { temp: :temp? })
|
|
185
|
-
]
|
|
221
|
+
# Rule 2: Send alert
|
|
222
|
+
rule "send_temp_alert", priority: 40 do
|
|
223
|
+
on :high_temp_detected, temp: :temp?
|
|
186
224
|
|
|
187
|
-
|
|
188
|
-
|
|
225
|
+
perform do |facts, bindings|
|
|
226
|
+
send_email("High temp: #{bindings[:temp?]}")
|
|
227
|
+
end
|
|
189
228
|
end
|
|
190
|
-
end
|
|
191
229
|
|
|
192
|
-
# Rule 3: Log event
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
KBS::Condition.new(:high_temp_detected, { temp: :temp? })
|
|
196
|
-
]
|
|
230
|
+
# Rule 3: Log event
|
|
231
|
+
rule "log_temp_event", priority: 30 do
|
|
232
|
+
on :high_temp_detected, temp: :temp?
|
|
197
233
|
|
|
198
|
-
|
|
199
|
-
|
|
234
|
+
perform do |facts, bindings|
|
|
235
|
+
logger.info("Temperature spike: #{bindings[:temp?]}")
|
|
236
|
+
end
|
|
200
237
|
end
|
|
201
238
|
end
|
|
202
239
|
```
|
|
@@ -207,19 +244,31 @@ Actions should be deterministic and idempotent when possible:
|
|
|
207
244
|
|
|
208
245
|
```ruby
|
|
209
246
|
# Good: Idempotent (safe to run multiple times)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
247
|
+
kb = KBS.knowledge_base do
|
|
248
|
+
rule "update_alert" do
|
|
249
|
+
on :trigger, id: :id?
|
|
250
|
+
|
|
251
|
+
perform do |facts, bindings|
|
|
252
|
+
# Remove old alert if exists
|
|
253
|
+
old = engine.facts.find { |f| f.type == :alert && f[:id] == bindings[:id?] }
|
|
254
|
+
engine.remove_fact(old) if old
|
|
214
255
|
|
|
215
|
-
|
|
216
|
-
|
|
256
|
+
# Add new alert
|
|
257
|
+
fact :alert, id: bindings[:id?], message: "Alert!"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
217
260
|
end
|
|
218
261
|
|
|
219
262
|
# Bad: Non-idempotent (creates duplicates)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
263
|
+
kb = KBS.knowledge_base do
|
|
264
|
+
rule "duplicate_alerts" do
|
|
265
|
+
on :trigger, id: :id?
|
|
266
|
+
|
|
267
|
+
perform do |facts, bindings|
|
|
268
|
+
# Always adds, even if alert already exists
|
|
269
|
+
fact :alert, id: bindings[:id?], message: "Alert!"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
223
272
|
end
|
|
224
273
|
```
|
|
225
274
|
|
|
@@ -228,17 +277,22 @@ end
|
|
|
228
277
|
Protect against failures:
|
|
229
278
|
|
|
230
279
|
```ruby
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
280
|
+
KBS.knowledge_base do
|
|
281
|
+
rule "safe_email" do
|
|
282
|
+
on :alert, email: :email?, message: :message?
|
|
283
|
+
|
|
284
|
+
perform do |facts, bindings|
|
|
285
|
+
begin
|
|
286
|
+
send_email(bindings[:email?], bindings[:message?])
|
|
287
|
+
rescue Net::SMTPError => e
|
|
288
|
+
logger.error("Failed to send email: #{e.message}")
|
|
289
|
+
# Add failure fact for retry logic
|
|
290
|
+
fact :email_failure,
|
|
291
|
+
email: bindings[:email?],
|
|
292
|
+
error: e.message,
|
|
293
|
+
timestamp: Time.now
|
|
294
|
+
end
|
|
295
|
+
end
|
|
242
296
|
end
|
|
243
297
|
end
|
|
244
298
|
```
|
|
@@ -267,20 +321,22 @@ Use descriptive, consistent variable names:
|
|
|
267
321
|
Connect facts through shared variables:
|
|
268
322
|
|
|
269
323
|
```ruby
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
324
|
+
KBS.knowledge_base do
|
|
325
|
+
# Pattern: Join sensor reading with threshold
|
|
326
|
+
rule "check_threshold" do
|
|
327
|
+
on :sensor,
|
|
328
|
+
id: :sensor_id?,
|
|
329
|
+
temp: :current_temp?
|
|
330
|
+
|
|
331
|
+
on :threshold,
|
|
332
|
+
sensor_id: :sensor_id?, # Same variable = join constraint
|
|
333
|
+
max_temp: :max_temp?
|
|
334
|
+
|
|
335
|
+
perform do |facts, bindings|
|
|
336
|
+
# Only matches when sensor_id is same in both facts
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
284
340
|
```
|
|
285
341
|
|
|
286
342
|
### Computed Bindings
|
|
@@ -288,15 +344,22 @@ r.conditions = [
|
|
|
288
344
|
Derive values in actions:
|
|
289
345
|
|
|
290
346
|
```ruby
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
347
|
+
KBS.knowledge_base do
|
|
348
|
+
rule "calculate_diff" do
|
|
349
|
+
on :sensor, temp: :current_temp?
|
|
350
|
+
on :threshold, max_temp: :max_temp?
|
|
351
|
+
|
|
352
|
+
perform do |facts, bindings|
|
|
353
|
+
current = bindings[:current_temp?]
|
|
354
|
+
max = bindings[:max_temp?]
|
|
294
355
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
356
|
+
# Compute derived values
|
|
357
|
+
diff = current - max
|
|
358
|
+
percentage_over = ((current / max.to_f) - 1) * 100
|
|
298
359
|
|
|
299
|
-
|
|
360
|
+
puts "#{diff}°C over threshold (#{percentage_over.round(1)}%)"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
300
363
|
end
|
|
301
364
|
```
|
|
302
365
|
|
|
@@ -307,47 +370,40 @@ end
|
|
|
307
370
|
Model state transitions:
|
|
308
371
|
|
|
309
372
|
```ruby
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
373
|
+
KBS.knowledge_base do
|
|
374
|
+
# Transition: pending → processing
|
|
375
|
+
rule "start_processing" do
|
|
376
|
+
on :order,
|
|
314
377
|
id: :order_id?,
|
|
315
378
|
status: "pending"
|
|
316
|
-
})
|
|
317
|
-
]
|
|
318
379
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
380
|
+
perform do |facts, bindings|
|
|
381
|
+
old_order = facts[0]
|
|
382
|
+
engine.remove_fact(old_order)
|
|
383
|
+
fact :order,
|
|
384
|
+
id: bindings[:order_id?],
|
|
385
|
+
status: "processing",
|
|
386
|
+
started_at: Time.now
|
|
387
|
+
end
|
|
327
388
|
end
|
|
328
|
-
end
|
|
329
389
|
|
|
330
|
-
# Transition: processing → completed
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
KBS::Condition.new(:order, {
|
|
390
|
+
# Transition: processing → completed
|
|
391
|
+
rule "complete_processing" do
|
|
392
|
+
on :order,
|
|
334
393
|
id: :order_id?,
|
|
335
394
|
status: "processing"
|
|
336
|
-
|
|
337
|
-
KBS::Condition.new(:processing_done, {
|
|
395
|
+
on :processing_done,
|
|
338
396
|
order_id: :order_id?
|
|
339
|
-
})
|
|
340
|
-
]
|
|
341
397
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
398
|
+
perform do |facts, bindings|
|
|
399
|
+
order = facts[0]
|
|
400
|
+
engine.remove_fact(order)
|
|
401
|
+
engine.remove_fact(facts[1]) # Remove trigger
|
|
402
|
+
fact :order,
|
|
403
|
+
id: bindings[:order_id?],
|
|
404
|
+
status: "completed",
|
|
405
|
+
completed_at: Time.now
|
|
406
|
+
end
|
|
351
407
|
end
|
|
352
408
|
end
|
|
353
409
|
```
|
|
@@ -357,19 +413,19 @@ end
|
|
|
357
413
|
Prevent duplicate actions:
|
|
358
414
|
|
|
359
415
|
```ruby
|
|
360
|
-
KBS
|
|
361
|
-
|
|
362
|
-
|
|
416
|
+
KBS.knowledge_base do
|
|
417
|
+
rule "send_alert_once" do
|
|
418
|
+
on :high_temp, sensor_id: :id?
|
|
363
419
|
|
|
364
420
|
# Guard: Only fire if alert not already sent
|
|
365
|
-
|
|
366
|
-
]
|
|
421
|
+
without :alert_sent, sensor_id: :id?
|
|
367
422
|
|
|
368
|
-
|
|
369
|
-
|
|
423
|
+
perform do |facts, bindings|
|
|
424
|
+
send_alert(bindings[:id?])
|
|
370
425
|
|
|
371
|
-
|
|
372
|
-
|
|
426
|
+
# Record that we sent this alert
|
|
427
|
+
fact :alert_sent, sensor_id: bindings[:id?]
|
|
428
|
+
end
|
|
373
429
|
end
|
|
374
430
|
end
|
|
375
431
|
```
|
|
@@ -379,18 +435,18 @@ end
|
|
|
379
435
|
Remove stale facts:
|
|
380
436
|
|
|
381
437
|
```ruby
|
|
382
|
-
KBS
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
timestamp: :time
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
]
|
|
438
|
+
KBS.knowledge_base do
|
|
439
|
+
rule "cleanup_stale_alerts", priority: 1 do
|
|
440
|
+
on :alert,
|
|
441
|
+
timestamp: :time?,
|
|
442
|
+
predicate: lambda { |f|
|
|
443
|
+
(Time.now - f[:timestamp]) > 3600 # 1 hour old
|
|
444
|
+
}
|
|
390
445
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
446
|
+
perform do |facts, bindings|
|
|
447
|
+
engine.remove_fact(facts[0])
|
|
448
|
+
logger.info("Removed stale alert")
|
|
449
|
+
end
|
|
394
450
|
end
|
|
395
451
|
end
|
|
396
452
|
```
|
|
@@ -400,20 +456,20 @@ end
|
|
|
400
456
|
Compute over multiple facts:
|
|
401
457
|
|
|
402
458
|
```ruby
|
|
403
|
-
KBS
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
]
|
|
459
|
+
KBS.knowledge_base do
|
|
460
|
+
rule "compute_average_temp" do
|
|
461
|
+
on :compute_avg_requested, {}
|
|
407
462
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
463
|
+
perform do |facts, bindings|
|
|
464
|
+
temps = engine.facts
|
|
465
|
+
.select { |f| f.type == :sensor }
|
|
466
|
+
.map { |f| f[:temp] }
|
|
467
|
+
.compact
|
|
413
468
|
|
|
414
|
-
|
|
469
|
+
avg = temps.sum / temps.size.to_f
|
|
415
470
|
|
|
416
|
-
|
|
471
|
+
fact :average_temp, value: avg
|
|
472
|
+
end
|
|
417
473
|
end
|
|
418
474
|
end
|
|
419
475
|
```
|
|
@@ -423,26 +479,23 @@ end
|
|
|
423
479
|
React to time-based conditions:
|
|
424
480
|
|
|
425
481
|
```ruby
|
|
426
|
-
KBS
|
|
427
|
-
|
|
428
|
-
|
|
482
|
+
KBS.knowledge_base do
|
|
483
|
+
rule "detect_delayed_response" do
|
|
484
|
+
on :request,
|
|
429
485
|
id: :req_id?,
|
|
430
486
|
created_at: :created?
|
|
431
|
-
}),
|
|
432
487
|
|
|
433
|
-
|
|
488
|
+
without :response,
|
|
434
489
|
request_id: :req_id?
|
|
435
|
-
}, negated: true),
|
|
436
490
|
|
|
437
|
-
|
|
491
|
+
on :request, {},
|
|
438
492
|
predicate: lambda { |f|
|
|
439
493
|
(Time.now - f[:created_at]) > 300 # 5 minutes
|
|
440
494
|
}
|
|
441
|
-
)
|
|
442
|
-
]
|
|
443
495
|
|
|
444
|
-
|
|
445
|
-
|
|
496
|
+
perform do |facts, bindings|
|
|
497
|
+
alert("Request #{bindings[:req_id?]} delayed!")
|
|
498
|
+
end
|
|
446
499
|
end
|
|
447
500
|
end
|
|
448
501
|
```
|
|
@@ -464,12 +517,14 @@ module Priority
|
|
|
464
517
|
end
|
|
465
518
|
|
|
466
519
|
# Use in rules
|
|
467
|
-
KBS
|
|
468
|
-
|
|
469
|
-
|
|
520
|
+
KBS.knowledge_base do
|
|
521
|
+
rule "emergency_shutdown", priority: Priority::CRITICAL do
|
|
522
|
+
# ...
|
|
523
|
+
end
|
|
470
524
|
|
|
471
|
-
|
|
472
|
-
|
|
525
|
+
rule "process_order", priority: Priority::NORMAL do
|
|
526
|
+
# ...
|
|
527
|
+
end
|
|
473
528
|
end
|
|
474
529
|
```
|
|
475
530
|
|
|
@@ -479,21 +534,37 @@ Avoid priority inversions where low-priority rules block high-priority rules:
|
|
|
479
534
|
|
|
480
535
|
```ruby
|
|
481
536
|
# Bad: Low priority rule creates fact needed by high priority rule
|
|
482
|
-
KBS
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
537
|
+
KBS.knowledge_base do
|
|
538
|
+
rule "compute_risk", priority: 10 do
|
|
539
|
+
on :data, value: :v?
|
|
540
|
+
perform do |facts, bindings|
|
|
541
|
+
fact :risk_score, value: calculate_risk(bindings[:v?])
|
|
542
|
+
end
|
|
543
|
+
end
|
|
486
544
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
545
|
+
rule "emergency_check", priority: 100 do
|
|
546
|
+
on :risk_score, value: :risk? # Depends on low priority rule!
|
|
547
|
+
perform do |facts, bindings|
|
|
548
|
+
emergency_shutdown if bindings[:risk?] > 90
|
|
549
|
+
end
|
|
550
|
+
end
|
|
492
551
|
end
|
|
493
552
|
|
|
494
553
|
# Fix: Make dependency higher priority
|
|
495
|
-
KBS
|
|
496
|
-
# Now runs before emergency_check
|
|
554
|
+
KBS.knowledge_base do
|
|
555
|
+
rule "compute_risk", priority: 110 do # Now runs before emergency_check
|
|
556
|
+
on :data, value: :v?
|
|
557
|
+
perform do |facts, bindings|
|
|
558
|
+
fact :risk_score, value: calculate_risk(bindings[:v?])
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
rule "emergency_check", priority: 100 do
|
|
563
|
+
on :risk_score, value: :risk?
|
|
564
|
+
perform do |facts, bindings|
|
|
565
|
+
emergency_shutdown if bindings[:risk?] > 90
|
|
566
|
+
end
|
|
567
|
+
end
|
|
497
568
|
end
|
|
498
569
|
```
|
|
499
570
|
|
|
@@ -506,46 +577,67 @@ require 'minitest/autorun'
|
|
|
506
577
|
require 'kbs'
|
|
507
578
|
|
|
508
579
|
class TestTemperatureRules < Minitest::Test
|
|
509
|
-
def
|
|
510
|
-
|
|
580
|
+
def test_fires_when_temp_exceeds_threshold
|
|
581
|
+
alert_fired = false
|
|
511
582
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
]
|
|
583
|
+
kb = KBS.knowledge_base do
|
|
584
|
+
rule "high_temp_alert" do
|
|
585
|
+
on :sensor, id: :id?, temp: :temp?
|
|
586
|
+
on :threshold, id: :id?, max: :max?
|
|
517
587
|
|
|
518
|
-
|
|
519
|
-
|
|
588
|
+
perform do |facts, bindings|
|
|
589
|
+
alert_fired = true if bindings[:temp?] > bindings[:max?]
|
|
590
|
+
end
|
|
520
591
|
end
|
|
592
|
+
|
|
593
|
+
fact :sensor, id: "bedroom", temp: 30
|
|
594
|
+
fact :threshold, id: "bedroom", max: 25
|
|
595
|
+
run
|
|
521
596
|
end
|
|
522
597
|
|
|
523
|
-
|
|
524
|
-
@alert_fired = false
|
|
598
|
+
assert alert_fired, "Rule should fire when temp > threshold"
|
|
525
599
|
end
|
|
526
600
|
|
|
527
|
-
def
|
|
528
|
-
|
|
529
|
-
@engine.add_fact(:threshold, { id: "bedroom", max: 25 })
|
|
530
|
-
@engine.run
|
|
601
|
+
def test_does_not_fire_when_temp_below_threshold
|
|
602
|
+
alert_fired = false
|
|
531
603
|
|
|
532
|
-
|
|
533
|
-
|
|
604
|
+
kb = KBS.knowledge_base do
|
|
605
|
+
rule "high_temp_alert" do
|
|
606
|
+
on :sensor, id: :id?, temp: :temp?
|
|
607
|
+
on :threshold, id: :id?, max: :max?
|
|
534
608
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
609
|
+
perform do |facts, bindings|
|
|
610
|
+
alert_fired = true if bindings[:temp?] > bindings[:max?]
|
|
611
|
+
end
|
|
612
|
+
end
|
|
539
613
|
|
|
540
|
-
|
|
614
|
+
fact :sensor, id: "bedroom", temp: 20
|
|
615
|
+
fact :threshold, id: "bedroom", max: 25
|
|
616
|
+
run
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
refute alert_fired, "Rule should not fire when temp <= threshold"
|
|
541
620
|
end
|
|
542
621
|
|
|
543
622
|
def test_only_fires_for_matching_sensor
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
623
|
+
alert_fired = false
|
|
624
|
+
|
|
625
|
+
kb = KBS.knowledge_base do
|
|
626
|
+
rule "high_temp_alert" do
|
|
627
|
+
on :sensor, id: :id?, temp: :temp?
|
|
628
|
+
on :threshold, id: :id?, max: :max?
|
|
629
|
+
|
|
630
|
+
perform do |facts, bindings|
|
|
631
|
+
alert_fired = true if bindings[:temp?] > bindings[:max?]
|
|
632
|
+
end
|
|
633
|
+
end
|
|
547
634
|
|
|
548
|
-
|
|
635
|
+
fact :sensor, id: "bedroom", temp: 30
|
|
636
|
+
fact :threshold, id: "kitchen", max: 25
|
|
637
|
+
run
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
refute alert_fired, "Rule should not fire for different sensors"
|
|
549
641
|
end
|
|
550
642
|
end
|
|
551
643
|
```
|
|
@@ -556,23 +648,42 @@ Test multiple rules working together:
|
|
|
556
648
|
|
|
557
649
|
```ruby
|
|
558
650
|
def test_state_machine_workflow
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
651
|
+
kb = KBS.knowledge_base do
|
|
652
|
+
# Add state transition rules
|
|
653
|
+
rule "start_processing" do
|
|
654
|
+
on :order, id: :id?, status: "pending"
|
|
655
|
+
perform do |facts, bindings|
|
|
656
|
+
engine.remove_fact(facts[0])
|
|
657
|
+
fact :order, id: bindings[:id?], status: "processing"
|
|
658
|
+
end
|
|
659
|
+
end
|
|
562
660
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
661
|
+
rule "complete_processing" do
|
|
662
|
+
on :order, id: :id?, status: "processing"
|
|
663
|
+
on :processing_done, order_id: :id?
|
|
664
|
+
perform do |facts, bindings|
|
|
665
|
+
engine.remove_fact(facts[0])
|
|
666
|
+
engine.remove_fact(facts[1])
|
|
667
|
+
fact :order, id: bindings[:id?], status: "completed"
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Add initial state
|
|
672
|
+
fact :order, id: 1, status: "pending"
|
|
673
|
+
run
|
|
674
|
+
end
|
|
566
675
|
|
|
567
676
|
# Should not transition yet
|
|
568
|
-
|
|
677
|
+
order = kb.engine.facts.find { |f| f.type == :order && f[:id] == 1 }
|
|
678
|
+
assert_equal "pending", order[:status]
|
|
569
679
|
|
|
570
680
|
# Trigger transition
|
|
571
|
-
|
|
572
|
-
|
|
681
|
+
kb.fact :processing_done, order_id: 1
|
|
682
|
+
kb.run
|
|
573
683
|
|
|
574
|
-
# Should transition to
|
|
575
|
-
|
|
684
|
+
# Should transition to completed
|
|
685
|
+
order = kb.engine.facts.find { |f| f.type == :order && f[:id] == 1 }
|
|
686
|
+
assert_equal "completed", order[:status]
|
|
576
687
|
end
|
|
577
688
|
```
|
|
578
689
|
|
|
@@ -582,16 +693,28 @@ Test rule invariants:
|
|
|
582
693
|
|
|
583
694
|
```ruby
|
|
584
695
|
def test_no_duplicate_alerts
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
696
|
+
kb = KBS.knowledge_base do
|
|
697
|
+
rule "send_alert_once" do
|
|
698
|
+
on :high_temp, sensor_id: :id?
|
|
699
|
+
without :alert_sent, sensor_id: :id?
|
|
700
|
+
|
|
701
|
+
perform do |facts, bindings|
|
|
702
|
+
send_alert(bindings[:id?])
|
|
703
|
+
fact :alert_sent, sensor_id: bindings[:id?]
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Add facts
|
|
708
|
+
100.times do |i|
|
|
709
|
+
fact :high_temp, sensor_id: i
|
|
710
|
+
end
|
|
589
711
|
|
|
590
|
-
|
|
591
|
-
|
|
712
|
+
# Run engine multiple times
|
|
713
|
+
10.times { run }
|
|
714
|
+
end
|
|
592
715
|
|
|
593
716
|
# Property: At most one alert per sensor
|
|
594
|
-
alert_counts = engine.facts
|
|
717
|
+
alert_counts = kb.engine.facts
|
|
595
718
|
.select { |f| f.type == :alert_sent }
|
|
596
719
|
.group_by { |f| f[:sensor_id] }
|
|
597
720
|
.transform_values(&:count)
|
|
@@ -610,62 +733,81 @@ Negations are expensive:
|
|
|
610
733
|
|
|
611
734
|
```ruby
|
|
612
735
|
# Expensive: 3 negations
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
736
|
+
KBS.knowledge_base do
|
|
737
|
+
rule "many_negations" do
|
|
738
|
+
without :foo, {}
|
|
739
|
+
without :bar, {}
|
|
740
|
+
without :baz, {}
|
|
741
|
+
perform { }
|
|
742
|
+
end
|
|
743
|
+
end
|
|
618
744
|
|
|
619
745
|
# Better: Combine into positive condition
|
|
620
|
-
|
|
746
|
+
KBS.knowledge_base do
|
|
747
|
+
rule "positive_logic" do
|
|
748
|
+
on :conditions_met, {}
|
|
749
|
+
perform { }
|
|
750
|
+
end
|
|
621
751
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
752
|
+
# Add conditions_met fact if foo, bar, baz don't exist
|
|
753
|
+
unless engine.facts.any? { |f| [:foo, :bar, :baz].include?(f.type) }
|
|
754
|
+
fact :conditions_met, {}
|
|
755
|
+
end
|
|
756
|
+
end
|
|
625
757
|
```
|
|
626
758
|
|
|
627
759
|
### Avoid Predicates for Simple Checks
|
|
628
760
|
|
|
629
761
|
```ruby
|
|
630
762
|
# Expensive: Predicate disables network sharing
|
|
631
|
-
KBS
|
|
632
|
-
|
|
633
|
-
|
|
763
|
+
KBS.knowledge_base do
|
|
764
|
+
rule "with_predicate" do
|
|
765
|
+
on :stock, {}, predicate: lambda { |f| f[:symbol] == "AAPL" }
|
|
766
|
+
perform { }
|
|
767
|
+
end
|
|
768
|
+
end
|
|
634
769
|
|
|
635
770
|
# Better: Use pattern matching
|
|
636
|
-
KBS
|
|
771
|
+
KBS.knowledge_base do
|
|
772
|
+
rule "with_pattern" do
|
|
773
|
+
on :stock, symbol: "AAPL"
|
|
774
|
+
perform { }
|
|
775
|
+
end
|
|
776
|
+
end
|
|
637
777
|
```
|
|
638
778
|
|
|
639
779
|
### Cache Computed Values
|
|
640
780
|
|
|
641
781
|
```ruby
|
|
642
782
|
# Bad: Recomputes every time rule fires
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
783
|
+
KBS.knowledge_base do
|
|
784
|
+
rule "check_average" do
|
|
785
|
+
on :sensor, temp: :temp?
|
|
786
|
+
|
|
787
|
+
perform do |facts, bindings|
|
|
788
|
+
avg = compute_expensive_average(engine.facts)
|
|
789
|
+
alert(avg) if avg > threshold
|
|
790
|
+
end
|
|
647
791
|
end
|
|
648
792
|
end
|
|
649
793
|
|
|
650
794
|
# Good: Cache as fact, recompute only when needed
|
|
651
|
-
KBS
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
]
|
|
795
|
+
KBS.knowledge_base do
|
|
796
|
+
rule "update_average", priority: 100 do
|
|
797
|
+
on :sensor, temp: :temp? # Triggers when sensor added
|
|
655
798
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
799
|
+
perform do |facts, bindings|
|
|
800
|
+
avg = compute_expensive_average(engine.facts)
|
|
801
|
+
fact :cached_average, value: avg
|
|
802
|
+
end
|
|
659
803
|
end
|
|
660
|
-
end
|
|
661
804
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
KBS::Condition.new(:cached_average, { value: :avg? })
|
|
665
|
-
]
|
|
805
|
+
rule "check_average", priority: 50 do
|
|
806
|
+
on :cached_average, value: :avg?
|
|
666
807
|
|
|
667
|
-
|
|
668
|
-
|
|
808
|
+
perform do |facts, bindings|
|
|
809
|
+
alert(bindings[:avg?]) if bindings[:avg?] > threshold
|
|
810
|
+
end
|
|
669
811
|
end
|
|
670
812
|
end
|
|
671
813
|
```
|
|
@@ -676,27 +818,27 @@ end
|
|
|
676
818
|
|
|
677
819
|
```ruby
|
|
678
820
|
# Bad: Rule fires itself indefinitely
|
|
679
|
-
KBS
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
]
|
|
821
|
+
KBS.knowledge_base do
|
|
822
|
+
rule "infinite_loop" do
|
|
823
|
+
on :sensor, temp: :temp?
|
|
683
824
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
825
|
+
perform do |facts, bindings|
|
|
826
|
+
# This triggers the rule again!
|
|
827
|
+
fact :sensor, temp: bindings[:temp?] + 1
|
|
828
|
+
end
|
|
687
829
|
end
|
|
688
830
|
end
|
|
689
831
|
|
|
690
832
|
# Fix: Add termination condition
|
|
691
|
-
KBS
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
833
|
+
KBS.knowledge_base do
|
|
834
|
+
rule "limited_increment" do
|
|
835
|
+
on :sensor, temp: :temp?
|
|
836
|
+
without :increment_done, {}
|
|
837
|
+
|
|
838
|
+
perform do |facts, bindings|
|
|
839
|
+
fact :sensor, temp: bindings[:temp?] + 1
|
|
840
|
+
fact :increment_done, {}
|
|
841
|
+
end
|
|
700
842
|
end
|
|
701
843
|
end
|
|
702
844
|
```
|
|
@@ -707,39 +849,56 @@ end
|
|
|
707
849
|
# Bad: Closure captures wrong variable
|
|
708
850
|
rules = []
|
|
709
851
|
%w[sensor1 sensor2 sensor3].each do |sensor|
|
|
710
|
-
rules
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
puts sensor
|
|
852
|
+
# All rules reference same 'sensor' variable (last value!)
|
|
853
|
+
kb = KBS.knowledge_base do
|
|
854
|
+
rule "process_#{sensor}" do
|
|
855
|
+
on :reading, {}
|
|
856
|
+
perform { puts sensor } # Wrong!
|
|
715
857
|
end
|
|
716
858
|
end
|
|
717
859
|
end
|
|
718
860
|
|
|
719
861
|
# Fix: Force closure with parameter
|
|
720
862
|
%w[sensor1 sensor2 sensor3].each do |sensor_name|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
863
|
+
captured_sensor = sensor_name # Force capture
|
|
864
|
+
|
|
865
|
+
kb = KBS.knowledge_base do
|
|
866
|
+
rule "process_#{captured_sensor}" do
|
|
867
|
+
on :reading, {}
|
|
868
|
+
perform { puts captured_sensor } # Correct
|
|
726
869
|
end
|
|
727
870
|
end
|
|
728
871
|
end
|
|
729
872
|
```
|
|
730
873
|
|
|
731
|
-
### 3. Forgetting to Call `
|
|
874
|
+
### 3. Forgetting to Call `run`
|
|
732
875
|
|
|
733
876
|
```ruby
|
|
734
877
|
# Bad: Facts added but never matched
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
878
|
+
kb = KBS.knowledge_base do
|
|
879
|
+
rule "example" do
|
|
880
|
+
on :sensor, temp: :temp?
|
|
881
|
+
on :threshold, max: :max?
|
|
882
|
+
perform { }
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
fact :sensor, temp: 30
|
|
886
|
+
fact :threshold, max: 25
|
|
887
|
+
# Rules never fire!
|
|
888
|
+
end
|
|
738
889
|
|
|
739
890
|
# Good: Run after adding facts
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
891
|
+
kb = KBS.knowledge_base do
|
|
892
|
+
rule "example" do
|
|
893
|
+
on :sensor, temp: :temp?
|
|
894
|
+
on :threshold, max: :max?
|
|
895
|
+
perform { }
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
fact :sensor, temp: 30
|
|
899
|
+
fact :threshold, max: 25
|
|
900
|
+
run # Match and fire rules
|
|
901
|
+
end
|
|
743
902
|
```
|
|
744
903
|
|
|
745
904
|
## Next Steps
|