merge_machines 0.2.0-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: 2d0658ea6e6338e7e3e562c25a7252c24e1f1decf3d2e65672eefce6d8df8f76
4
+ data.tar.gz: cb093d38e65a6cdaa206a1bc6ae7c85eea3917a1fb54ff831ff8897d1d1fea9e
5
+ SHA512:
6
+ metadata.gz: 2f62e616091582604a54cc1324b29b04bd2b9570db407b70080ed64b10d96d7a424a9df76652c8146565b3c53930f7b987b953e63a9926acb2f922d96bd46347
7
+ data.tar.gz: 6d8559d9f9cdba3dd029f5b4ba0d7dac389ac46d71fb0d82b29f333c310a3936442cf622e9b3cdb4ac4084b9ef52251f94db124ca345fd8fec58fbc917f599e9
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/seuros/merge_machines/compare/merge_machines-v0.1.0...merge_machines/v0.2.0) (2026-11-01)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * hybrid timestamps cross the boundary as nanoseconds, not Float seconds
9
+
10
+ ### Features
11
+
12
+ * CRDTs and the clocks they need ([0cf6b7f](https://github.com/seuros/merge_machines/commit/0cf6b7f8de95fc40b5cdb6f05a1bf4bcafe6e7af))
13
+ * pass a rubygems OTP through the environment ([6834db9](https://github.com/seuros/merge_machines/commit/6834db97472a0028a6fa837d2cb3b845620d97e6))
14
+ * Ruby gem, magnus FFI, and a codec for every state ([6be0bda](https://github.com/seuros/merge_machines/commit/6be0bdaea6317b2bc02087db28eb374c0eea88af))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * a crowded reading let the hybrid clock reissue one timestamp ([4c4d139](https://github.com/seuros/merge_machines/commit/4c4d13942d5ccb3b70a5666f0b7306d1487d367b))
20
+ * a merge that overran capacity kept the names of the keys it turned down ([fe5ac2a](https://github.com/seuros/merge_machines/commit/fe5ac2aaec8bf9db35e2131fb67951dcaf5c4c1b))
21
+ * a PNCounter balance was the difference of two clamped totals ([dde63bc](https://github.com/seuros/merge_machines/commit/dde63bca70683ed8ba57deb962f980fab854b776))
22
+ * a register that got its value by merging took a stale write ([ae393ef](https://github.com/seuros/merge_machines/commit/ae393efc1fdaf8bf3980d70e7ef0508a3296c36d))
23
+ * a single observed timestamp stopped a Lamport clock for good ([48f7788](https://github.com/seuros/merge_machines/commit/48f77882dffb7bdd6952c1175612ca60e69c0aae))
24
+ * a source install can build, and a failed gem push fails the release ([bf37071](https://github.com/seuros/merge_machines/commit/bf37071c1398ecc81d4185f24d89cd89e8a8abe0))
25
+ * a truncated register blob loaded as an empty one ([1aa9391](https://github.com/seuros/merge_machines/commit/1aa939148ba7f5161158b033a0a08c658ad3d9fa))
26
+ * an OrSet add vanished when a replica's tag counter ran out ([8f81ce8](https://github.com/seuros/merge_machines/commit/8f81ce8240ce9070bb50f685bd6d54750674089b))
27
+ * collect the built gems whatever depth the download puts them at ([29ccbdd](https://github.com/seuros/merge_machines/commit/29ccbddfbaee366919c2833925cfa989a5fb2a00))
28
+ * hybrid timestamps cross the boundary as nanoseconds, not Float seconds ([01c9d86](https://github.com/seuros/merge_machines/commit/01c9d8602d07be6bdfcb659ae90b8f42706130e4))
29
+ * keep the name table consistent when a merge overruns capacity ([3f34aa5](https://github.com/seuros/merge_machines/commit/3f34aa5c24bae838a1a3421f64ae061c5a6afb5b))
30
+ * refuse a fractional stamp instead of truncating it into a tie ([6d3be65](https://github.com/seuros/merge_machines/commit/6d3be65af48918f890544e8547cfeb0eb03b45a2))
31
+ * ship the core's test files so the packaged crate builds ([4651644](https://github.com/seuros/merge_machines/commit/4651644a58306aac0b6fd0e890f1f033b89adfa9))
32
+ * the hash let a short key collide with a longer zero-padded one ([0a53206](https://github.com/seuros/merge_machines/commit/0a532065246def302a6510f082779f7981a61ff3))
33
+ * the release tag carries the gem name, and the gate uses the aggregate output ([d048d8c](https://github.com/seuros/merge_machines/commit/d048d8cf92bf29b7a284a2f475c4e6f94280563d))
34
+ * two unrescuable panics, a non-commutative merge, and a lost first write ([6951483](https://github.com/seuros/merge_machines/commit/6951483b917a731359ac2b4d94c0b326439f0882))
35
+
36
+ ## 0.1.0
37
+
38
+ - Initial release.
39
+ - `MergeMachines::GCounter` and `PNCounter`: one count per replica, summed for
40
+ a value that only grows, and two of those for one that moves both ways.
41
+ - `MergeMachines::LwwRegister`: one value, held by whichever write was stamped
42
+ last, with the replica id as the tie-break, stamped by either clock.
43
+ - `MergeMachines::GSet` and `OrSet`: a set nothing leaves, and one where a
44
+ remove only takes out the adds it has seen, so a concurrent add survives it.
45
+ - `MergeMachines::LogicalClock` and `HybridClock`: Lamport's counter, and a
46
+ wall-clock reading paired with a counter that breaks its ties. The hybrid
47
+ clock reads `Process::CLOCK_REALTIME`, which may step backwards, and no
48
+ deadline may be derived from it.
49
+ - Keys and register values are Ruby strings, carried as 64-bit hashes with a
50
+ side table that `dump` and `load` round-trip.
51
+ - Replica counts and set capacities are const generics in the crate, so each
52
+ size is a separately compiled type and anything off the menu raises
53
+ `ArgumentError`.
54
+ - `merge-machines` core crate: the states and the clocks in `no_std` Rust with
55
+ zero dependencies, plus a fixed-length codec per state. Builds and lints
56
+ clean on bare metal.
57
+ - RBS signatures for the whole public API.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 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,252 @@
1
+ # MergeMachines 🔀
2
+
3
+ > **Two ships, one log, no argument.** The tender that lets separated crews write
4
+ > their own records and still agree when the fleet regroups.
5
+
6
+ Conflict-free replicated data types for Ruby, and the clocks that stamp them.
7
+
8
+ Two processes that both edited the same thing have to agree on the result
9
+ without asking each other. These states merge commutatively, associatively and
10
+ idempotently, so a replica converges on the same value whatever order the
11
+ updates arrived in and however many times one of them arrived. **AMS Lamport**
12
+ runs under comms blackout by design: it never calls home, it reconciles on
13
+ contact.
14
+
15
+ ---
16
+
17
+ ## 🚀 Launch Sequence
18
+
19
+ ```ruby
20
+ require 'merge_machines'
21
+
22
+ edge = MergeMachines::GCounter.new(replicas: 8)
23
+ origin = MergeMachines::GCounter.new(replicas: 8)
24
+
25
+ edge.increment(replica: 1, by: 3)
26
+ origin.increment(replica: 2, by: 7)
27
+
28
+ edge.merge(origin).value # => 10
29
+ edge.merge(origin).value # => 10, the same state again is absorbed
30
+ ```
31
+
32
+ Nothing here talks to a network. The caller owns the transport, the replica ids
33
+ and every clock reading.
34
+
35
+ ---
36
+
37
+ ## 🔩 The Native Extension Is Required
38
+
39
+ There is no pure-Ruby fallback and no `DISABLE_*` switch. The gem is a binding
40
+ to the `merge-machines` crate in `ext/merge_machines_native/core`, and it needs
41
+ Ruby 4.0 and a Rust toolchain (or a platform gem). A second implementation
42
+ would mean two sets of convergence tests and two byte formats to keep in step.
43
+
44
+ ---
45
+
46
+ ## 📐 Sizes Are a Fixed Menu
47
+
48
+ The crate sizes every state by a const generic, so each size is a separately
49
+ compiled type. The extension carries these and raises `ArgumentError` for
50
+ anything else:
51
+
52
+ | Class | Keyword | Values |
53
+ | ------------- | ----------- | ---------------- |
54
+ | `GCounter` | `replicas:` | 4, 8, 16, 32 |
55
+ | `PNCounter` | `replicas:` | 4, 8, 16, 32 |
56
+ | `GSet` | `capacity:` | 64, 256, 1024 |
57
+ | `OrSet` | `capacity:` | 64, 256, 1024 |
58
+ | `LwwRegister` | `stamp:` | `:logical`, `:hybrid` |
59
+
60
+ ```ruby
61
+ MergeMachines::GSet.new(capacity: 100)
62
+ # ArgumentError: capacity 100 is not supported, expected one of [64, 256, 1024]
63
+ ```
64
+
65
+ A state with no free slot left raises `MergeMachines::Full` rather than
66
+ growing. That is the deal a fixed size buys: a replica's memory is known at
67
+ construction time.
68
+
69
+ ---
70
+
71
+ ## 🔢 Counters
72
+
73
+ ```ruby
74
+ counter = MergeMachines::GCounter.new(replicas: 8)
75
+ counter.increment(replica: 1, by: 3)
76
+ counter.increment(replica: 1) # by: defaults to 1
77
+ counter.count(replica: 1) # => 4
78
+ counter.value # => 4, every replica's count
79
+ counter.merge(other) # exact union, idempotent
80
+ ```
81
+
82
+ One shared number cannot be merged: two replicas that both saw 5 and both added
83
+ 1 have no way to tell 6 from 7. One count per replica can, because no replica
84
+ ever writes another's count, which is why `increment` needs a `replica:`.
85
+
86
+ `PNCounter` is two of those, one for what went up and one for what came down,
87
+ so its value may be negative.
88
+
89
+ ```ruby
90
+ counter = MergeMachines::PNCounter.new(replicas: 8)
91
+ counter.increment(replica: 1, by: 10)
92
+ counter.decrement(replica: 2, by: 4)
93
+ counter.value # => 6
94
+ counter.added # => 10
95
+ counter.removed # => 4
96
+ ```
97
+
98
+ ---
99
+
100
+ ## 🗂️ Sets
101
+
102
+ Keys are strings and must be UTF-8. The crate's elements are `Copy + Eq`, so a
103
+ key crosses as a 64-bit hash of its bytes and the extension keeps the string
104
+ each hash came from; `dump` carries that table along, so `load` hands the real
105
+ strings back.
106
+
107
+ ```ruby
108
+ set = MergeMachines::GSet.new(capacity: 64)
109
+ set.add('user-1')
110
+ set.include?('user-1') # => true
111
+ set.members # => ['user-1']
112
+ set.merge(other) # union
113
+ ```
114
+
115
+ `GSet` only grows, so merging is the union and there is nothing to resolve.
116
+ `OrSet` tags every add and tombstones the tags a remove has seen, so a remove
117
+ races a concurrent add and loses, and re-adding afterwards works.
118
+
119
+ ```ruby
120
+ set = MergeMachines::OrSet.new(capacity: 64)
121
+ set.add('user-1', replica: 1)
122
+ set.remove('user-1')
123
+ set.include?('user-1') # => false
124
+
125
+ set.add('user-1', replica: 1)
126
+ set.include?('user-1') # => true
127
+ ```
128
+
129
+ `capacity:` bounds the adds and the tombstones separately. A removed key keeps
130
+ its add slot, so a set that churns runs out.
131
+
132
+ ---
133
+
134
+ ## 🏁 One Value, Last Writer Wins
135
+
136
+ Stamps order the writes and the replica id breaks the ties. Both sides of a
137
+ concurrent write pick the same winner, which is the whole point, and the
138
+ loser's value is gone, which is the price.
139
+
140
+ ```ruby
141
+ register = MergeMachines::LwwRegister.new
142
+ register.set('http://origin', replica: 1, stamp: 7)
143
+ register.value # => 'http://origin'
144
+ register.stamp # => 7
145
+ register.replica # => 1
146
+ register.merge(other)
147
+ ```
148
+
149
+ A register nobody has written holds `nil`. `stamp:` is whichever clock you
150
+ picked at construction: an Integer for `:logical`, a `Timestamp` for
151
+ `:hybrid`.
152
+
153
+ ---
154
+
155
+ ## 🕰️ Which Chronometer
156
+
157
+ `LogicalClock` is Lamport's counter. It advances on every local event and jumps
158
+ past any stamp it is shown, so a receive is always stamped after the send it
159
+ answers. No clock is read.
160
+
161
+ ```ruby
162
+ clock = MergeMachines::LogicalClock.new(replica: 1)
163
+ sent_at = clock.tick # => 1
164
+ clock.observe(42) # => 43, past both the local history and the send
165
+ clock.now # => 43
166
+ ```
167
+
168
+ A counter with nothing left raises `MergeMachines::Exhausted` rather than
169
+ handing out a stamp it already gave away, and the clock stays where it was.
170
+
171
+ `HybridClock` pairs a wall-clock reading with a counter: close to physical
172
+ time, still totally ordered, and still monotonic when the host's clock is
173
+ corrected backwards.
174
+
175
+ ```ruby
176
+ clock = MergeMachines::HybridClock.new(replica: 1)
177
+ clock.tick # Timestamp, reading from the realtime clock
178
+ clock.tick(wall_ns: 1_700_000_000_500_000_000) # or a reading you supply
179
+ clock.observe(wall_ns: 1_500_000_000, counter: 3) # a timestamp that arrived
180
+ clock.now # the last one issued
181
+ ```
182
+
183
+ It raises `MergeMachines::Exhausted` too, when the reading has not advanced and
184
+ the counter that separates two events at one reading has nothing left. A
185
+ reading that really did advance resets that counter.
186
+
187
+ ### ⚠️ The Reading It Wants Is Not a Monotonic One
188
+
189
+ `wall_ns:` is physical time two machines can compare, in nanoseconds, and
190
+ defaults to
191
+ `Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)`. That clock
192
+ **steps backwards** when the host's is corrected, which is exactly why the
193
+ counter exists.
194
+
195
+ **No deadline, timeout or budget may be derived from it.** Measuring how long
196
+ something took belongs to the monotonic clock, which this gem neither reads nor
197
+ models. `deadline_machines` owns that, and **AMS Layland** is the ship you hail
198
+ for it.
199
+
200
+ A reading crosses this boundary as seconds in a Float, so a realtime reading
201
+ round-trips to about 256 nanoseconds. Two timestamps that land inside the same
202
+ window are separated by the counter, which is what it is for.
203
+
204
+ ---
205
+
206
+ ## 📦 Putting State on a Wire
207
+
208
+ `dump` returns a binary string and `load` reads one back.
209
+
210
+ ```ruby
211
+ blob = set.dump # String, Encoding::BINARY
212
+ MergeMachines::GSet.load(blob)
213
+ ```
214
+
215
+ A blob is the crate's own fixed-length frame, led by a tag byte naming the
216
+ structure and a field naming the shape, followed by whatever the Ruby surface
217
+ needs that the frame does not hold: the key table for the sets and the
218
+ register, the replica id for the clocks. `load` raises `ArgumentError` on a
219
+ truncated blob, on one written by another structure, and on one written at a
220
+ shape this build does not carry.
221
+
222
+ ```ruby
223
+ MergeMachines::GSet.load(MergeMachines::OrSet.new(capacity: 64).dump)
224
+ # ArgumentError: codec: wrong structure tag
225
+ ```
226
+
227
+ ---
228
+
229
+ ## 🚫 Rules of Engagement
230
+
231
+ - It is not a transport. There is no gossip, no anti-entropy loop, no peer
232
+ list. You ship the blobs.
233
+ - It is not a timer. The hybrid clock reads a wall clock, never a monotonic
234
+ one, and nothing here measures elapsed time.
235
+ - It is not a state machine, and it does not retry, back off, break a circuit
236
+ or shed load. Other machines in this family do those.
237
+
238
+ ---
239
+
240
+ ## 📡 Credits
241
+
242
+ **AMS Lamport**, NCC-1178, *Lamport*-class tender.
243
+
244
+ Named for Leslie Lamport's *Time, Clocks, and the Ordering of Events in a
245
+ Distributed System* (1978), which showed that order between separated machines
246
+ is something you stamp, not something you read off a clock.
247
+
248
+ ---
249
+
250
+ ## License
251
+
252
+ MIT.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MergeMachines
4
+ VERSION = '0.2.0'
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'merge_machines/version'
4
+
5
+ # GCounter, PNCounter, LwwRegister, GSet, OrSet, LogicalClock and HybridClock
6
+ # are defined by the native extension; there is no pure-Ruby implementation to
7
+ # fall back to.
8
+ require 'merge_machines_native/merge_machines_native'
@@ -0,0 +1,238 @@
1
+ # Conflict-free replicated data types and the clocks that stamp them. Every
2
+ # class here is defined by the native extension, which binds the
3
+ # `merge-machines` crate.
4
+ #
5
+ # Every state merges commutatively, associatively and idempotently, so replicas
6
+ # converge without a coordinator and without ordering their messages. Nothing
7
+ # here talks to a network: the caller owns the transport and the replica ids.
8
+ module MergeMachines
9
+ VERSION: String
10
+
11
+ # Nanoseconds in a second. Wall-clock readings cross this boundary as seconds
12
+ # and move through the clocks as nanoseconds.
13
+ NS_PER_SECOND: Integer
14
+
15
+ class Error < StandardError
16
+ end
17
+
18
+ # Raised when a state needs a slot and every one of them is taken.
19
+ class Full < Error
20
+ end
21
+
22
+ # Raised when a clock has issued the last stamp its counter can hold, by
23
+ # either clock.
24
+ class Exhausted < Error
25
+ end
26
+
27
+ # A wall-clock reading in nanoseconds and the counter that breaks its ties.
28
+ class Timestamp < ::Data
29
+ attr_reader wall_ns: Integer
30
+ attr_reader counter: Integer
31
+ end
32
+
33
+ # Which clock stamps a register's writes.
34
+ type stamp_kind = :logical | :hybrid
35
+
36
+ # A counter that only grows, counted separately by each replica.
37
+ class GCounter
38
+ # `replicas` is 4, 8, 16 or 32.
39
+ def self.new: (replicas: Integer) -> instance
40
+
41
+ def self.load: (String blob) -> instance
42
+
43
+ def replicas: () -> Integer
44
+
45
+ # Adds `by` to `replica`'s own count. Raises `Full` when `replica` has no
46
+ # slot yet and every one of them is taken.
47
+ def increment: (replica: Integer, ?by: Integer) -> nil
48
+
49
+ # What `replica` has added, zero if it has no slot here.
50
+ def count: (replica: Integer) -> Integer
51
+
52
+ # Every replica's count.
53
+ def value: () -> Integer
54
+
55
+ # Folds `other` in. Raises `ArgumentError` on a different replica count.
56
+ def merge: (GCounter other) -> self
57
+
58
+ def dump: () -> String
59
+ def inspect: () -> String
60
+ end
61
+
62
+ # A counter replicas can both raise and lower, from two that only grow.
63
+ class PNCounter
64
+ # `replicas` is 4, 8, 16 or 32.
65
+ def self.new: (replicas: Integer) -> instance
66
+
67
+ def self.load: (String blob) -> instance
68
+
69
+ def replicas: () -> Integer
70
+
71
+ def increment: (replica: Integer, ?by: Integer) -> nil
72
+
73
+ def decrement: (replica: Integer, ?by: Integer) -> nil
74
+
75
+ # Everything added, minus everything taken away, which may be negative.
76
+ def value: () -> Integer
77
+
78
+ def added: () -> Integer
79
+ def removed: () -> Integer
80
+
81
+ # Folds `other` in. Raises `ArgumentError` on a different replica count.
82
+ def merge: (PNCounter other) -> self
83
+
84
+ def dump: () -> String
85
+ def inspect: () -> String
86
+ end
87
+
88
+ # One value, held by whichever write was stamped last, with the replica id as
89
+ # the tie-break.
90
+ class LwwRegister
91
+ # `stamp` picks which clock stamps the writes, defaulting to `:logical`.
92
+ def self.new: (?stamp: stamp_kind) -> instance
93
+
94
+ def self.load: (String blob) -> instance
95
+
96
+ def stamp_kind: () -> stamp_kind
97
+
98
+ # Writes `value` if `stamp` and `replica` beat the write already held.
99
+ # `stamp` is an Integer on a logical register and a `Timestamp` on a hybrid
100
+ # one. `value` must be UTF-8.
101
+ def set: (String value, replica: Integer, stamp: Integer | Timestamp) -> nil
102
+
103
+ # The value being held, `nil` until the first write.
104
+ def value: () -> String?
105
+
106
+ # The stamp of the write being held.
107
+ def stamp: () -> (Integer | Timestamp)
108
+
109
+ # The replica that wrote what is being held.
110
+ def replica: () -> Integer
111
+
112
+ # Folds `other` in. Raises `ArgumentError` on a register stamped by the
113
+ # other clock.
114
+ def merge: (LwwRegister other) -> self
115
+
116
+ def dump: () -> String
117
+ def inspect: () -> String
118
+ end
119
+
120
+ # A set nothing is ever taken out of.
121
+ class GSet
122
+ # `capacity` is 64, 256 or 1024.
123
+ def self.new: (capacity: Integer) -> instance
124
+
125
+ def self.load: (String blob) -> instance
126
+
127
+ def capacity: () -> Integer
128
+
129
+ # Adds `key`, which is a no-op if it is already in. Raises `Full` when
130
+ # `key` is new and every slot is taken, `EncodingError` when it is not
131
+ # UTF-8.
132
+ def add: (String key) -> nil
133
+
134
+ def include?: (String key) -> bool
135
+
136
+ # The keys in the set, in no particular order.
137
+ def members: () -> Array[String]
138
+
139
+ def size: () -> Integer
140
+ def empty?: () -> bool
141
+
142
+ # Unions `other` in. Raises `ArgumentError` on a different capacity.
143
+ def merge: (GSet other) -> self
144
+
145
+ def dump: () -> String
146
+ def inspect: () -> String
147
+ end
148
+
149
+ # A set keys can leave, where a concurrent add survives a remove.
150
+ class OrSet
151
+ # `capacity` is 64, 256 or 1024, and bounds the adds and the tombstones
152
+ # separately.
153
+ def self.new: (capacity: Integer) -> instance
154
+
155
+ def self.load: (String blob) -> instance
156
+
157
+ def capacity: () -> Integer
158
+
159
+ # Adds `key` under a tag of `replica` and the next counter that replica has
160
+ # not used here. Raises `Full` when every add slot is taken, `Exhausted`
161
+ # when `replica` has no counter left, `EncodingError` when `key` is not
162
+ # UTF-8.
163
+ def add: (String key, replica: Integer) -> nil
164
+
165
+ # Tombstones every tag this set has seen for `key`. Raises `Full` when the
166
+ # tombstones do not fit.
167
+ def remove: (String key) -> nil
168
+
169
+ def include?: (String key) -> bool
170
+
171
+ # The keys still in the set, each once, in no particular order.
172
+ def members: () -> Array[String]
173
+
174
+ def size: () -> Integer
175
+ def empty?: () -> bool
176
+
177
+ # Folds `other` in. Raises `ArgumentError` on a different capacity.
178
+ def merge: (OrSet other) -> self
179
+
180
+ def dump: () -> String
181
+ def inspect: () -> String
182
+ end
183
+
184
+ # Lamport's counter: ordering events without reading a clock.
185
+ class LogicalClock
186
+ def self.new: (replica: Integer) -> instance
187
+
188
+ def self.load: (String blob) -> instance
189
+
190
+ # The replica whose events this clock stamps.
191
+ def replica: () -> Integer
192
+
193
+ # The last stamp this clock issued.
194
+ def now: () -> Integer
195
+
196
+ # Stamps a local event. Raises `Exhausted` when the counter holds its
197
+ # highest value, which leaves the clock where it was.
198
+ def tick: () -> Integer
199
+
200
+ # Stamps the receipt of `observed`, a stamp from another replica. Raises
201
+ # `Exhausted` when it cannot be stamped after `observed`.
202
+ def observe: (Integer observed) -> Integer
203
+
204
+ def dump: () -> String
205
+ def inspect: () -> String
206
+ end
207
+
208
+ # A wall-clock reading paired with a counter that breaks its ties, which never
209
+ # goes backwards.
210
+ #
211
+ # The reading comes from `Process::CLOCK_REALTIME`, which may step backwards.
212
+ # No deadline may be derived from it.
213
+ class HybridClock
214
+ def self.new: (replica: Integer) -> instance
215
+
216
+ def self.load: (String blob) -> instance
217
+
218
+ # The replica whose events this clock stamps.
219
+ def replica: () -> Integer
220
+
221
+ # The last timestamp this clock issued.
222
+ def now: () -> Timestamp
223
+
224
+ # Stamps a local event at `wall_ns`, defaulting to the realtime clock's
225
+ # reading. Raises `Exhausted` when the reading has not advanced and the
226
+ # counter that separates two events at one reading holds its highest value,
227
+ # which leaves the clock where it was.
228
+ def tick: (?wall_ns: Integer) -> Timestamp
229
+
230
+ # Stamps the receipt of the timestamp `wall_ns` and `counter` name, at the
231
+ # realtime clock's reading. Raises `Exhausted` when it cannot be stamped
232
+ # after the timestamp observed.
233
+ def observe: (wall_ns: Integer, counter: Integer) -> Timestamp
234
+
235
+ def dump: () -> String
236
+ def inspect: () -> String
237
+ end
238
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: merge_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: x86_64-linux
6
+ authors:
7
+ - Abdelkader Boudih
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Counters, a last-writer-wins register, grow-only and observed-remove
14
+ sets, and the Lamport and hybrid logical clocks that stamp them, bound to Ruby from
15
+ the no_std merge-machines crate. Every state merges commutatively, associatively
16
+ and idempotently, and encodes to a byte string other languages reading the same
17
+ crate can decode.
18
+ email:
19
+ - oss@seuros.com
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - ext/merge_machines_native/extconf.rb
28
+ - lib/merge_machines.rb
29
+ - lib/merge_machines/version.rb
30
+ - lib/merge_machines_native/merge_machines_native.so
31
+ - sig/merge_machines.rbs
32
+ homepage: https://github.com/seuros/merge_machines
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ source_code_uri: https://github.com/seuros/merge_machines
37
+ changelog_uri: https://github.com/seuros/merge_machines/blob/main/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/seuros/merge_machines/issues
39
+ rubygems_mfa_required: 'true'
40
+ cargo_crate_name: merge_machines_native
41
+ cargo_manifest_path: ext/merge_machines_native/ffi/Cargo.toml
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '4.0'
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: 4.1.dev
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.5.23
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: Conflict-free replicated data types for Ruby
64
+ test_files: []