rangeable 1.0.0 → 2.0.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b0a56ae009cc8892842f7b40ca828a0aa0f716c389b58b2e172afe08bca8e70
4
- data.tar.gz: 67ecd4f99e0e9857a952301eacc7564507d1d0c422a9ee61b2e34eb141ad852d
3
+ metadata.gz: f362dd2814304e1073bec891bb7e2611d379ca0661f9681f78ca87a066253ec2
4
+ data.tar.gz: c6cfb8717fe206e7477710627faabe5f91c07177f4e740120f5a1b2eaf01625a
5
5
  SHA512:
6
- metadata.gz: 119170a145bf1229fc3777caa8d6c7747349cd9f8a05cf5411b609b01ff22d2b28a1914d4eaa3494dfccbf6a9f40716e2456662b4cf507c8479f0c5071ddaba1
7
- data.tar.gz: 2e76959c07b80799a06b5f01aedaa71d807f3a842be70e4565bf38aed64b3a860d1b7380aa1f9c125e18aa4d1bab5ca59f548e27cb48b0b97cfdad8034fd6c15
6
+ metadata.gz: c82564bc81baa64215ec301c681a350cba6600d45a39d04ff7bb1e65f900efb593d5dd5192c051de84254337394de99f312183c0fd682d731c5c88152b3ac330
7
+ data.tar.gz: b00a459e59a86b5d62a95d9bafeeae1edcf7f539a16e0b4b11fd75d9ef0631f2ded2c395cb52e9c44c48b68a6e780982446fbca73b42ef2b4638924305ae41dc
data/README.md CHANGED
@@ -95,10 +95,18 @@ See [RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC) § 4 for normative
95
95
  | Brute-force baseline (m=50, L=2000) vs `Rangeable` | **~5.5× speedup** in micro-benchmark |
96
96
  | Real consumer ([ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown)) | **2.23× end-to-end speedup**, 55% render-time reduction; all 306 existing tests byte-identical |
97
97
 
98
+ ## Cross-language consistency
99
+
100
+ This Ruby implementation joins the [Swift](https://github.com/ZhgChgLi/SwiftRangeable), [Python](https://github.com/ZhgChgLi/PythonRangeable), [JS](https://github.com/ZhgChgLi/JSRangeable), [Kotlin](https://github.com/ZhgChgLi/KotlinRangeable) and [Go](https://github.com/ZhgChgLi/GoRangeable) implementations. All six share a 160-op / 86-probe JSON fixture and produce byte-identical outputs.
101
+
98
102
  ## See also
99
103
 
100
104
  - **[RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC)** — the normative specification this gem implements.
101
- - **[SwiftRangeable](https://github.com/ZhgChgLi/SwiftRangeable)** — sibling reference implementation in Swift; produces byte-identical outputs against a shared cross-language fixture.
105
+ - **[SwiftRangeable](https://github.com/ZhgChgLi/SwiftRangeable)** — Swift reference (SPM).
106
+ - **[PythonRangeable](https://github.com/ZhgChgLi/PythonRangeable)** — Python reference (`pip install rangeable`).
107
+ - **[JSRangeable](https://github.com/ZhgChgLi/JSRangeable)** — TypeScript reference (`npm i rangeable-js`).
108
+ - **[KotlinRangeable](https://github.com/ZhgChgLi/KotlinRangeable)** — Kotlin/JVM reference (JitPack).
109
+ - **[GoRangeable](https://github.com/ZhgChgLi/GoRangeable)** — Go reference (`go get github.com/ZhgChgLi/GoRangeable`).
102
110
  - **[ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown)** — real-world consumer that exercises this gem on Medium GraphQL renderers.
103
111
 
104
112
  ## Development
@@ -13,10 +13,13 @@ class Rangeable
13
13
  #
14
14
  # `insert` follows RFC §6.1 cleaner variant (containment idempotent
15
15
  # fast-path) and signals to the caller whether the mutation actually
16
- # changed the canonical state.
16
+ # changed the canonical state. `remove` follows RFC §6.6 with the same
17
+ # mutated-vs-idempotent distinction (both use the same return symbols so
18
+ # the upstream `Rangeable` only writes one version-bump branch).
17
19
  class DisjointSet
18
- # Result codes returned from `insert`. The caller (Rangeable) uses these
19
- # to decide whether to bump the container-level version counter.
20
+ # Result codes returned from `insert` / `remove_subrange`. The caller
21
+ # (Rangeable) uses these to decide whether to bump the container-level
22
+ # version counter.
20
23
  MUTATED = :mutated
21
24
  IDEMPOTENT = :idempotent
22
25
 
@@ -42,6 +45,14 @@ class Rangeable
42
45
  @entries.size
43
46
  end
44
47
 
48
+ # Read-only access to the underlying Array<Interval>. Returned reference
49
+ # is the live storage — callers that mutate it violate (I1). Used by the
50
+ # set-op two-pointer sweeps in `Rangeable` to avoid an Array allocation
51
+ # on every union/intersect/difference call.
52
+ def entries
53
+ @entries
54
+ end
55
+
45
56
  # Insert `[lo, hi]` into the set, performing union-with-merge per RFC §6.1.
46
57
  # Returns `MUTATED` if the canonical state changed (caller should bump
47
58
  # version), `IDEMPOTENT` if the insert was absorbed by an existing entry
@@ -88,5 +99,177 @@ class Rangeable
88
99
  @entries.insert(i0, merged)
89
100
  MUTATED
90
101
  end
102
+
103
+ # Subtract `[start_, end_]` from the set (§6.6 sweep+splice). Returns
104
+ # `MUTATED` if any entry was changed; `IDEMPOTENT` if no overlap.
105
+ # Caller is responsible for the eager-prune (§4.10 N1) check via `empty?`.
106
+ def remove_subrange(start_, end_)
107
+ raise ArgumentError, "lo (#{start_}) > hi (#{end_})" if start_ > end_
108
+
109
+ # Step 3 of §6.6: leftmost entry where `iv.hi >= start_`. Strict
110
+ # overlap; adjacency does NOT subtract anything.
111
+ i = @entries.bsearch_index { |iv| iv.hi >= start_ } || @entries.size
112
+
113
+ # Step 4: quick-exit. Either no entry crosses or past the cut window.
114
+ return IDEMPOTENT if i == @entries.size || @entries[i].lo > end_
115
+
116
+ # Step 5: sweep all overlapping entries; produce 0..2 residuals each.
117
+ to_replace_start = i
118
+ replacements = []
119
+ while i < @entries.size && @entries[i].lo <= end_
120
+ iv = @entries[i]
121
+ # Left residual exists iff iv.lo < start_; underflow-safe since
122
+ # start_ > iv.lo >= Int.min ⇒ start_ - 1 well-defined (§6.6).
123
+ replacements << Interval.new(iv.lo, start_ - 1) if iv.lo < start_
124
+ # Right residual exists iff end_ < iv.hi; overflow-safe since
125
+ # end_ < iv.hi <= Int.max ⇒ end_ + 1 well-defined.
126
+ replacements << Interval.new(end_ + 1, iv.hi) if end_ < iv.hi
127
+ i += 1
128
+ end
129
+ to_replace_end = i
130
+
131
+ # Step 6/7: splice. `slice!` then `insert` mirrors the reference
132
+ # `replace_subrange` and avoids quadratic shifts.
133
+ @entries.slice!(to_replace_start, to_replace_end - to_replace_start)
134
+ @entries.insert(to_replace_start, *replacements) unless replacements.empty?
135
+ MUTATED
136
+ end
137
+
138
+ # Replace the live entries with the given (I1)-canonical list. Used by
139
+ # set-op pipelines that compute the canonical result list externally
140
+ # (e.g. `merge_disjoint_lists`) and want to drop it in without re-running
141
+ # `insert`. Caller MUST guarantee the list satisfies (I1.1)–(I1.3); we
142
+ # do not re-verify (this is the internal hot path).
143
+ def replace_entries(new_entries)
144
+ @entries = new_entries
145
+ end
146
+
147
+ # Build a new DisjointSet directly from a known-canonical Array<Interval>.
148
+ # Used by set-op result construction; bypasses the `insert` cost path.
149
+ def self.from_entries(entries)
150
+ ds = new
151
+ ds.replace_entries(entries.dup)
152
+ ds
153
+ end
154
+
155
+ # === Set-op primitives (RFC §6.10–§6.13) ===
156
+ #
157
+ # All three primitives take Array<Interval> inputs and return
158
+ # Array<Interval> outputs. They live here (rather than on Rangeable)
159
+ # because they operate on per-element interval lists, which is exactly
160
+ # the DisjointSet's domain.
161
+
162
+ # §6.10 union helper: merge two (I1)-canonical lists into one
163
+ # (I1)-canonical list via two-pointer sweep + adjacency-collapse.
164
+ # `O(|l1| + |l2|)`. Either input may be empty.
165
+ def self.merge_disjoint_lists(l1, l2)
166
+ out = []
167
+ i = 0
168
+ j = 0
169
+ n1 = l1.size
170
+ n2 = l2.size
171
+ while i < n1 && j < n2
172
+ if l1[i].lo <= l2[j].lo
173
+ append_or_merge(out, l1[i])
174
+ i += 1
175
+ else
176
+ append_or_merge(out, l2[j])
177
+ j += 1
178
+ end
179
+ end
180
+ while i < n1
181
+ append_or_merge(out, l1[i])
182
+ i += 1
183
+ end
184
+ while j < n2
185
+ append_or_merge(out, l2[j])
186
+ j += 1
187
+ end
188
+ out
189
+ end
190
+
191
+ # §6.11 intersect helper: pairwise intersection via two-pointer sweep.
192
+ # No adjacency-collapse needed (Lemma 6.11.A). `O(|l1| + |l2|)`.
193
+ def self.intersect_disjoint_lists(l1, l2)
194
+ out = []
195
+ i = 0
196
+ j = 0
197
+ n1 = l1.size
198
+ n2 = l2.size
199
+ while i < n1 && j < n2
200
+ a = l1[i]
201
+ b = l2[j]
202
+ lo = a.lo > b.lo ? a.lo : b.lo
203
+ hi = a.hi < b.hi ? a.hi : b.hi
204
+ out << Interval.new(lo, hi) if lo <= hi
205
+ if a.hi <= b.hi
206
+ i += 1
207
+ else
208
+ j += 1
209
+ end
210
+ end
211
+ out
212
+ end
213
+
214
+ # §6.12 difference helper: subtract every interval in `l_b` from every
215
+ # interval in `l_a`. Two-pointer "in-flight current" sweep. `O(|l_a| + |l_b|)`.
216
+ def self.subtract_disjoint_lists(l_a, l_b)
217
+ out = []
218
+ i = 0
219
+ j = 0
220
+ n_a = l_a.size
221
+ n_b = l_b.size
222
+ current_lo = nil
223
+ current_hi = nil
224
+ while i < n_a
225
+ if current_lo.nil?
226
+ current_lo = l_a[i].lo
227
+ current_hi = l_a[i].hi
228
+ end
229
+ # Skip l_b entries strictly before the current entry.
230
+ j += 1 while j < n_b && l_b[j].hi < current_lo
231
+
232
+ if j == n_b || l_b[j].lo > current_hi
233
+ # No more l_b cuts on this entry; commit and advance i.
234
+ out << Interval.new(current_lo, current_hi)
235
+ i += 1
236
+ current_lo = nil
237
+ current_hi = nil
238
+ next
239
+ end
240
+
241
+ # l_b[j] overlaps [current_lo, current_hi]; cut.
242
+ # Left residual: (current_lo, l_b[j].lo - 1). Underflow-safe: only
243
+ # taken when l_b[j].lo > current_lo, so l_b[j].lo > Int.min.
244
+ out << Interval.new(current_lo, l_b[j].lo - 1) if l_b[j].lo > current_lo
245
+ if l_b[j].hi < current_hi
246
+ # Right residual remains as the new current; advance j.
247
+ # Overflow-safe: l_b[j].hi < current_hi ≤ Int.max.
248
+ current_lo = l_b[j].hi + 1
249
+ j += 1
250
+ else
251
+ # l_b[j] swallows the rest of the current entry; advance i.
252
+ i += 1
253
+ current_lo = nil
254
+ current_hi = nil
255
+ end
256
+ end
257
+ out
258
+ end
259
+
260
+ # @api private
261
+ # Helper for `merge_disjoint_lists`: append `iv` to `out`, collapsing
262
+ # if it overlaps or is integer-adjacent to `out.last`.
263
+ def self.append_or_merge(out, iv)
264
+ if out.empty? || out.last.hi + 1 < iv.lo
265
+ out << iv
266
+ else
267
+ # Overlap or adjacency: extend the last entry's hi if needed.
268
+ last = out.last
269
+ if iv.hi > last.hi
270
+ out[-1] = Interval.new(last.lo, iv.hi)
271
+ end
272
+ end
273
+ end
91
274
  end
92
275
  end
@@ -5,5 +5,5 @@
5
5
  # alone (e.g. from the gemspec) does not lock the symbol into being a
6
6
  # module.
7
7
  class Rangeable
8
- VERSION = '1.0.0'
8
+ VERSION = '2.0.0'
9
9
  end
data/lib/rangeable.rb CHANGED
@@ -189,8 +189,321 @@ class Rangeable
189
189
  end
190
190
  end
191
191
 
192
+ # ===========================================================================
193
+ # v2 Removal API (RFC §6.6–§6.9, §4.10 eager-pruning)
194
+ # ===========================================================================
195
+
196
+ # Remove the closed interval `[start_, end_]` from `R(element)`. RFC §6.6.
197
+ # Splits an entry if the cut lies strictly inside it. Idempotent (no-op,
198
+ # no version bump) when `element` is absent or no entry overlaps the cut
199
+ # window. Eagerly prunes `element` if its `R(e)` becomes empty (§4.10 N1).
200
+ def remove(element, start:, end:)
201
+ end_ = binding.local_variable_get(:end)
202
+ raise InvalidIntervalError, "start (#{start}) > end (#{end_})" if start > end_
203
+
204
+ set = @intervals[element]
205
+ return self if set.nil? # §4.10 (N3): no R(e) ⇒ no-op, no bump.
206
+
207
+ result = set.remove_subrange(start, end_)
208
+ return self if result == DisjointSet::IDEMPOTENT
209
+
210
+ # Eager pruning (§4.10 N1) when R(e) is now ∅.
211
+ excise_element(element) if set.empty?
212
+
213
+ @version += 1
214
+ @event_index = nil
215
+ self
216
+ end
217
+
218
+ # Fully remove `element` from the container. RFC §6.7. Idempotent on a
219
+ # never-inserted element. Eagerly excises and densely renumbers `ord`.
220
+ def remove_element(element)
221
+ # Hash#delete returns the deleted value or nil. nil ⇒ key absent ⇒
222
+ # no-op per §4.10 (N3); MUST NOT bump version.
223
+ return self unless @intervals.delete(element)
224
+
225
+ idx = @insertion_order.index(element)
226
+ @insertion_order.delete_at(idx)
227
+ @ord.delete(element)
228
+ # Renumber ord densely for elements that moved up by one position.
229
+ (idx...@insertion_order.length).each do |k|
230
+ @ord[@insertion_order[k]] = k + 1
231
+ end
232
+
233
+ @version += 1
234
+ @event_index = nil
235
+ self
236
+ end
237
+ alias delete remove_element
238
+
239
+ # Reset the container to its empty state. RFC §6.8. Idempotent on an
240
+ # already-empty container (no version bump).
241
+ def clear
242
+ return self if @insertion_order.empty? # §4.10 (N3) idempotence.
243
+
244
+ @intervals = {}
245
+ @insertion_order = []
246
+ @ord = {}
247
+ @version += 1
248
+ @event_index = nil
249
+ self
250
+ end
251
+ alias remove_all clear
252
+
253
+ # Subtract `[start_, end_]` from every element's `R(e)`. RFC §6.9.
254
+ # Atomic: at most one version bump for the entire op. Eagerly prunes
255
+ # any element whose `R(e)` becomes empty. No-op (no bump) if no element
256
+ # overlaps the cut window.
257
+ def remove_ranges(start:, end:)
258
+ end_ = binding.local_variable_get(:end)
259
+ raise InvalidIntervalError, "start (#{start}) > end (#{end_})" if start > end_
260
+
261
+ any_change = false
262
+ # Iterate a snapshot of @insertion_order: we may delete from @intervals
263
+ # mid-loop. We do NOT mutate @insertion_order in this loop — the §6.9
264
+ # pseudocode defers the rebuild to step 4 to avoid O(E²) delete_at cost.
265
+ @insertion_order.each do |element|
266
+ set = @intervals[element]
267
+ result = set.remove_subrange(start, end_)
268
+ next if result == DisjointSet::IDEMPOTENT
269
+
270
+ any_change = true
271
+ @intervals.delete(element) if set.empty?
272
+ end
273
+
274
+ return self unless any_change # §4.10 (N3) no-op.
275
+
276
+ # Step 4: single-pass rebuild of insertion_order and dense ord.
277
+ @insertion_order = @insertion_order.select { |e| @intervals.key?(e) }
278
+ @ord = {}
279
+ @insertion_order.each_with_index { |e, k| @ord[e] = k + 1 }
280
+
281
+ @version += 1
282
+ @event_index = nil
283
+ self
284
+ end
285
+
286
+ # ===========================================================================
287
+ # v2 Set Operations API (RFC §6.10–§6.13)
288
+ # ===========================================================================
289
+
290
+ # Per-element `R_self(e) ∪ R_other(e)`. Returns a fresh `Rangeable` with
291
+ # `version == 0`. Insertion-order rule: preserve `self`'s, tail-append
292
+ # keys ∈ `other` ∖ `self` in `other`'s insertion-order order. RFC §6.10.
293
+ def union(other)
294
+ raise ArgumentError, "expected Rangeable, got #{other.class}" unless other.is_a?(Rangeable)
295
+
296
+ out = self.class.new
297
+
298
+ # Step 1: walk self's insertion_order; merge with other if shared key.
299
+ @insertion_order.each do |element|
300
+ list_self = @intervals[element].entries
301
+ other_set = other.send(:intervals_internal)[element]
302
+ list_other = other_set ? other_set.entries : EMPTY_ACTIVE
303
+ merged = DisjointSet.merge_disjoint_lists(list_self, list_other)
304
+ # length(merged) > 0 is guaranteed because list_self is non-empty (I1.4).
305
+ out.send(:install_element, element, DisjointSet.from_entries(merged))
306
+ end
307
+
308
+ # Step 2: tail-append keys ∈ other ∖ self in other's insertion order.
309
+ other.send(:insertion_order_internal).each do |element|
310
+ next if @intervals.key?(element)
311
+
312
+ other_entries = other.send(:intervals_internal)[element].entries
313
+ out.send(:install_element, element, DisjointSet.from_entries(other_entries.dup))
314
+ end
315
+
316
+ out
317
+ end
318
+ alias_method :|, :union
319
+
320
+ # Per-element `R_self(e) ∩ R_other(e)` for keys in both. Empty results
321
+ # are eagerly pruned (§4.10). Insertion-order: preserve self's order over
322
+ # surviving keys. RFC §6.11.
323
+ def intersect(other)
324
+ raise ArgumentError, "expected Rangeable, got #{other.class}" unless other.is_a?(Rangeable)
325
+
326
+ out = self.class.new
327
+
328
+ @insertion_order.each do |element|
329
+ other_set = other.send(:intervals_internal)[element]
330
+ next unless other_set # not in other ⇒ drop.
331
+
332
+ list_self = @intervals[element].entries
333
+ list_other = other_set.entries
334
+ intersected = DisjointSet.intersect_disjoint_lists(list_self, list_other)
335
+ next if intersected.empty? # eager prune.
336
+
337
+ out.send(:install_element, element, DisjointSet.from_entries(intersected))
338
+ end
339
+
340
+ out
341
+ end
342
+ alias_method :&, :intersect
343
+ alias intersection intersect
344
+
345
+ # Per-element `R_self(e) ∖ R_other(e)`. Empty results eagerly pruned.
346
+ # Insertion-order: preserve self's order over surviving keys. RFC §6.12.
347
+ def difference(other)
348
+ raise ArgumentError, "expected Rangeable, got #{other.class}" unless other.is_a?(Rangeable)
349
+
350
+ out = self.class.new
351
+
352
+ @insertion_order.each do |element|
353
+ list_self = @intervals[element].entries
354
+ other_set = other.send(:intervals_internal)[element]
355
+ remaining =
356
+ if other_set.nil? || other_set.empty?
357
+ list_self.dup
358
+ else
359
+ DisjointSet.subtract_disjoint_lists(list_self, other_set.entries)
360
+ end
361
+ next if remaining.empty? # eager prune.
362
+
363
+ out.send(:install_element, element, DisjointSet.from_entries(remaining))
364
+ end
365
+
366
+ out
367
+ end
368
+ alias_method :-, :difference
369
+ alias subtract difference
370
+
371
+ # Per-element `R_self(e) △ R_other(e) = (self∖other) ∪ (other∖self)`.
372
+ # Empty results eagerly pruned. Insertion-order: preserve self's order
373
+ # for `e ∈ keys(self)`; tail-append `e ∈ keys(other) ∖ keys(self)` in
374
+ # other's order. RFC §6.13.
375
+ #
376
+ # `merge_disjoint_lists` (NOT sorted concat) is required because the two
377
+ # one-sided residuals can be integer-adjacent (RFC §10.B Test #34 worked
378
+ # example: `R_self=[(0,5)], R_other=[(6,10)]` ⇒ a=[(0,5)], b=[(6,10)],
379
+ # adjacent at 5+1==6, must collapse to [(0,10)]).
380
+ def symmetric_difference(other)
381
+ raise ArgumentError, "expected Rangeable, got #{other.class}" unless other.is_a?(Rangeable)
382
+
383
+ out = self.class.new
384
+
385
+ # Step 1: self-primary keys.
386
+ @insertion_order.each do |element|
387
+ list_self = @intervals[element].entries
388
+ other_set = other.send(:intervals_internal)[element]
389
+ list_other = other_set ? other_set.entries : EMPTY_ACTIVE
390
+ a = DisjointSet.subtract_disjoint_lists(list_self, list_other)
391
+ b = DisjointSet.subtract_disjoint_lists(list_other, list_self)
392
+ sym = DisjointSet.merge_disjoint_lists(a, b)
393
+ next if sym.empty? # eager prune.
394
+
395
+ out.send(:install_element, element, DisjointSet.from_entries(sym))
396
+ end
397
+
398
+ # Step 2: other-only keys.
399
+ other.send(:insertion_order_internal).each do |element|
400
+ next if @intervals.key?(element)
401
+
402
+ other_entries = other.send(:intervals_internal)[element].entries
403
+ next if other_entries.empty? # defensive; (I1.4) makes this unreachable.
404
+
405
+ out.send(:install_element, element, DisjointSet.from_entries(other_entries.dup))
406
+ end
407
+
408
+ out
409
+ end
410
+ alias_method :^, :symmetric_difference
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Mutating set ops (Ruby `!` convention). All four ALWAYS return self for
414
+ # chain-friendliness. Each bumps `version` exactly once iff the result
415
+ # differs structurally from `self` (per §6.10–§6.13 idempotence dual).
416
+ # Implementation strategy: build the new container via the non-mutating
417
+ # form (copy-then-swap), then compare to self. If structurally identical,
418
+ # discard and skip the bump.
419
+ # ---------------------------------------------------------------------------
420
+
421
+ def union!(other)
422
+ swap_with(union(other))
423
+ end
424
+
425
+ def intersect!(other)
426
+ swap_with(intersect(other))
427
+ end
428
+ alias intersection! intersect!
429
+
430
+ def difference!(other)
431
+ swap_with(difference(other))
432
+ end
433
+ alias subtract! difference!
434
+
435
+ def symmetric_difference!(other)
436
+ swap_with(symmetric_difference(other))
437
+ end
438
+
192
439
  private
193
440
 
441
+ # Internal accessor for set-op fast paths — exposed via `send` so the
442
+ # public surface stays clean. Returns the live @intervals Hash.
443
+ def intervals_internal
444
+ @intervals
445
+ end
446
+
447
+ def insertion_order_internal
448
+ @insertion_order
449
+ end
450
+
451
+ # Splice an already-canonical DisjointSet into this container under
452
+ # `element`. Used by set-op result construction. Caller MUST guarantee
453
+ # the element is not already a key (we tail-append to insertion_order).
454
+ def install_element(element, set)
455
+ @intervals[element] = set
456
+ @insertion_order << element
457
+ @ord[element] = @insertion_order.length
458
+ end
459
+
460
+ # Excise an element from all three element-keyed structures and densely
461
+ # renumber `ord` for the survivors at positions ≥ idx. Used by §6.6
462
+ # eager-prune path.
463
+ def excise_element(element)
464
+ @intervals.delete(element)
465
+ idx = @insertion_order.index(element)
466
+ @insertion_order.delete_at(idx)
467
+ @ord.delete(element)
468
+ (idx...@insertion_order.length).each do |k|
469
+ @ord[@insertion_order[k]] = k + 1
470
+ end
471
+ end
472
+
473
+ # In-place swap helper for the mutating set-op variants. Compares the
474
+ # candidate result `other` to `self` structurally; if equal, no-op (no
475
+ # version bump). Otherwise replaces all element-keyed state with `other`'s
476
+ # contents and bumps version exactly once. Always returns self.
477
+ def swap_with(other)
478
+ return self if structurally_equal?(other)
479
+
480
+ @intervals = other.send(:intervals_internal)
481
+ @insertion_order = other.send(:insertion_order_internal)
482
+ @ord = other.send(:ord_internal)
483
+ @version += 1
484
+ @event_index = nil
485
+ self
486
+ end
487
+
488
+ # Internal accessor so `swap_with` can read the candidate's ord map.
489
+ def ord_internal
490
+ @ord
491
+ end
492
+
493
+ # Structural-equality test for the swap fast-path: same insertion order
494
+ # AND per-element same canonical entries. Used by mutating set ops to
495
+ # honor the §3.2 idempotence dual ("no bump if result == self").
496
+ def structurally_equal?(other)
497
+ return false unless @insertion_order == other.send(:insertion_order_internal)
498
+
499
+ other_intervals = other.send(:intervals_internal)
500
+ @insertion_order.all? do |e|
501
+ a = @intervals[e].entries
502
+ b = other_intervals[e].entries
503
+ a.size == b.size && a.zip(b).all? { |x, y| x.lo == y.lo && x.hi == y.hi }
504
+ end
505
+ end
506
+
194
507
  # RFC §4.6 (M2): freeze on insert as cheap defence against caller-side
195
508
  # mutation of hash-affecting state. We try to dup-then-freeze; if `dup`
196
509
  # is not supported (some Symbol-like cases) we fall back to the original.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rangeable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZhgChgLi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-09 00:00:00.000000000 Z
11
+ date: 2026-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest