feedback_machines 0.1.4-x86_64-linux

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: f31d1697829cec063186e65b936957b537b36aeec5e4fb6b040649c22cee0024
4
+ data.tar.gz: bb54c2f074ca82bd8dcc436cd24c8b6db985d7cf090d91dbd95364750cc80425
5
+ SHA512:
6
+ metadata.gz: 51cd2e811c7f3c8d4dc87988582a805441ee3c7d50a71a7afa2a7587f48e101c89f8169630d6f4901499107276d2401513b1665ead0bb529869d8385f44dd7ee
7
+ data.tar.gz: a72859a26834c330a97260c64b0a3e72891500aaa97628ad9cf2849cd45b5860ef27ff2756fbbfe98f6a58dae178f80eb1ab2897c694e2f9db1a94cc79339754
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [0.1.4](https://github.com/seuros/feedback_machines/compare/feedback_machines/v0.1.3...feedback_machines/v0.1.4) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([fa5503e](https://github.com/seuros/feedback_machines/commit/fa5503e226b815de6da68bf3710f3b108c27ce2c))
9
+
10
+ ## [0.1.3](https://github.com/seuros/feedback_machines/compare/feedback_machines/v0.1.2...feedback_machines/v0.1.3) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([d507afd](https://github.com/seuros/feedback_machines/commit/d507afd8ea54ec317a9b3dccbeb0cb4e3d455f6f))
16
+
17
+ ## [0.1.2](https://github.com/seuros/feedback_machines/compare/feedback_machines/v0.1.1...feedback_machines/v0.1.2) (2026-11-06)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * cross builds no longer compile for the host ([c724a14](https://github.com/seuros/feedback_machines/commit/c724a1461d0e5e0b8a710ce12222a14f1c1f4013))
23
+
24
+ ## [0.1.1](https://github.com/seuros/feedback_machines/compare/feedback_machines-v0.1.0...feedback_machines/v0.1.1) (2026-11-06)
25
+
26
+
27
+ ### Features
28
+
29
+ * initial extraction ([d273a1e](https://github.com/seuros/feedback_machines/commit/d273a1e1a17979e275ded3d02618e316e45d34f3))
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # FeedbackMachines
2
+
3
+ > Proportion, memory, and anticipation. The three things a threshold doesn't have. 🎛️
4
+
5
+ Two small control loops for Ruby. A `Pid` turns "how far off are we" into "how much to adjust", bounded so it can never ask for more than the actuator can give. A `Hysteresis` band turns a noisy reading into a clean on/off decision that doesn't flap. You supply the measurements and the clock. They supply the arithmetic.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem 'feedback_machines'
11
+ ```
12
+
13
+ ```ruby
14
+ # Keep the job queue near 50 pending by adding or removing workers.
15
+ pid = FeedbackMachines::Pid.new(p: 0.5, i: 0.1, min: -4.0, max: 4.0)
16
+
17
+ loop do
18
+ error = queue.depth - 50
19
+ delta = pid.step(error: error, elapsed: seconds_since_last_tick)
20
+ pool.resize(pool.size + delta.round) if delta
21
+ sleep 1
22
+ end
23
+ ```
24
+
25
+ ```ruby
26
+ # Flip into shed mode above 80% CPU, back out below 60%, nothing in between.
27
+ band = FeedbackMachines::Hysteresis.new(min: 0.6, max: 0.8)
28
+
29
+ shed_mode = band.observe(cpu.utilization) == :high
30
+ ```
31
+
32
+ ## A Message to the Resistance
33
+
34
+ So the queue backed up and you wrote the autoscaler. If depth is over 100, add a worker. If it's under 10, remove one. Ship it.
35
+
36
+ Monday morning the depth hits 101. A worker spins up. It takes 20 seconds to boot, by which time depth is 140 and the rule has fired three more times. Four new workers drain the queue to zero in a minute. Now depth is under 10, so the rule removes them, one per tick, and the queue backs up again. Your dashboard looks like a seismograph and ops has named the pattern after you.
37
+
38
+ The problem isn't the rule. The problem is that a threshold has no memory and no sense of proportion. It can't tell "a little over" from "catastrophically over", and it can't tell "climbing" from "already falling". Control engineers solved this in the 1920s for steering ships. A proportional term scales the response to the size of the error. An integral term remembers how long you've been off. A derivative term notices which way you're heading. Clamp the output so you never ask for forty workers at once, and stop accumulating when you're already clamped so the controller doesn't owe the universe an impossible correction later.
39
+
40
+ That is what `Pid` does. `Hysteresis` is the same cure for on/off decisions: two thresholds instead of one, so a reading hovering at 79.9% doesn't toggle shed mode fifty times a minute.
41
+
42
+ ## Chapter 1: Stardate 2025.186, the AMS Maxwell
43
+
44
+ > **AMS Maxwell**, NCC-4208, *Maxwell*-class guidance tender.
45
+ > Correct the heading without teaching the thrusters to oscillate forever.
46
+
47
+ The tender doesn't fly the ship. It takes a heading error and a sampling interval and returns a thruster command the engines can actually execute. The helm reads the compass and fires the thrusters. The tender just makes sure that when the ship is one degree off, the correction is one degree's worth, and when it has been two degrees off for ten minutes, somebody finally does something about it.
48
+
49
+ On the bridge, a cadet proposes:
50
+
51
+ ```ruby
52
+ def correct_heading
53
+ if compass.error > 0
54
+ thrusters.fire(:port, 100)
55
+ elsif compass.error < 0
56
+ thrusters.fire(:starboard, 100)
57
+ end
58
+ end
59
+ ```
60
+
61
+ "That," says the navigator, "is how a ship ends up pointing everywhere except where it's going."
62
+
63
+ ## The Flight Plan
64
+
65
+ The hysteresis band has two states and refuses to change its mind in the middle.
66
+
67
+ ```mermaid
68
+ stateDiagram-v2
69
+ [*] --> low
70
+ low --> high: reading >= max
71
+ low --> low: reading < max
72
+ high --> low: reading <= min
73
+ high --> high: reading > min
74
+ ```
75
+
76
+ And one tick of the queue autoscaler:
77
+
78
+ ```mermaid
79
+ sequenceDiagram
80
+ participant Tick
81
+ participant Queue
82
+ participant Pid
83
+ participant Pool
84
+
85
+ Tick->>Queue: depth?
86
+ Queue-->>Tick: 70
87
+ Tick->>Pid: step(error: 20, elapsed: 1.0)
88
+ Note over Pid: p: 0.5 × 20 = 10, clamped to 4
89
+ Pid-->>Tick: 4.0
90
+ Tick->>Pool: resize(+4)
91
+
92
+ Tick->>Queue: depth?
93
+ Queue-->>Tick: 54
94
+ Tick->>Pid: step(error: 4, elapsed: 1.0)
95
+ Note over Pid: 2 + integral 0.4
96
+ Pid-->>Tick: 2.4
97
+ Tick->>Pool: resize(+2)
98
+
99
+ Tick->>Queue: depth?
100
+ Queue-->>Tick: 48
101
+ Tick->>Pid: step(error: -2, elapsed: 1.0)
102
+ Pid-->>Tick: -0.8
103
+ Tick->>Pool: resize(-1)
104
+ ```
105
+
106
+ ## How the Pid Works
107
+
108
+ ### Proportional
109
+
110
+ With only `p` set, the output is the error times the gain, clamped to the limits.
111
+
112
+ ```ruby
113
+ pid = FeedbackMachines::Pid.new(p: 1.0, min: -10.0, max: 10.0)
114
+
115
+ pid.step(error: 3.0, elapsed: 1.0) # => 3.0
116
+ pid.step(error: -3.0, elapsed: 1.0) # => -3.0
117
+ pid.step(error: 30.0, elapsed: 1.0) # => 10.0, clamped
118
+ ```
119
+
120
+ ### Integral, without windup
121
+
122
+ Add `i` and the controller starts remembering. Each step adds `error × elapsed` to a running total. But when the output is already pinned at a limit, the total stops growing. That's the part that keeps a saturated actuator from accumulating a debt it can never repay.
123
+
124
+ ```ruby
125
+ pid = FeedbackMachines::Pid.new(p: 1.0, i: 1.0, min: -10.0, max: 10.0)
126
+
127
+ pid.step(error: 100.0, elapsed: 1.0) # => 10.0, clamped, integral frozen
128
+ pid.step(error: 1.0, elapsed: 1.0) # => 2.0, not 101.0
129
+ pid.step(error: 1.0, elapsed: 1.0) # => 3.0, integral climbing normally now
130
+ ```
131
+
132
+ Without that guard, the second step would try to pay back a hundred units of accumulated error and overshoot for the next minute.
133
+
134
+ ### Derivative
135
+
136
+ Add `d` and the controller reacts to the *rate* of change. The first step has nothing to compare against, so the derivative term is zero.
137
+
138
+ ```ruby
139
+ pid = FeedbackMachines::Pid.new(p: 0.0, d: 1.0, min: -100.0, max: 100.0)
140
+
141
+ pid.step(error: 5.0, elapsed: 1.0) # => 0.0, no history yet
142
+ pid.step(error: 6.0, elapsed: 0.5) # => 2.0, rose by 1 over 0.5s
143
+ pid.step(error: 6.0, elapsed: 0.5) # => 0.0, holding steady
144
+ ```
145
+
146
+ ### Bad samples don't poison the history
147
+
148
+ A zero interval, a NaN reading, or an infinite one returns `nil` and leaves the controller exactly as it was. Skip that tick and carry on.
149
+
150
+ ```ruby
151
+ pid = FeedbackMachines::Pid.new(p: 1.0, i: 1.0, min: -10.0, max: 10.0)
152
+
153
+ pid.step(error: 2.0, elapsed: 1.0) # => 4.0
154
+ pid.step(error: 2.0, elapsed: 0.0) # => nil
155
+ pid.step(error: Float::NAN, elapsed: 1.0) # => nil
156
+ pid.step(error: 2.0, elapsed: 1.0) # => 6.0, as if the two bad ticks never happened
157
+ ```
158
+
159
+ ## How the Band Works
160
+
161
+ Two thresholds. The band starts `:low` and only goes `:high` when a reading reaches `max`. It only comes back down when a reading falls to `min`. Everything in between keeps whatever it was.
162
+
163
+ ```ruby
164
+ band = FeedbackMachines::Hysteresis.new(min: 0.6, max: 0.8)
165
+
166
+ band.observe(0.7) # => :low, started low, not at max yet
167
+ band.observe(0.85) # => :high
168
+ band.observe(0.7) # => :high, in the dead zone, holds
169
+ band.observe(0.79) # => :high, still holds
170
+ band.observe(0.6) # => :low, reached min
171
+ band.observe(0.7) # => :low, dead zone again, holds
172
+ ```
173
+
174
+ A non-finite reading returns `nil` and the band keeps its state.
175
+
176
+ ## The Rules of the Tender
177
+
178
+ - **Gains are finite and nonnegative.** Negative or infinite `p`, `i`, or `d` raises `ArgumentError`. Flip the sign of your error instead.
179
+ - **Limits need `min < max`.** Equal or reversed raises `ArgumentError`. Same for the band.
180
+ - **`elapsed` is a duration in seconds**, not a timestamp. Negative or NaN raises `ArgumentError`. Zero returns `nil`.
181
+ - **Integers are fine.** `p: 1, min: 0, max: 1` works. Everything is treated as a float.
182
+ - **Frozen means frozen.** `freeze` a controller or a band and every `step` or `observe` raises `FrozenError`.
183
+ - **Each controller is its own loop.** Two `Pid` instances share nothing, and they can't be `dup`ed or `clone`d.
184
+
185
+ ## What You Think You're Doing vs Reality
186
+
187
+ ### You Think: "I'll just add a worker when the queue is over 100."
188
+ ### Reality: You built an oscillator with a twenty-second period.
189
+
190
+ A threshold reacts to where you are. It has no idea how fast you got there or how long you've been stuck. By the time the action lands, the situation has moved, so the action is wrong, so the next action overcorrects. Proportion, memory, and anticipation are the three things a threshold lacks, and they're the three letters in PID.
191
+
192
+ ## What's Yours
193
+
194
+ The controller doesn't read sensors, run timers, or touch actuators. You decide what "error" means, how often to sample, what to do with the number that comes back, and what gains make sense for your system. Start with `p` alone, add `i` when the system settles short of the target, add `d` only if it overshoots.
195
+
196
+ ## License
197
+
198
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
199
+
200
+ **Remember: A threshold tells you that you're off course. A controller tells you by how much, for how long, and which way you're drifting.**
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2](https://github.com/seuros/feedback_machines/compare/feedback-machines-v0.2.1...feedback-machines-v0.2.2) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([fa5503e](https://github.com/seuros/feedback_machines/commit/fa5503e226b815de6da68bf3710f3b108c27ce2c))
9
+
10
+ ## [0.2.1](https://github.com/seuros/feedback_machines/compare/feedback-machines-v0.2.0...feedback-machines-v0.2.1) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([d507afd](https://github.com/seuros/feedback_machines/commit/d507afd8ea54ec317a9b3dccbeb0cb4e3d455f6f))
16
+
17
+ ## [0.2.0](https://github.com/seuros/feedback_machines/compare/feedback-machines-v0.1.0...feedback-machines-v0.2.0) (2026-11-06)
18
+
19
+
20
+ ### Features
21
+
22
+ * initial extraction ([d273a1e](https://github.com/seuros/feedback_machines/commit/d273a1e1a17979e275ded3d02618e316e45d34f3))
@@ -0,0 +1,30 @@
1
+ # feedback-machines
2
+
3
+ > **AMS Maxwell**, NCC-4208, *Maxwell*-class guidance tender.
4
+
5
+ Engine-room flight manual: turn measured drift into bounded control recommendations.
6
+
7
+ Dependency-free, allocation-free `no_std` controllers.
8
+
9
+ PID accepts explicit error and elapsed time, rejects nonfinite inputs, zero
10
+ sampling intervals, and overflowing intermediate calculations without changing
11
+ state. Derivative is taken on error; the first sample has zero derivative.
12
+ Output is clamped and conditional integration prevents windup while saturation
13
+ and error point in the same direction. There is no derivative filtering,
14
+ auto-tuning, or stability guarantee. Gains must be finite and nonnegative;
15
+ reverse the error convention for inverse-action systems.
16
+
17
+ Hysteresis switches high at/above its upper threshold and low at/below its lower
18
+ threshold. It retains state inside the band. Unlike flow watermarks, these are
19
+ generic signed floating-point measurements, not queue occupancy.
20
+
21
+ ```rust
22
+ use core::time::Duration;
23
+ use feedback_machines::{Gains, Limits, Pid};
24
+ # fn example() -> Option<()> {
25
+ let mut controller = Pid::new(Gains::new(2.0, 0.0, 0.0)?, Limits::new(0.0, 10.0)?);
26
+ assert_eq!(controller.step(2.0, Duration::from_secs(1)), Some(4.0));
27
+ # Some(())
28
+ # }
29
+ # assert_eq!(example(), Some(()));
30
+ ```
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module FeedbackMachines
3
+ VERSION = '0.1.4'
4
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'feedback_machines/version'
3
+ require 'feedback_machines_native/feedback_machines_native'
4
+
5
+ module FeedbackMachines
6
+ class Pid
7
+ def self.new(p:, min:, max:, i: 0.0, d: 0.0) = _new(p, i, d, min, max)
8
+ private_class_method :_new
9
+ def step(error:, elapsed:) = _step(error, elapsed)
10
+ private :_step
11
+ end
12
+
13
+ class Hysteresis
14
+ def self.new(min:, max:) = _new(min, max)
15
+ private_class_method :_new
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module FeedbackMachines
2
+ VERSION: String
3
+ class Pid
4
+ def self.new: (p: Float, min: Float, max: Float, ?i: Float, ?d: Float) -> instance
5
+ def step: (error: Float, elapsed: Float) -> Float?
6
+ end
7
+ class Hysteresis
8
+ def self.new: (min: Float, max: Float) -> instance
9
+ def observe: (Float) -> (:low | :high)?
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: feedback_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.4
5
+ platform: x86_64-linux
6
+ authors:
7
+ - Abdelkader Boudih
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Thin Ruby bindings to the standalone feedback-machines core; sampling,
14
+ tuning, and actuation belong to the caller.
15
+ email:
16
+ - oss@seuros.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - ext/feedback_machines_native/core/CHANGELOG.md
25
+ - ext/feedback_machines_native/core/README.md
26
+ - ext/feedback_machines_native/extconf.rb
27
+ - lib/feedback_machines.rb
28
+ - lib/feedback_machines/version.rb
29
+ - lib/feedback_machines_native/feedback_machines_native.so
30
+ - sig/feedback_machines.rbs
31
+ homepage: https://github.com/seuros/feedback_machines
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ source_code_uri: https://github.com/seuros/feedback_machines
36
+ rubygems_mfa_required: 'true'
37
+ cargo_crate_name: feedback_machines_native
38
+ cargo_manifest_path: ext/feedback_machines_native/ffi/Cargo.toml
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '4.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: 4.1.dev
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.5.23
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Native PID and hysteresis controllers for Ruby
61
+ test_files: []