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 +4 -4
- data/README.md +9 -1
- data/lib/rangeable/disjoint_set.rb +186 -3
- data/lib/rangeable/version.rb +1 -1
- data/lib/rangeable.rb +313 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f362dd2814304e1073bec891bb7e2611d379ca0661f9681f78ca87a066253ec2
|
|
4
|
+
data.tar.gz: c6cfb8717fe206e7477710627faabe5f91c07177f4e740120f5a1b2eaf01625a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)** —
|
|
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
|
|
19
|
-
# to decide whether to bump the container-level
|
|
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
|
data/lib/rangeable/version.rb
CHANGED
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:
|
|
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-
|
|
11
|
+
date: 2026-05-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|