behavior_machines 0.1.0-arm64-darwin

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a37a3004f0df9f28400a7f8685b3a18ad247b5adb4958483f851c69c8e5d636c
4
+ data.tar.gz: 68aef4f586a4e4c92c98d563964460a9f81ae27479c8feb464bf2a4fc29491a0
5
+ SHA512:
6
+ metadata.gz: bd33ceefa35b451f89cd67284348a7b110a00d93602a9e8b46a1c715454aa6445f75d7e3a618c983f5ea7a3301f68e19d6b17064332e9a8d2983359e648ad2ca
7
+ data.tar.gz: a17b122167f2839e7a7d0c502406cfaff81e2ca305ad4dd5d9b26cf1acc13fb9ebf01fa5ad75e4077297aaa59427476dd3bba4f6d63440fdf0e995894214d5c9
data/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Abdelkader Boudih
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # behavior_machines
2
+
3
+ Behavior trees with explicit halt semantics for Ruby, following the
4
+ behavior-tree pattern. Pure Ruby, with an optional native backend built on the
5
+ [behavior-machines](ext/behavior_machines_native/core) Rust crate.
6
+
7
+ Each tick advances as far as the leaves allow. Every running leaf the tree
8
+ abandons, because a parallel decided, a reactive guard flipped, a timeout
9
+ expired, or the tree was halted, gets `halt` called exactly once, in the same
10
+ call. Leaves can therefore own real resources and release them reliably.
11
+
12
+ Requires Ruby 4.0 or newer.
13
+
14
+ ```ruby
15
+ require 'behavior_machines'
16
+
17
+ BM = BehaviorMachines
18
+
19
+ class BatteryLow
20
+ def tick(robot, _now) = robot.battery < 20 ? :success : :failure
21
+ end
22
+
23
+ class Charge
24
+ def tick(robot, _now)
25
+ return :success if robot.battery == 100
26
+
27
+ robot.charging = true
28
+ :running
29
+ end
30
+
31
+ def halt(robot) = robot.charging = false
32
+ end
33
+
34
+ class Patrol
35
+ def tick(_robot, _now) = :running
36
+ end
37
+
38
+ tree = BM::Tree.new(
39
+ BM.fallback(
40
+ BM.sequence(BM.leaf(BatteryLow.new), BM.leaf(Charge.new)),
41
+ BM.leaf(Patrol.new)
42
+ )
43
+ )
44
+
45
+ tree.tick(robot) # => :running, charging
46
+ tree.halt(robot) # Charge#halt runs exactly once
47
+ ```
48
+
49
+ ## Nodes
50
+
51
+ - **Leaves**: any object responding to `tick(ctx, now)`, returning
52
+ `:success`, `:failure`, or `:running`, and optionally `halt(ctx)`. Built-in:
53
+ `BM.sleep(seconds)`, `BM.always_success`, `BM.always_failure`.
54
+ - **Control**: `BM.sequence`, `BM.reactive_sequence`, `BM.fallback`,
55
+ `BM.reactive_fallback`, `BM.parallel(*children, success: :all, failure: 1)`,
56
+ `BM.if_then_else(condition, then_branch, otherwise = nil)`,
57
+ `BM.while_do_else(condition, body, otherwise = nil)`.
58
+ - **Decorators**, on any node: `inverter`, `force_success`, `force_failure`,
59
+ `timeout(seconds)`, `repeat(limit)`, `retry_until_successful(limit)`,
60
+ `keep_running_until_failure`. Limits are an Integer or `:unlimited`; looping
61
+ decorators finish at most one child run per tick.
62
+
63
+ Node definitions are immutable `Data` values; `BM::Tree.new` validates and
64
+ compiles them.
65
+
66
+ ## Time
67
+
68
+ Durations are seconds. The clock is an Integer count of nanoseconds, passed as
69
+ `now:` and defaulting to `BehaviorMachines.now` (monotonic).
70
+ `tree.next_deadline` returns when a running `sleep` or `timeout` next needs a
71
+ tick, so a host can sleep until then.
72
+
73
+ ## Errors
74
+
75
+ If a leaf or observer raises during a tick, no more leaf code runs in that
76
+ tick: active leaves stay active and idle ones don't start. Every leaf still
77
+ active is then halted, without observer reports, and the first error
78
+ propagates. The raising leaf counts as finished. During a halt, every active
79
+ leaf is halted even if a leaf's `halt` or the observer raises, and the first
80
+ error propagates afterwards.
81
+
82
+ A leaf returning anything but the three statuses raises
83
+ `BehaviorMachines::InvalidStatusError`. A leaf calling back into the tree
84
+ that is ticking it raises `BehaviorMachines::ReentrantCallError`. Non-local
85
+ exits from a leaf, such as `throw` or `break`, are not supported.
86
+
87
+ Both backends follow these rules identically; the test suite compares them
88
+ side by side, including with raising leaves.
89
+
90
+ ## Observers
91
+
92
+ `tree.tick(ctx, observer:)` reports `ticked(node_id, status)` and
93
+ `halted(node_id)` for every node, children before parents. Node ids number the
94
+ definition in pre-order.
95
+
96
+ ## state_machines
97
+
98
+ `BehaviorMachines::Machine` has leaves for objects powered by the
99
+ [state_machines](https://github.com/state-machines/state_machines) gem:
100
+
101
+ ```ruby
102
+ job_leaves = BM.sequence(
103
+ BM.leaf(BM::Machine::Fire.new(:job, :start)),
104
+ BM.leaf(BM::Machine::WaitFor.new(:job, success: :completed, failure: :failed, on_halt: :cancel))
105
+ )
106
+ ```
107
+
108
+ `InState` checks the state, `Fire` fires an event, and `WaitFor` waits for a
109
+ machine something else drives, firing `on_halt` if the tree abandons it.
110
+
111
+ ## Native backend
112
+
113
+ The native backend runs the same trees on the Rust engine. It is opt-in:
114
+
115
+ ```console
116
+ $ bundle exec rake compile
117
+ $ BEHAVIOR_MACHINES_NATIVE=1 ruby app.rb
118
+ ```
119
+
120
+ `BM::Tree.new(root, backend: :ruby | :native | :auto)` picks one explicitly.
121
+ Leaves stay Ruby, so the gain is limited to the tree walk itself. The test
122
+ suite runs against both backends (`bundle exec rake test:both`).
123
+
124
+ ## License
125
+
126
+ BSD-3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # Validates a node definition and lowers it to the plain nested-Array spec
5
+ # that both backends build from, so they always agree on shape and on the
6
+ # pre-order numbering observers see.
7
+ module Definition
8
+ # The clock is an unsigned 64-bit count of nanoseconds.
9
+ MAX_NANOS = (2**64) - 1
10
+
11
+ module_function
12
+
13
+ def compile(node)
14
+ case node
15
+ when Node::Leaf then leaf(node.leaf)
16
+ when Node::AlwaysSuccess then %i[constant success]
17
+ when Node::AlwaysFailure then %i[constant failure]
18
+ when Node::Sleep then [:sleep, nanos(node.seconds, 'Sleep')]
19
+ when Node::Sequence then ordered(node.children, 'Sequence', :success, false)
20
+ when Node::ReactiveSequence then ordered(node.children, 'ReactiveSequence', :success, true)
21
+ when Node::Fallback then ordered(node.children, 'Fallback', :failure, false)
22
+ when Node::ReactiveFallback then ordered(node.children, 'ReactiveFallback', :failure, true)
23
+ when Node::Parallel then parallel(node)
24
+ when Node::IfThenElse then conditional(node.condition, node.then_branch, node.otherwise, false)
25
+ when Node::WhileDoElse then conditional(node.condition, node.body, node.otherwise, true)
26
+ when Node::Inverter then [:map, compile(node.child), :inverter]
27
+ when Node::ForceSuccess then [:map, compile(node.child), :force_success]
28
+ when Node::ForceFailure then [:map, compile(node.child), :force_failure]
29
+ when Node::Timeout then [:timeout, nanos(node.seconds, 'Timeout'), compile(node.child)]
30
+ when Node::Repeat then [:loop, compile(node.child), :success, limit(node.limit, 'Repeat')]
31
+ when Node::RetryUntilSuccessful
32
+ [:loop, compile(node.child), :failure, limit(node.limit, 'RetryUntilSuccessful')]
33
+ when Node::KeepRunningUntilFailure then [:loop, compile(node.child), :success, nil]
34
+ else raise BuildError, "#{node.inspect} is not a BehaviorMachines node"
35
+ end
36
+ end
37
+
38
+ def leaf(object)
39
+ raise BuildError, "leaf #{object.inspect} must respond to tick(ctx, now)" unless object.respond_to?(:tick)
40
+
41
+ [:leaf, object, object.respond_to?(:halt)]
42
+ end
43
+
44
+ def ordered(children, name, advance_on, reactive)
45
+ [:ordered, children_of(children, name), advance_on, reactive]
46
+ end
47
+
48
+ def parallel(node)
49
+ children = children_of(node.children, 'Parallel')
50
+ [:parallel, children, threshold(node.success, children.size), threshold(node.failure, children.size)]
51
+ end
52
+
53
+ def conditional(condition, branch, otherwise, reactive)
54
+ # Only nil means absent; anything else must be a node.
55
+ [:conditional, compile(condition), compile(branch), otherwise.nil? ? nil : compile(otherwise), reactive]
56
+ end
57
+
58
+ def children_of(children, name)
59
+ raise BuildError, "#{name} needs at least one child" if children.empty?
60
+
61
+ children.map { |child| compile(child) }
62
+ end
63
+
64
+ def threshold(value, children)
65
+ return children if value == :all
66
+ raise BuildError, "Parallel threshold must be :all or an Integer, got #{value.inspect}" unless value.is_a?(Integer)
67
+ raise BuildError, "Parallel threshold #{value} must be between 1 and #{children}" unless value.between?(1, children)
68
+
69
+ value
70
+ end
71
+
72
+ def nanos(seconds, name)
73
+ unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && !seconds.negative?
74
+ raise BuildError, "#{name} needs a finite, non-negative number of seconds, got #{seconds.inspect}"
75
+ end
76
+
77
+ nanos = (seconds.to_r * 1_000_000_000).round
78
+ raise BuildError, "#{name} of #{seconds} seconds exceeds the 64-bit nanosecond clock" if nanos > MAX_NANOS
79
+
80
+ nanos
81
+ end
82
+
83
+ def limit(value, name)
84
+ return nil if value == :unlimited
85
+ return value if value.is_a?(Integer) && value.between?(0, MAX_NANOS)
86
+
87
+ raise BuildError, "#{name} limit must be :unlimited or an Integer in 0..2**64-1, got #{value.inspect}"
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # The pure-Ruby backend. It mirrors the Rust engine node for node; the same
5
+ # test suite runs against both backends.
6
+ module Engine
7
+ MAX_TIME = Definition::MAX_NANOS
8
+
9
+ # Discards observer reports.
10
+ module NullObserver
11
+ def self.ticked(_id, _status) = nil
12
+ def self.halted(_id) = nil
13
+ end
14
+
15
+ # One tick or halt. `errors` collects what leaves and observers raise; once
16
+ # it holds one, no more leaf ticks or observer reports run this call.
17
+ Run = Data.define(:ctx, :observer, :errors) do
18
+ def self.start(ctx, observer) = new(ctx, observer || NullObserver, [])
19
+
20
+ def failed? = !errors.empty?
21
+
22
+ def record(error)
23
+ errors << error
24
+ nil
25
+ end
26
+
27
+ def report_ticked(id, status) = report { observer.ticked(id, status) }
28
+ def report_halted(id) = report { observer.halted(id) }
29
+
30
+ private
31
+
32
+ def report
33
+ return if failed?
34
+
35
+ yield
36
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, raised after cleanup
37
+ record(e)
38
+ end
39
+ end
40
+
41
+ # A deadline past the end of the clock never fires.
42
+ def self.deadline_after(now, nanos)
43
+ deadline = now + nanos
44
+ deadline > MAX_TIME ? nil : deadline
45
+ end
46
+
47
+ def self.reached?(deadline, now) = !deadline.nil? && now >= deadline
48
+
49
+ # Compiled tree plus its execution state.
50
+ class Tree
51
+ def initialize(spec)
52
+ @root = Builder.new.build(spec)
53
+ end
54
+
55
+ # Once a leaf or observer raises, the rest of the tick runs no Ruby: an
56
+ # active leaf stays active and an idle one does not start. Then every
57
+ # leaf still active is halted, without observer reports, and the first
58
+ # error propagates. Errors raised during that cleanup are dropped.
59
+ def tick(ctx, now, observer)
60
+ run = Run.start(ctx, observer)
61
+ status = @root.tick(run, now)
62
+ return status unless run.failed?
63
+
64
+ @root.halt(Run.start(ctx, nil))
65
+ raise run.errors.first
66
+ end
67
+
68
+ # Every active leaf is halted even if one, or the observer, raises; the
69
+ # first error propagates afterwards.
70
+ def halt(ctx, observer)
71
+ run = Run.start(ctx, observer)
72
+ @root.halt(run)
73
+ raise run.errors.first if run.failed?
74
+ end
75
+
76
+ def running? = @root.running?
77
+ def next_deadline = @root.next_deadline
78
+ end
79
+
80
+ # Builds slots from a spec, numbering nodes in pre-order.
81
+ class Builder
82
+ def initialize
83
+ @next_id = 0
84
+ end
85
+
86
+ def build(spec)
87
+ id = @next_id
88
+ @next_id += 1
89
+ kind, *args = spec
90
+ case kind
91
+ when :leaf then LeafSlot.new(id, *args)
92
+ when :constant then ConstantSlot.new(id, *args)
93
+ when :sleep then SleepSlot.new(id, *args)
94
+ when :ordered then ordered(id, *args)
95
+ when :parallel then parallel(id, *args)
96
+ when :conditional then conditional(id, *args)
97
+ when :map then MapSlot.new(id, build(args[0]), args[1])
98
+ when :timeout then TimeoutSlot.new(id, args[0], build(args[1]))
99
+ when :loop then LoopSlot.new(id, build(args[0]), args[1], args[2])
100
+ else raise ArgumentError, "unknown spec #{kind.inspect}"
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def ordered(id, children, advance_on, reactive)
107
+ (reactive ? ReactiveSlot : OrderedSlot).new(id, children.map { |child| build(child) }, advance_on)
108
+ end
109
+
110
+ def parallel(id, children, success, failure)
111
+ ParallelSlot.new(id, children.map { |child| build(child) }, success, failure)
112
+ end
113
+
114
+ def conditional(id, condition, branch, otherwise, reactive)
115
+ condition = build(condition)
116
+ branch = build(branch)
117
+ otherwise &&= build(otherwise)
118
+ (reactive ? WhileDoElseSlot : IfThenElseSlot).new(id, condition, branch, otherwise)
119
+ end
120
+ end
121
+
122
+ # One compiled node. `@running` is set while its last tick returned
123
+ # :running and it has not been halted since; only running slots halt.
124
+ class Slot
125
+ def initialize(id)
126
+ @id = id
127
+ @running = false
128
+ end
129
+
130
+ def running? = @running
131
+
132
+ def tick(run, now)
133
+ status = step(run, now, !@running)
134
+ @running = status == :running
135
+ run.report_ticked(@id, status)
136
+ status
137
+ end
138
+
139
+ def halt(run)
140
+ return unless @running
141
+
142
+ @running = false
143
+ stop(run)
144
+ run.report_halted(@id)
145
+ end
146
+
147
+ def next_deadline = @running ? running_deadline : nil
148
+
149
+ private
150
+
151
+ def stop(_run) = nil
152
+ def running_deadline = nil
153
+ def halt_all(slots, run) = slots.each { |slot| slot.halt(run) }
154
+ def earliest(deadlines) = deadlines.compact.min
155
+ end
156
+
157
+ # A user leaf.
158
+ class LeafSlot < Slot
159
+ def initialize(id, leaf, halts)
160
+ super(id)
161
+ @leaf = leaf
162
+ @halts = halts
163
+ end
164
+
165
+ private
166
+
167
+ # After an error no more leaf code runs this call: an active leaf stays
168
+ # active (so cleanup halts it) and an idle one does not start. A leaf
169
+ # that raises counts as finished.
170
+ def step(run, now, starting)
171
+ return starting ? :failure : :running if run.failed?
172
+
173
+ BehaviorMachines.check_status(@leaf.tick(run.ctx, now), @leaf)
174
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, raised after cleanup
175
+ run.record(e)
176
+ :failure
177
+ end
178
+
179
+ def stop(run)
180
+ @leaf.halt(run.ctx) if @halts
181
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded; the halt continues
182
+ run.record(e)
183
+ end
184
+ end
185
+
186
+ # AlwaysSuccess and AlwaysFailure.
187
+ class ConstantSlot < Slot
188
+ def initialize(id, status)
189
+ super(id)
190
+ @status = status
191
+ end
192
+
193
+ private
194
+
195
+ def step(_run, _now, _starting) = @status
196
+ end
197
+
198
+ class SleepSlot < Slot
199
+ def initialize(id, nanos)
200
+ super(id)
201
+ @nanos = nanos
202
+ @deadline = nil
203
+ end
204
+
205
+ private
206
+
207
+ def step(_run, now, starting)
208
+ @deadline = Engine.deadline_after(now, @nanos) if starting
209
+ Engine.reached?(@deadline, now) ? :success : :running
210
+ end
211
+
212
+ def running_deadline = @deadline
213
+ end
214
+
215
+ # Sequence and Fallback with memory: resume the running child.
216
+ class OrderedSlot < Slot
217
+ def initialize(id, children, advance_on)
218
+ super(id)
219
+ @children = children
220
+ @advance_on = advance_on
221
+ @cursor = 0
222
+ end
223
+
224
+ private
225
+
226
+ def step(run, now, _starting)
227
+ while (child = @children[@cursor])
228
+ status = child.tick(run, now)
229
+ return :running if status == :running
230
+ return finish(status) unless status == @advance_on
231
+
232
+ @cursor += 1
233
+ end
234
+ finish(@advance_on)
235
+ end
236
+
237
+ def finish(status)
238
+ @cursor = 0
239
+ status
240
+ end
241
+
242
+ def stop(run)
243
+ halt_all(@children, run)
244
+ @cursor = 0
245
+ end
246
+
247
+ def running_deadline = earliest(@children.map(&:next_deadline))
248
+ end
249
+
250
+ # ReactiveSequence and ReactiveFallback: re-walk from the first child.
251
+ class ReactiveSlot < Slot
252
+ def initialize(id, children, advance_on)
253
+ super(id)
254
+ @children = children
255
+ @advance_on = advance_on
256
+ end
257
+
258
+ private
259
+
260
+ def step(run, now, _starting)
261
+ @children.each_with_index do |child, index|
262
+ status = child.tick(run, now)
263
+ next if status == @advance_on
264
+
265
+ halt_all(@children.drop(index + 1), run)
266
+ return status
267
+ end
268
+ @advance_on
269
+ end
270
+
271
+ def stop(run) = halt_all(@children, run)
272
+ def running_deadline = earliest(@children.map(&:next_deadline))
273
+ end
274
+
275
+ class ParallelSlot < Slot
276
+ def initialize(id, children, success, failure)
277
+ super(id)
278
+ @children = children
279
+ @success = success
280
+ @failure = failure
281
+ @outcomes = Array.new(children.size)
282
+ end
283
+
284
+ private
285
+
286
+ def step(run, now, _starting)
287
+ decided = decide(run, now)
288
+ return :running unless decided
289
+
290
+ halt_all(@children, run)
291
+ @outcomes.fill(nil)
292
+ decided
293
+ end
294
+
295
+ # Children decide in order, so the leftmost wins ties.
296
+ def decide(run, now)
297
+ succeeded = @outcomes.count(:success)
298
+ failed = @outcomes.count(:failure)
299
+ @children.each_with_index do |child, index|
300
+ next if @outcomes[index]
301
+
302
+ status = child.tick(run, now)
303
+ next if status == :running
304
+
305
+ @outcomes[index] = status
306
+ status == :success ? succeeded += 1 : failed += 1
307
+ return :success if succeeded >= @success
308
+ return :failure if failed >= @failure || @children.size - failed < @success
309
+ end
310
+ nil
311
+ end
312
+
313
+ def stop(run)
314
+ halt_all(@children, run)
315
+ @outcomes.fill(nil)
316
+ end
317
+
318
+ def running_deadline = earliest(@children.map(&:next_deadline))
319
+ end
320
+
321
+ # Shared by IfThenElse and WhileDoElse.
322
+ class ConditionalSlot < Slot
323
+ def initialize(id, condition, branch, otherwise)
324
+ super(id)
325
+ @condition = condition
326
+ @branch = branch
327
+ @otherwise = otherwise
328
+ end
329
+
330
+ private
331
+
332
+ def stop(run)
333
+ [@condition, @branch, @otherwise].compact.each { |slot| slot.halt(run) }
334
+ end
335
+
336
+ def running_deadline = earliest([@condition, @branch, @otherwise].compact.map(&:next_deadline))
337
+ end
338
+
339
+ class IfThenElseSlot < ConditionalSlot
340
+ def initialize(...)
341
+ super
342
+ @selected = nil
343
+ end
344
+
345
+ private
346
+
347
+ def step(run, now, _starting)
348
+ unless @selected
349
+ case @condition.tick(run, now)
350
+ when :running then return :running
351
+ when :success then @selected = :branch
352
+ else @selected = :otherwise
353
+ end
354
+ end
355
+ chosen = @selected == :branch ? @branch : @otherwise
356
+ return finish(:failure) unless chosen
357
+
358
+ status = chosen.tick(run, now)
359
+ status == :running ? status : finish(status)
360
+ end
361
+
362
+ def finish(status)
363
+ @selected = nil
364
+ status
365
+ end
366
+
367
+ def stop(run)
368
+ super
369
+ @selected = nil
370
+ end
371
+ end
372
+
373
+ # While the condition runs the branches are paused: neither ticked nor
374
+ # halted. Otherwise the unselected branch is halted before the other ticks.
375
+ class WhileDoElseSlot < ConditionalSlot
376
+ private
377
+
378
+ def step(run, now, _starting)
379
+ case @condition.tick(run, now)
380
+ when :running then :running
381
+ when :success
382
+ @otherwise&.halt(run)
383
+ @branch.tick(run, now)
384
+ else
385
+ @branch.halt(run)
386
+ @otherwise ? @otherwise.tick(run, now) : :failure
387
+ end
388
+ end
389
+
390
+ # A paused branch's timers cannot act until the condition finishes.
391
+ def running_deadline = @condition.running? ? @condition.next_deadline : super
392
+ end
393
+
394
+ # Inverter, ForceSuccess, and ForceFailure.
395
+ class MapSlot < Slot
396
+ def initialize(id, child, map)
397
+ super(id)
398
+ @child = child
399
+ @map = map
400
+ end
401
+
402
+ private
403
+
404
+ def step(run, now, _starting)
405
+ status = @child.tick(run, now)
406
+ return status if status == :running
407
+
408
+ case @map
409
+ when :inverter then status == :success ? :failure : :success
410
+ when :force_success then :success
411
+ else :failure
412
+ end
413
+ end
414
+
415
+ def stop(run) = @child.halt(run)
416
+ def running_deadline = @child.next_deadline
417
+ end
418
+
419
+ # The child only ticks strictly before the deadline.
420
+ class TimeoutSlot < Slot
421
+ def initialize(id, nanos, child)
422
+ super(id)
423
+ @nanos = nanos
424
+ @child = child
425
+ @deadline = nil
426
+ end
427
+
428
+ private
429
+
430
+ def step(run, now, starting)
431
+ @deadline = Engine.deadline_after(now, @nanos) if starting
432
+ if Engine.reached?(@deadline, now)
433
+ @child.halt(run)
434
+ return :failure
435
+ end
436
+ @child.tick(run, now)
437
+ end
438
+
439
+ def stop(run) = @child.halt(run)
440
+ def running_deadline = earliest([@deadline, @child.next_deadline])
441
+ end
442
+
443
+ # Repeat, RetryUntilSuccessful, and KeepRunningUntilFailure: at most one
444
+ # finished child run per tick.
445
+ class LoopSlot < Slot
446
+ def initialize(id, child, again_on, limit)
447
+ super(id)
448
+ @child = child
449
+ @again_on = again_on
450
+ @limit = limit
451
+ @runs = 0
452
+ end
453
+
454
+ private
455
+
456
+ def step(run, now, _starting)
457
+ return @again_on if @limit&.zero?
458
+
459
+ status = @child.tick(run, now)
460
+ return status if status == :running
461
+ return finish(status) unless status == @again_on
462
+ return :running if @limit.nil?
463
+ return finish(status) if @runs + 1 == @limit
464
+
465
+ @runs += 1
466
+ :running
467
+ end
468
+
469
+ def finish(status)
470
+ @runs = 0
471
+ status
472
+ end
473
+
474
+ def stop(run)
475
+ @child.halt(run)
476
+ @runs = 0
477
+ end
478
+
479
+ def running_deadline = @child.next_deadline
480
+ end
481
+ end
482
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # Base class for errors raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # A node definition cannot be compiled into a tree.
8
+ class BuildError < Error; end
9
+
10
+ # A leaf's tick returned something other than :success, :failure, or :running.
11
+ class InvalidStatusError < Error; end
12
+
13
+ # A leaf called Tree#tick or Tree#halt on the tree that is ticking it.
14
+ class ReentrantCallError < Error; end
15
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # Leaves that drive objects powered by the state_machines gem, through the
5
+ # helpers it generates for every machine: `<machine>_name` and
6
+ # `fire_<machine>_event`.
7
+ #
8
+ # Each leaf finds its object in the tree context with `access`: a Symbol
9
+ # naming a method on the context, or anything responding to call(ctx).
10
+ module Machine
11
+ def self.accessor(access)
12
+ case access
13
+ when Symbol, String then ->(ctx) { ctx.public_send(access) }
14
+ else
15
+ raise ArgumentError, "access must be a Symbol or respond to call, got #{access.inspect}" unless access.respond_to?(:call)
16
+
17
+ access
18
+ end
19
+ end
20
+
21
+ # Succeeds while the machine is in one of `states`, fails otherwise.
22
+ class InState
23
+ def initialize(access, *states, machine: :state)
24
+ raise ArgumentError, 'InState needs at least one state' if states.empty?
25
+
26
+ @access = Machine.accessor(access)
27
+ @states = states.map(&:to_sym).freeze
28
+ @reader = :"#{machine}_name"
29
+ end
30
+
31
+ def tick(ctx, _now) = @states.include?(@access.call(ctx).public_send(@reader)) ? :success : :failure
32
+ end
33
+
34
+ # Fires `event` each tick: succeeds if the machine accepts it, fails if it
35
+ # is rejected.
36
+ class Fire
37
+ def initialize(access, event, machine: :state)
38
+ @access = Machine.accessor(access)
39
+ @event = event.to_sym
40
+ @fire = :"fire_#{machine}_event"
41
+ end
42
+
43
+ def tick(ctx, _now) = @access.call(ctx).public_send(@fire, @event) ? :success : :failure
44
+ end
45
+
46
+ # Waits on a machine something else drives: succeeds in a `success` state,
47
+ # fails in a `failure` state, runs otherwise. If the tree abandons it,
48
+ # fires `on_halt` (typically :cancel).
49
+ class WaitFor
50
+ def initialize(access, success:, failure: [], on_halt: nil, machine: :state)
51
+ @access = Machine.accessor(access)
52
+ @success = Array(success).map(&:to_sym).freeze
53
+ @failure = Array(failure).map(&:to_sym).freeze
54
+ @on_halt = on_halt&.to_sym
55
+ @reader = :"#{machine}_name"
56
+ @fire = :"fire_#{machine}_event"
57
+ end
58
+
59
+ def tick(ctx, _now)
60
+ state = @access.call(ctx).public_send(@reader)
61
+ return :success if @success.include?(state)
62
+ return :failure if @failure.include?(state)
63
+
64
+ :running
65
+ end
66
+
67
+ # A rejected event means the machine already left the states that accept
68
+ # it, e.g. a job that completed after the last tick. The tree abandons
69
+ # the leaf either way, so the result is deliberately ignored.
70
+ def halt(ctx)
71
+ @access.call(ctx).public_send(@fire, @on_halt) if @on_halt
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module BehaviorMachines
6
+ # Loads the optional native backend. It is opt-in: set
7
+ # BEHAVIOR_MACHINES_NATIVE=1 before requiring behavior_machines.
8
+ module NativeExtension
9
+ class << self
10
+ # Why the last load attempt failed, if it did.
11
+ attr_reader :error
12
+
13
+ def load!
14
+ return @loaded if defined?(@loaded)
15
+ return @loaded = false unless ENV['BEHAVIOR_MACHINES_NATIVE'] == '1'
16
+
17
+ errors = []
18
+ candidates.each do |path|
19
+ require path
20
+ return @loaded = true
21
+ rescue LoadError => e
22
+ errors << "#{path}: #{e.message}"
23
+ end
24
+ @error = errors.join(' | ')
25
+ @loaded = false
26
+ end
27
+
28
+ def loaded? = load!
29
+
30
+ private
31
+
32
+ # Prefer a build matching this Ruby version and platform.
33
+ def candidates
34
+ dlext = RbConfig::CONFIG['DLEXT']
35
+ root = File.expand_path('..', __dir__)
36
+ builds = Dir.glob(File.join(root, 'behavior_machines_native', '**', "behavior_machines_native.#{dlext}"))
37
+ ranked = builds.sort_by do |path|
38
+ score = 0
39
+ score -= 2 if path.include?(RbConfig::CONFIG['ruby_version'])
40
+ score -= 1 if path.include?(RbConfig::CONFIG['arch'])
41
+ [score, path]
42
+ end
43
+ paths = ranked.map { |path| path.delete_prefix("#{root}/").delete_suffix(".#{dlext}") }
44
+ (paths + ['behavior_machines_native/behavior_machines_native']).uniq
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # Immutable node definitions. Build them with the module functions on
5
+ # BehaviorMachines (BehaviorMachines.sequence, .parallel, ...) and the
6
+ # decorator methods every node responds to, then compile with Tree.new.
7
+ #
8
+ # Names and semantics follow the behavior-tree pattern, matching the
9
+ # behavior-machines Rust crate.
10
+ module Node
11
+ # Decorators available on every node.
12
+ module Decorators
13
+ def inverter = Inverter.new(child: self)
14
+ def force_success = ForceSuccess.new(child: self)
15
+ def force_failure = ForceFailure.new(child: self)
16
+ def timeout(seconds) = Timeout.new(seconds:, child: self)
17
+ def repeat(limit) = Repeat.new(limit:, child: self)
18
+ def retry_until_successful(limit) = RetryUntilSuccessful.new(limit:, child: self)
19
+ def keep_running_until_failure = KeepRunningUntilFailure.new(child: self)
20
+ end
21
+
22
+ def self.define(*members) = Data.define(*members) { include Decorators }
23
+ private_class_method :define
24
+
25
+ Leaf = define(:leaf)
26
+ AlwaysSuccess = define
27
+ AlwaysFailure = define
28
+ Sleep = define(:seconds)
29
+ Sequence = define(:children)
30
+ ReactiveSequence = define(:children)
31
+ Fallback = define(:children)
32
+ ReactiveFallback = define(:children)
33
+ Parallel = define(:children, :success, :failure)
34
+ IfThenElse = define(:condition, :then_branch, :otherwise)
35
+ WhileDoElse = define(:condition, :body, :otherwise)
36
+ Inverter = define(:child)
37
+ ForceSuccess = define(:child)
38
+ ForceFailure = define(:child)
39
+ Timeout = define(:seconds, :child)
40
+ Repeat = define(:limit, :child)
41
+ RetryUntilSuccessful = define(:limit, :child)
42
+ KeepRunningUntilFailure = define(:child)
43
+ end
44
+
45
+ class << self
46
+ # A condition check or action: any object responding to tick(ctx, now)
47
+ # and, optionally, halt(ctx).
48
+ def leaf(leaf) = Node::Leaf.new(leaf:)
49
+
50
+ # Succeeds immediately.
51
+ def always_success = Node::AlwaysSuccess.new
52
+
53
+ # Fails immediately.
54
+ def always_failure = Node::AlwaysFailure.new
55
+
56
+ # Runs until `seconds` have passed since it started, then succeeds.
57
+ def sleep(seconds) = Node::Sleep.new(seconds:)
58
+
59
+ # Ticks children in order; fails on the first failure, succeeds once all
60
+ # succeed. A running child is resumed on the next tick.
61
+ def sequence(*children) = Node::Sequence.new(children: children.freeze)
62
+
63
+ # A sequence that restarts from its first child every tick, halting a
64
+ # running later child once an earlier one stops succeeding.
65
+ def reactive_sequence(*children) = Node::ReactiveSequence.new(children: children.freeze)
66
+
67
+ # Ticks children in order; succeeds on the first success, fails once all
68
+ # fail. A running child is resumed on the next tick.
69
+ def fallback(*children) = Node::Fallback.new(children: children.freeze)
70
+
71
+ # A fallback that restarts from its first child every tick, so a
72
+ # higher-priority child preempts (halts) a running lower-priority one.
73
+ def reactive_fallback(*children) = Node::ReactiveFallback.new(children: children.freeze)
74
+
75
+ # Ticks every unfinished child each tick. Succeeds once `success` children
76
+ # succeeded; fails once `failure` failed or success became unreachable.
77
+ # Thresholds are :all or an Integer; by default every child must succeed
78
+ # and one failure fails the node.
79
+ def parallel(*children, success: :all, failure: 1)
80
+ Node::Parallel.new(children: children.freeze, success:, failure:)
81
+ end
82
+
83
+ # Ticks `condition` until it finishes, then runs `then_branch` or
84
+ # `otherwise`. Without `otherwise`, a failed condition fails the node.
85
+ def if_then_else(condition, then_branch, otherwise = nil)
86
+ Node::IfThenElse.new(condition:, then_branch:, otherwise:)
87
+ end
88
+
89
+ # The reactive if_then_else: re-checks `condition` every tick, pausing the
90
+ # branches while it runs and halting the unselected branch when it flips.
91
+ def while_do_else(condition, body, otherwise = nil)
92
+ Node::WhileDoElse.new(condition:, body:, otherwise:)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ # A compiled behavior tree and its execution state.
5
+ #
6
+ # Each tick advances as far as the leaves allow; looping decorators finish
7
+ # at most one child run per tick. When the tree abandons a running node,
8
+ # every active leaf under it is halted exactly once before the call returns.
9
+ #
10
+ # Leaves are objects responding to `tick(ctx, now)`, returning :success,
11
+ # :failure, or :running, and optionally `halt(ctx)`.
12
+ #
13
+ # If a leaf or observer raises during a tick, the rest of that tick runs no
14
+ # leaf code (active leaves stay active, idle ones do not start), every leaf
15
+ # still active is then halted without observer reports, and the first error
16
+ # propagates. The raising leaf counts as finished. During a halt, every
17
+ # active leaf is halted even if a leaf's halt or the observer raises; the
18
+ # first error propagates afterwards. Non-local exits from a leaf, such as
19
+ # `throw` or `break`, are not supported.
20
+ #
21
+ # Time is an Integer count of nanoseconds on a monotonic clock, passed as
22
+ # `now:` (default BehaviorMachines.now). Durations in node definitions are
23
+ # seconds.
24
+ class Tree
25
+ attr_reader :backend
26
+
27
+ # Compiles `root`. `backend:` is :ruby, :native, or :auto (native when the
28
+ # extension is loaded, see BehaviorMachines.native_available?).
29
+ def initialize(root, backend: :auto)
30
+ spec = Definition.compile(root)
31
+ @backend = BehaviorMachines.resolve_backend(backend)
32
+ @engine = @backend == :native ? BehaviorMachinesNative::Tree.new(spec) : Engine::Tree.new(spec)
33
+ @busy = false
34
+ end
35
+
36
+ # Ticks once and returns the root's status. `observer` may respond to
37
+ # `ticked(node_id, status)` and `halted(node_id)`; node ids number the
38
+ # definition in pre-order, and children report before their parent.
39
+ def tick(ctx = nil, now: BehaviorMachines.now, observer: nil)
40
+ now = BehaviorMachines.check_time(now)
41
+ exclusive { @engine.tick(ctx, now, Observers.wrap(observer)) }
42
+ end
43
+
44
+ # Halts every active leaf and resets the tree. Does nothing if not running.
45
+ def halt(ctx = nil, observer: nil)
46
+ exclusive { @engine.halt(ctx, Observers.wrap(observer)) }
47
+ nil
48
+ end
49
+
50
+ # Whether the last tick returned :running and the tree was not halted since.
51
+ def running?
52
+ check_idle
53
+ @engine.running?
54
+ end
55
+
56
+ # The earliest time, in nanoseconds, a running Sleep or Timeout will fire.
57
+ def next_deadline
58
+ check_idle
59
+ @engine.next_deadline
60
+ end
61
+
62
+ private
63
+
64
+ def check_idle
65
+ raise ReentrantCallError, 'a leaf called back into the tree that is ticking it' if @busy
66
+ end
67
+
68
+ def exclusive
69
+ check_idle
70
+
71
+ begin
72
+ @busy = true
73
+ yield
74
+ ensure
75
+ @busy = false
76
+ end
77
+ end
78
+ end
79
+
80
+ # Normalizes user observers so engines can call both hooks unconditionally.
81
+ module Observers
82
+ # Forwards only the hooks the wrapped observer implements.
83
+ Partial = Data.define(:target) do
84
+ def ticked(id, status) = (target.ticked(id, status) if target.respond_to?(:ticked))
85
+ def halted(id) = (target.halted(id) if target.respond_to?(:halted))
86
+ end
87
+
88
+ def self.wrap(observer)
89
+ return nil if observer.nil?
90
+ return observer if observer.respond_to?(:ticked) && observer.respond_to?(:halted)
91
+
92
+ Partial.new(observer)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorMachines
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'behavior_machines/version'
4
+ require_relative 'behavior_machines/errors'
5
+ require_relative 'behavior_machines/node'
6
+ require_relative 'behavior_machines/definition'
7
+ require_relative 'behavior_machines/engine'
8
+ require_relative 'behavior_machines/tree'
9
+ require_relative 'behavior_machines/machine'
10
+ require_relative 'behavior_machines/native_extension'
11
+
12
+ # Behavior trees with explicit halt semantics, following the behavior-tree
13
+ # pattern. Pure Ruby, with an optional native backend built on the behavior-machines Rust crate.
14
+ module BehaviorMachines
15
+ STATUSES = %i[success failure running].freeze
16
+
17
+ class << self
18
+ # The current monotonic time in nanoseconds.
19
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
20
+
21
+ # Whether the native backend is loaded (opt-in, see NativeExtension).
22
+ def native_available? = NativeExtension.loaded?
23
+
24
+ def resolve_backend(backend)
25
+ case backend
26
+ when :ruby then :ruby
27
+ when :auto then native_available? ? :native : :ruby
28
+ when :native
29
+ return :native if native_available?
30
+
31
+ raise Error, "native backend not loaded (set BEHAVIOR_MACHINES_NATIVE=1#{"; #{NativeExtension.error}" if NativeExtension.error})"
32
+ else raise ArgumentError, "backend must be :ruby, :native, or :auto, got #{backend.inspect}"
33
+ end
34
+ end
35
+
36
+ def check_status(status, leaf)
37
+ return status if STATUSES.include?(status)
38
+
39
+ raise InvalidStatusError, "#{leaf.inspect} returned #{status.inspect}; expected :success, :failure, or :running"
40
+ end
41
+
42
+ def check_time(now)
43
+ return now if now.is_a?(Integer) && now.between?(0, Definition::MAX_NANOS)
44
+
45
+ raise ArgumentError, "now must be an Integer number of nanoseconds in 0..2**64-1, got #{now.inspect}"
46
+ end
47
+ end
48
+ end
49
+
50
+ BehaviorMachines::NativeExtension.load!
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: behavior_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: arm64-darwin
6
+ authors:
7
+ - Abdelkader Boudih
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: state_machines
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.202'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.202'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake-compiler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.3'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.3'
69
+ description: |
70
+ BehaviorMachines runs behavior trees whose nodes follow the behavior-tree pattern: Sequence,
71
+ Fallback, their reactive variants, thresholded Parallel,
72
+ IfThenElse, WhileDoElse, and the usual decorators. Every running leaf the tree abandons is halted
73
+ exactly once, in the same call, so leaves can own real resources. Pure Ruby, with an optional
74
+ native backend built on the behavior-machines Rust crate, and leaves for state_machines objects.
75
+ email:
76
+ - terminale@gmail.com
77
+ executables: []
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - LICENSE
82
+ - README.md
83
+ - lib/behavior_machines.rb
84
+ - lib/behavior_machines/definition.rb
85
+ - lib/behavior_machines/engine.rb
86
+ - lib/behavior_machines/errors.rb
87
+ - lib/behavior_machines/machine.rb
88
+ - lib/behavior_machines/native_extension.rb
89
+ - lib/behavior_machines/node.rb
90
+ - lib/behavior_machines/tree.rb
91
+ - lib/behavior_machines/version.rb
92
+ - lib/behavior_machines_native/behavior_machines_native.bundle
93
+ homepage: https://github.com/seuros/behavior-machines
94
+ licenses:
95
+ - BSD-3-Clause
96
+ metadata:
97
+ homepage_uri: https://github.com/seuros/behavior-machines
98
+ source_code_uri: https://github.com/seuros/behavior-machines
99
+ bug_tracker_uri: https://github.com/seuros/behavior-machines/issues
100
+ rubygems_mfa_required: 'true'
101
+ cargo_crate_name: behavior_machines_native
102
+ cargo_manifest_path: ext/behavior_machines_native/ffi/Cargo.toml
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '4.0'
112
+ - - "<"
113
+ - !ruby/object:Gem::Version
114
+ version: 4.1.dev
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.5.23
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Behavior trees with explicit halt semantics, following the behavior-tree
125
+ pattern
126
+ test_files: []