rangeable 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b0a56ae009cc8892842f7b40ca828a0aa0f716c389b58b2e172afe08bca8e70
4
+ data.tar.gz: 67ecd4f99e0e9857a952301eacc7564507d1d0c422a9ee61b2e34eb141ad852d
5
+ SHA512:
6
+ metadata.gz: 119170a145bf1229fc3777caa8d6c7747349cd9f8a05cf5411b609b01ff22d2b28a1914d4eaa3494dfccbf6a9f40716e2456662b4cf507c8479f0c5071ddaba1
7
+ data.tar.gz: 2e76959c07b80799a06b5f01aedaa71d807f3a842be70e4565bf38aed64b3a860d1b7380aa1f9c125e18aa4d1bab5ca59f548e27cb48b0b97cfdad8034fd6c15
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] — 2026-05-10
9
+
10
+ ### Added
11
+
12
+ - Initial public release of `Rangeable<Element>`, the Ruby reference implementation of [RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC).
13
+ - Per-element sorted disjoint-interval list with idempotent containment fast-path.
14
+ - Lazy boundary-event index, rebuilt on the next read after a mutating insert; version-counter based invalidation.
15
+ - Public API: `insert(element, start:, end:)`, `[i].objs`, `get_range(element)`, `transitions(over:)`, `count`, `empty?`, `each`, `copy`.
16
+ - Refinement-style sugar `using Rangeable::Refinements` to enable `element.get_range(from: r)` without polluting `Object`.
17
+ - Full RFC § 10 normative test contract (23 tests) plus a 1000-iteration property test against a brute-force oracle and a markdown-shaped micro-benchmark.
18
+ - Cross-language fixture (160 ops, 86 probes) shared with the Swift reference implementation; outputs are byte-identical.
19
+
20
+ ### Performance
21
+
22
+ - Micro-benchmark: ~5.5× speedup over brute-force at m=50, L=2000.
23
+ - Real-world consumer ([ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown)): 2.23× end-to-end speedup, 55% render-time reduction; all 306 existing tests pass byte-identical.
24
+
25
+ ### Specification
26
+
27
+ - Conforms to [RangeableRFC v1.0](https://github.com/ZhgChgLi/RangeableRFC), reviewed and APPROVED by an independent academic reviewer (round 2; round-1 verdict REJECTED with 6 MUST-FIX items addressed).
28
+
29
+ [1.0.0]: https://github.com/ZhgChgLi/RubyRangeable/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZhgChgLi
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # RubyRangeable
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/rangeable)](https://rubygems.org/gems/rangeable) [![Ruby](https://img.shields.io/badge/ruby-3.2%2B-red)]() [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
+
5
+ Reference Ruby implementation of [`Rangeable<Element>`](https://github.com/ZhgChgLi/RangeableRFC) — a generic, integer-coordinate, closed-interval set container with first-insert ordered active queries.
6
+
7
+ ## Installation
8
+
9
+ Add to your `Gemfile`:
10
+
11
+ ```ruby
12
+ gem 'rangeable', '~> 1.0'
13
+ ```
14
+
15
+ Or install directly:
16
+
17
+ ```sh
18
+ $ gem install rangeable
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```ruby
24
+ require 'rangeable'
25
+
26
+ # Any Ruby object with sensible == / hash works as an element.
27
+ Strong = Struct.new(:tag)
28
+ Italic = Struct.new(:tag)
29
+
30
+ r = Rangeable.new
31
+ r.insert(Strong.new(:bold), start: 2, end: 5)
32
+ r.insert(Strong.new(:bold), start: 3, end: 7) # merges with [2, 5] → [2, 7]
33
+ r.insert(Strong.new(:bold), start: 9, end: 11) # disjoint
34
+ r.insert(Italic.new(:em), start: 3, end: 8)
35
+
36
+ r.get_range(Strong.new(:bold)) # => [[2, 7], [9, 11]]
37
+ r.get_range(Italic.new(:em)) # => [[3, 8]]
38
+
39
+ r[4].objs # => [Strong<:bold>, Italic<:em>] first-insert order
40
+ r[8].objs # => [Italic<:em>]
41
+ r[10].objs # => [Strong<:bold>]
42
+ ```
43
+
44
+ ### Refinement sugar
45
+
46
+ Avoid polluting the global namespace:
47
+
48
+ ```ruby
49
+ using Rangeable::Refinements
50
+
51
+ Strong.new(:bold).get_range(from: r) # => [[2, 7], [9, 11]]
52
+ ```
53
+
54
+ ### Sweep iteration via transitions
55
+
56
+ ```ruby
57
+ r.transitions(over: 0..15).each do |event|
58
+ case event.kind
59
+ when :open then puts "#{event.coordinate}: open #{event.element}"
60
+ when :close then puts "#{event.coordinate}: close #{event.element}"
61
+ end
62
+ end
63
+ ```
64
+
65
+ ## API
66
+
67
+ | Method | Returns | Notes |
68
+ |---|---|---|
69
+ | `Rangeable.new` | empty `Rangeable` | |
70
+ | `r.insert(element, start:, end:)` | `:mutated` / `:idempotent` | raises `Rangeable::InvalidIntervalError` if `start > end` |
71
+ | `r[i]` | `Rangeable::Slot` | wrapper exposing `.objs` |
72
+ | `r[i].objs` | frozen `Array<Element>` | first-insert order |
73
+ | `r.get_range(element)` | `Array<[lo, hi]>` | merged disjoint ranges |
74
+ | `r.transitions(over: a..b)` | `Array<Event>` | each `Event` has `coordinate`, `kind`, `element` |
75
+ | `r.count` | `Integer` | distinct elements |
76
+ | `r.empty?` | `Boolean` | |
77
+ | `r.each { \|element, ranges\| ... }` | `self` | iteration |
78
+ | `r.copy` | `Rangeable` | deep copy |
79
+
80
+ ## Semantics
81
+
82
+ - **End is inclusive**: `[a, b]` covers `a..b`, both ends.
83
+ - **Same-element merging**: equal elements (by `==` / `hash`) merge on overlap or integer adjacency. `[2, 4] ∪ [5, 7] = [2, 7]`.
84
+ - **Idempotent insert**: re-inserting a contained interval costs no version bump.
85
+ - **Out-of-order rejected**: `insert(_, start: 5, end: 2)` raises.
86
+ - **Active-set ordering**: deterministic across runs — first-insert order of the element, not hash bucket order.
87
+ - **Element immutability**: elements are frozen on insert (`element.dup.freeze`); mutating an element after insert is undefined behaviour.
88
+
89
+ See [RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC) § 4 for normative semantics, § 6 for algorithms, § 7 for the Φ-potential amortised-complexity proof, and § 10 for the 23-case normative test contract this gem must pass.
90
+
91
+ ## Performance
92
+
93
+ | Workload | Result |
94
+ |---|---|
95
+ | Brute-force baseline (m=50, L=2000) vs `Rangeable` | **~5.5× speedup** in micro-benchmark |
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
+
98
+ ## See also
99
+
100
+ - **[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.
102
+ - **[ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown)** — real-world consumer that exercises this gem on Medium GraphQL renderers.
103
+
104
+ ## Development
105
+
106
+ ```sh
107
+ $ bundle install
108
+ $ bundle exec rake test
109
+ ```
110
+
111
+ The test suite covers the full RFC § 10 contract (23 normative tests), a 1000-iteration property test against a brute-force oracle, and a markdown-shaped micro-benchmark.
112
+
113
+ ## License
114
+
115
+ MIT © [ZhgChgLi](https://github.com/ZhgChgLi)
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rangeable
4
+ # Lazy boundary-event index per RFC §5.2 / §6.3.
5
+ #
6
+ # Built from a snapshot of the per-element interval map plus the
7
+ # insertion-order map `ord`. Carries:
8
+ #
9
+ # * `events` — sorted Array of TransitionEvent under §4.5 ordering.
10
+ # * `segments` — sorted, disjoint Array of (seg_lo, seg_hi, frozen active)
11
+ # triples covering every coordinate at which the active
12
+ # set is non-empty. Active sets are frozen and sorted by
13
+ # `ord(e)` ascending.
14
+ # * `version` — snapshot of Rangeable.version at build time. The owner
15
+ # Rangeable invalidates the index by setting it to nil
16
+ # on any mutation; reads compare versions to decide
17
+ # whether to rebuild (T3 mutex pattern, §11).
18
+ #
19
+ # `nil` close coordinates encode +∞ for hi == Integer::MAX boundaries
20
+ # (§4.7 C4). Comparison against `nil` always treats it as greater than
21
+ # any finite Integer; we centralise that logic in `coord_lt?` /
22
+ # `coord_le?`.
23
+ class BoundaryIndex
24
+ # Public TransitionEvent type returned by `Rangeable#transitions`.
25
+ # `coordinate` is normally an Integer; it is `nil` for close events
26
+ # whose underlying interval ends at the implementation's +∞ sentinel
27
+ # (i.e. `hi` was the maximum representable boundary in the cross-language
28
+ # contract). `kind` is `:open` or `:close`.
29
+ TransitionEvent = Struct.new(:coordinate, :kind, :element, :ord) do
30
+ def open?
31
+ kind == :open
32
+ end
33
+
34
+ def close?
35
+ kind == :close
36
+ end
37
+
38
+ def to_h
39
+ { coordinate: coordinate, kind: kind, element: element }
40
+ end
41
+ end
42
+
43
+ # Sentinel: callers may pass `Integer.MAX` semantics by passing the
44
+ # special value below as `hi`. We default to no upper bound so close
45
+ # coords are always `hi + 1` finite Integer except when the caller
46
+ # explicitly opts into the +∞ sentinel via `Rangeable::INT_MAX`.
47
+ #
48
+ # Ruby has unbounded Integer, so this sentinel only matters for
49
+ # cross-language byte-identical fixtures that need to round-trip
50
+ # Swift's `Int.max` boundary (Test #23.A).
51
+
52
+ Segment = Struct.new(:lo, :hi, :active)
53
+
54
+ attr_reader :events, :segments, :version
55
+
56
+ def initialize(events, segments, version)
57
+ @events = events.freeze
58
+ @segments = segments.freeze
59
+ @version = version
60
+ freeze
61
+ end
62
+
63
+ # Find the segment containing `coord`, or nil if none. O(log |segments|).
64
+ # `coord` must be a finite Integer.
65
+ def segment_at(coord)
66
+ idx = @segments.bsearch_index { |seg| seg.hi >= coord }
67
+ return nil unless idx
68
+
69
+ seg = @segments[idx]
70
+ return nil unless seg.lo <= coord
71
+ seg
72
+ end
73
+
74
+ # Returns the events whose coordinate falls in `[lo, succ(hi)]` per
75
+ # RFC §6.4. `lo` is a finite Integer; `upper_coord` may be `nil` to
76
+ # mean "include all events through +∞".
77
+ def events_in_range(lo, upper_coord)
78
+ i_start = @events.bsearch_index { |ev| coord_ge?(ev.coordinate, lo) } || @events.size
79
+ result = []
80
+ i = i_start
81
+ while i < @events.size && coord_le?(@events[i].coordinate, upper_coord)
82
+ result << @events[i]
83
+ i += 1
84
+ end
85
+ result
86
+ end
87
+
88
+ # Build a fresh index from the per-element interval map and the
89
+ # insertion-order map `ord`. `int_max_sentinel` (default nil) lets the
90
+ # caller opt into "treat hi == sentinel as +∞" semantics for cross-
91
+ # language fixture parity; if it is nil we never coerce close coords
92
+ # to nil (Ruby's unbounded Integer makes that unnecessary).
93
+ def self.build(intervals, ord, snapshot_version, int_max_sentinel: nil)
94
+ events = []
95
+ intervals.each do |element, set|
96
+ element_ord = ord[element]
97
+ set.each do |interval|
98
+ events << TransitionEvent.new(interval.lo, :open, element, element_ord)
99
+ close_coord =
100
+ if !int_max_sentinel.nil? && interval.hi == int_max_sentinel
101
+ nil
102
+ else
103
+ interval.hi + 1
104
+ end
105
+ events << TransitionEvent.new(close_coord, :close, element, element_ord)
106
+ end
107
+ end
108
+
109
+ events.sort! do |a, b|
110
+ cmp = compare_coord(a.coordinate, b.coordinate)
111
+ next cmp unless cmp.zero?
112
+
113
+ # Same coord: opens before closes.
114
+ cmp = (a.kind == :open ? 0 : 1) <=> (b.kind == :open ? 0 : 1)
115
+ next cmp unless cmp.zero?
116
+
117
+ # Same coord + same kind: open ascending by ord, close descending.
118
+ if a.kind == :open
119
+ a.ord <=> b.ord
120
+ else
121
+ b.ord <=> a.ord
122
+ end
123
+ end
124
+
125
+ segments = materialise_segments(events)
126
+ new(events, segments, snapshot_version)
127
+ end
128
+
129
+ # Sweep events linearly, materialising a Segment for every maximal run
130
+ # of integers over which the active set is constant. Per RFC §6.3 we
131
+ # do not emit a segment whose active set is empty (no phantom
132
+ # `(-inf, first_open - 1)` segment).
133
+ def self.materialise_segments(events)
134
+ segments = []
135
+ active_by_ord = {} # ord => element (treated as a sorted set keyed by ord)
136
+ prev_coord = nil
137
+ i = 0
138
+ while i < events.size
139
+ # Group events at the same coord; we apply all of them before
140
+ # snapshotting the active set so that segment boundaries land on
141
+ # transitions, not in the middle of a same-coord burst.
142
+ ev = events[i]
143
+ coord = ev.coordinate
144
+
145
+ if !prev_coord.nil? && !active_by_ord.empty?
146
+ segments << Segment.new(prev_coord, coord - 1, snapshot_active(active_by_ord))
147
+ end
148
+
149
+ # Apply every event at this coord.
150
+ while i < events.size && events[i].coordinate == coord
151
+ ev_i = events[i]
152
+ if ev_i.open?
153
+ active_by_ord[ev_i.ord] = ev_i.element
154
+ else
155
+ active_by_ord.delete(ev_i.ord)
156
+ end
157
+ i += 1
158
+ end
159
+
160
+ prev_coord = coord
161
+ end
162
+ segments
163
+ end
164
+
165
+ # Snapshot the active set as a frozen Array sorted by ord ascending.
166
+ # The Hash insertion order is not guaranteed to match ord ascending
167
+ # (we add/remove arbitrarily), so we sort explicitly.
168
+ def self.snapshot_active(active_by_ord)
169
+ active_by_ord.keys.sort.map { |o| active_by_ord[o] }.freeze
170
+ end
171
+
172
+ # Total order over coordinates: nil (== +∞) is greater than any finite.
173
+ # Returns -1 / 0 / +1.
174
+ def self.compare_coord(a, b)
175
+ return 0 if a.nil? && b.nil?
176
+ return 1 if a.nil?
177
+ return -1 if b.nil?
178
+
179
+ a <=> b
180
+ end
181
+
182
+ private
183
+
184
+ def coord_ge?(coord, threshold)
185
+ self.class.compare_coord(coord, threshold) >= 0
186
+ end
187
+
188
+ def coord_le?(coord, upper)
189
+ self.class.compare_coord(coord, upper) <= 0
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'interval'
4
+
5
+ class Rangeable
6
+ # Sorted, disjoint, non-adjacent list of `Interval` for a single element.
7
+ #
8
+ # Maintains RFC §5.1 (I1) invariant:
9
+ # * sorted by lo strictly ascending
10
+ # * any two adjacent entries (lo1, hi1), (lo2, hi2) satisfy hi1 + 1 < lo2
11
+ # (no overlap, no integer adjacency)
12
+ # * lo <= hi for every entry
13
+ #
14
+ # `insert` follows RFC §6.1 cleaner variant (containment idempotent
15
+ # fast-path) and signals to the caller whether the mutation actually
16
+ # changed the canonical state.
17
+ 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
+ MUTATED = :mutated
21
+ IDEMPOTENT = :idempotent
22
+
23
+ def initialize
24
+ @entries = []
25
+ end
26
+
27
+ # Returns a frozen snapshot of the merged intervals as `[[lo, hi], ...]`.
28
+ # The list satisfies RFC §5.1 (I1).
29
+ def to_pairs
30
+ @entries.map(&:to_a)
31
+ end
32
+
33
+ def each(&block)
34
+ @entries.each(&block)
35
+ end
36
+
37
+ def empty?
38
+ @entries.empty?
39
+ end
40
+
41
+ def size
42
+ @entries.size
43
+ end
44
+
45
+ # Insert `[lo, hi]` into the set, performing union-with-merge per RFC §6.1.
46
+ # Returns `MUTATED` if the canonical state changed (caller should bump
47
+ # version), `IDEMPOTENT` if the insert was absorbed by an existing entry
48
+ # (caller MUST NOT bump version, per Test #21 and Lemma 6.5.B).
49
+ def insert(lo, hi)
50
+ raise ArgumentError, "lo (#{lo}) > hi (#{hi})" if lo > hi
51
+
52
+ # Step 4 of §6.1: bsearch for the leftmost touch candidate.
53
+ # Predicate: `iv.hi + 1 >= lo`. We use `iv.hi + 1` (not `lo - 1`) to
54
+ # avoid Integer underflow at lo == Int.min boundaries (§4.7 C5).
55
+ i0 = @entries.bsearch_index { |iv| iv.hi + 1 >= lo } || @entries.size
56
+
57
+ # Step 5: collect contiguous touch entries while `entries[i].lo <= hi + 1`.
58
+ to_merge_end = i0
59
+ while to_merge_end < @entries.size && @entries[to_merge_end].lo <= hi + 1
60
+ to_merge_end += 1
61
+ end
62
+ merge_count = to_merge_end - i0
63
+
64
+ # Step 6: containment idempotent fast-path. If we touch exactly one
65
+ # existing entry that fully covers [lo, hi], this insert is a no-op.
66
+ # MUST NOT mutate, MUST NOT bump version.
67
+ if merge_count == 1
68
+ existing = @entries[i0]
69
+ if existing.lo <= lo && hi <= existing.hi
70
+ return IDEMPOTENT
71
+ end
72
+ end
73
+
74
+ # Step 7: real mutation path. Compute merged bounds, splice in.
75
+ new_lo = lo
76
+ new_hi = hi
77
+ if merge_count.positive?
78
+ first = @entries[i0]
79
+ last = @entries[to_merge_end - 1]
80
+ new_lo = first.lo if first.lo < new_lo
81
+ new_hi = last.hi if last.hi > new_hi
82
+ end
83
+ merged = Interval.new(new_lo, new_hi)
84
+ # `slice!` removes the touched range; then we splice the merged
85
+ # interval at i0. Equivalent to the §6.1 reference pseudocode but
86
+ # avoids repeated O(m_e) shifts that delete_at would cause in a loop.
87
+ @entries.slice!(i0, merge_count) if merge_count.positive?
88
+ @entries.insert(i0, merged)
89
+ MUTATED
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rangeable
4
+ # Immutable closed integer interval [lo, hi].
5
+ #
6
+ # Used internally as the storage form within Rangeable::DisjointSet. Instances
7
+ # are produced by `insert` and never mutated; equality is structural so two
8
+ # intervals with the same `lo` / `hi` compare equal under `==`, `eql?` and
9
+ # `hash`.
10
+ class Interval
11
+ attr_reader :lo, :hi
12
+
13
+ def initialize(lo, hi)
14
+ raise ArgumentError, "lo (#{lo}) > hi (#{hi})" if lo > hi
15
+
16
+ @lo = lo
17
+ @hi = hi
18
+ freeze
19
+ end
20
+
21
+ # Inclusive containment check: true when `coord` falls inside [lo, hi].
22
+ def include?(coord)
23
+ lo <= coord && coord <= hi
24
+ end
25
+
26
+ # Returns the interval as a 2-element array `[lo, hi]`.
27
+ def to_a
28
+ [lo, hi]
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(Interval) && other.lo == lo && other.hi == hi
33
+ end
34
+ alias eql? ==
35
+
36
+ def hash
37
+ [Interval, lo, hi].hash
38
+ end
39
+
40
+ def to_s
41
+ "[#{lo}, #{hi}]"
42
+ end
43
+
44
+ def inspect
45
+ "#<Rangeable::Interval lo=#{lo} hi=#{hi}>"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rangeable
4
+ # Thin wrapper returned by `Rangeable#[]`. Wraps the active-element list at
5
+ # a single coordinate so we can grow the surface (e.g. add `count`,
6
+ # `transitions`, etc.) without changing the public type.
7
+ #
8
+ # `objs` is always a frozen `Array` of elements ordered by first-insert
9
+ # `ord(e)` ascending (RFC §4.5). We use a `Struct` (rather than a plain
10
+ # class) because the hot-path call sites are `r[i].objs` and the Struct
11
+ # accessor is materially faster than going through `attr_reader` + an
12
+ # ivar in MRI.
13
+ Slot = Struct.new(:objs) do
14
+ def empty?
15
+ objs.empty?
16
+ end
17
+
18
+ def size
19
+ objs.size
20
+ end
21
+ alias_method :count, :size
22
+ alias_method :length, :size
23
+
24
+ def each(&block)
25
+ objs.each(&block)
26
+ end
27
+
28
+ def to_a
29
+ objs.dup
30
+ end
31
+
32
+ def inspect
33
+ "#<Rangeable::Slot objs=#{objs.inspect}>"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defined as a class because the main `Rangeable` symbol in `rangeable.rb` is
4
+ # a class. We declare it the same way here so that requiring this file
5
+ # alone (e.g. from the gemspec) does not lock the symbol into being a
6
+ # module.
7
+ class Rangeable
8
+ VERSION = '1.0.0'
9
+ end
data/lib/rangeable.rb ADDED
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rangeable/version'
4
+ require_relative 'rangeable/interval'
5
+ require_relative 'rangeable/slot'
6
+ require_relative 'rangeable/disjoint_set'
7
+ require_relative 'rangeable/boundary_index'
8
+
9
+ # `Rangeable` is the language-neutral, generic, integer-coordinate closed-
10
+ # interval set container described in `RFC.md`. It pairs hashable elements
11
+ # with their merged disjoint integer ranges and supports three query
12
+ # families: by-element (`get_range`), by-position (`r[i]`) and by-range
13
+ # (`transitions`). See RFC §3 for the API surface.
14
+ class Rangeable
15
+ # Raised when an interval is malformed (start > end). Subclassing
16
+ # `ArgumentError` keeps it `rescue`able alongside other invalid-arg
17
+ # mistakes per RFC §3.7.
18
+ class InvalidIntervalError < ArgumentError; end
19
+
20
+ # `Rangeable::TransitionEvent` is the public alias for the Struct defined
21
+ # inside the index. Re-exposed here so callers don't need to reach through
22
+ # `BoundaryIndex` to reference its type.
23
+ TransitionEvent = ::Rangeable::BoundaryIndex::TransitionEvent unless const_defined?(:TransitionEvent, false)
24
+
25
+ # Frozen empty Array we hand back from `[]` when the coordinate is
26
+ # outside every segment. Avoids re-allocating an empty Array per query.
27
+ EMPTY_ACTIVE = [].freeze unless const_defined?(:EMPTY_ACTIVE, false)
28
+
29
+ attr_reader :version
30
+
31
+ # Build a fresh empty container. RFC §3.1.
32
+ def initialize
33
+ @intervals = {} # element => DisjointSet
34
+ @insertion_order = [] # Array<element> in first-insert order
35
+ @ord = {} # element => Integer (1-based)
36
+ @version = 0
37
+ @event_index = nil # BoundaryIndex or nil (lazy)
38
+ end
39
+
40
+ # Sugar: `Rangeable.empty` matches the RFC §3.1 alias.
41
+ def self.empty
42
+ new
43
+ end
44
+
45
+ # Insert `element` covering `[start, end_]` (closed interval). Idempotent
46
+ # by RFC §3.2: re-inserting a sub-range that is already fully contained
47
+ # leaves the container unchanged and does NOT bump version.
48
+ #
49
+ # The RFC reference uses the keyword `end:`, but Ruby treats `end` inside
50
+ # method headers as a soft-keyword that is fine in keyword-arg position.
51
+ # We accept it that way to match the RFC API exactly.
52
+ def insert(element, start:, end:)
53
+ end_ = binding.local_variable_get(:end)
54
+ raise InvalidIntervalError, "start (#{start}) > end (#{end_})" if start > end_
55
+
56
+ e = freeze_for_insert(element)
57
+
58
+ set = @intervals[e]
59
+ if set.nil?
60
+ set = DisjointSet.new
61
+ @intervals[e] = set
62
+ @insertion_order << e
63
+ @ord[e] = @insertion_order.length
64
+ end
65
+
66
+ result = set.insert(start, end_)
67
+ if result == DisjointSet::MUTATED
68
+ @version += 1
69
+ @event_index = nil
70
+ end
71
+ self
72
+ end
73
+
74
+ # Active-element list at `i`. RFC §3.3.
75
+ # O(log |segments| + r) once the index is built.
76
+ #
77
+ # Hot path: we inline the segment bsearch here (instead of going through
78
+ # a private helper or method dispatch into BoundaryIndex#segment_at) so
79
+ # `r[i]` stays allocation-free apart from the unavoidable `Slot`
80
+ # envelope. The cached active array on the segment is already frozen,
81
+ # so `Slot.new` does not duplicate it (see `Slot#initialize`).
82
+ def [](i)
83
+ ensure_event_index_fresh
84
+ segs = @event_index.segments
85
+ idx = segs.bsearch_index { |seg| seg.hi >= i }
86
+ if idx
87
+ seg = segs[idx]
88
+ if seg.lo <= i
89
+ Slot.new(seg.active)
90
+ else
91
+ Slot.new(EMPTY_ACTIVE)
92
+ end
93
+ else
94
+ Slot.new(EMPTY_ACTIVE)
95
+ end
96
+ end
97
+ alias active_at_index []
98
+
99
+ # Same as `[]` but spelled out to match RFC §3.3.
100
+ def active_at(index:)
101
+ self[index]
102
+ end
103
+
104
+ # Merged ranges for `element` as `[[lo, hi], ...]`. RFC §3.4. Returns an
105
+ # empty Array when the element has never been inserted.
106
+ def get_range(element)
107
+ set = @intervals[element]
108
+ return [] unless set
109
+
110
+ set.to_pairs
111
+ end
112
+ alias range_of get_range
113
+ alias get_range_of get_range
114
+
115
+ # Transitions (open / close events) within an inclusive coordinate range.
116
+ # Accepts a Ruby `Range` (inclusive or exclusive). RFC §3.5.
117
+ def transitions(over:)
118
+ raise InvalidIntervalError, "transitions range must be a Range" unless over.is_a?(Range)
119
+
120
+ lo = over.begin
121
+ hi = over.end
122
+ raise InvalidIntervalError, "open-ended begin not supported" if lo.nil?
123
+
124
+ # Normalise exclusive end (`a...b` ⇒ inclusive end `b - 1`).
125
+ hi = hi - 1 if !hi.nil? && over.exclude_end?
126
+
127
+ # Open-ended top (`a..nil`) means +∞ ⇒ include all events.
128
+ if hi.nil?
129
+ raise InvalidIntervalError, "lo (#{lo}) > hi (nil)" if lo.nil?
130
+ else
131
+ raise InvalidIntervalError, "lo (#{lo}) > hi (#{hi})" if lo > hi
132
+ end
133
+
134
+ ensure_event_index_fresh
135
+ upper = hi.nil? ? nil : hi + 1
136
+ @event_index.events_in_range(lo, upper).map do |ev|
137
+ TransitionEvent.new(ev.coordinate, ev.kind, ev.element, ev.ord)
138
+ end
139
+ end
140
+
141
+ # Number of distinct equivalence-class elements ever inserted. RFC §3.5.1.
142
+ def count
143
+ @insertion_order.length
144
+ end
145
+ alias size count
146
+ alias length count
147
+
148
+ # `count == 0`. RFC §3.5.1.
149
+ def empty?
150
+ @insertion_order.empty?
151
+ end
152
+
153
+ # Iterate `(element, ranges)` pairs in insertion-order ascending. RFC §3.5.1.
154
+ def each(&block)
155
+ return enum_for(:each) unless block_given?
156
+
157
+ @insertion_order.each do |element|
158
+ yield element, @intervals[element].to_pairs
159
+ end
160
+ self
161
+ end
162
+ include Enumerable
163
+
164
+ # Deep copy of the entire container per RFC §3.5.1. Mutation on the copy
165
+ # MUST NOT affect this instance and vice versa.
166
+ def copy
167
+ dup_instance = self.class.new
168
+ @insertion_order.each do |element|
169
+ dup_instance.send(:replant, element, @intervals[element], @ord[element])
170
+ end
171
+ dup_instance.send(:set_version_after_copy, @version)
172
+ dup_instance
173
+ end
174
+ alias dup copy
175
+ alias clone copy
176
+
177
+ # Sugar form: `Rangeable.range_of(element, from: r)`. RFC §3.4.
178
+ def self.range_of(element, from:)
179
+ from.get_range(element)
180
+ end
181
+
182
+ # Refinement-only sugar so `element.get_range(from: r)` works without
183
+ # polluting the global Object namespace. Caller does `using Rangeable::Refinements`.
184
+ module Refinements
185
+ refine Object do
186
+ def get_range(from:)
187
+ from.get_range(self)
188
+ end
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ # RFC §4.6 (M2): freeze on insert as cheap defence against caller-side
195
+ # mutation of hash-affecting state. We try to dup-then-freeze; if `dup`
196
+ # is not supported (some Symbol-like cases) we fall back to the original.
197
+ def freeze_for_insert(element)
198
+ return element if element.frozen?
199
+
200
+ begin
201
+ element.dup.freeze
202
+ rescue TypeError
203
+ element
204
+ end
205
+ end
206
+
207
+ def ensure_event_index_fresh
208
+ return if @event_index && @event_index.version == @version
209
+
210
+ # Snapshot the version at the start of the build. After build, recheck
211
+ # against `@version` (T3 mutex pattern, §11). In Ruby's GVL world this
212
+ # is mostly a no-op, but the comment exists to flag the contract for
213
+ # any future Ractor port.
214
+ v_start = @version
215
+ rebuilt = BoundaryIndex.build(@intervals, @ord, v_start)
216
+ @event_index = rebuilt if @version == v_start
217
+ end
218
+
219
+ # Public-ish helper kept available for direct callers. The hot path in
220
+ # `[]` inlines this for one less method dispatch per query.
221
+ def active_at_coord(coord)
222
+ ensure_event_index_fresh
223
+ seg = @event_index.segment_at(coord)
224
+ seg ? seg.active : EMPTY_ACTIVE
225
+ end
226
+
227
+ # Used by `copy` to clone an element's interval set with its ord intact.
228
+ # Lives here as a private setter so the public surface stays clean.
229
+ def replant(element, source_set, source_ord)
230
+ new_set = DisjointSet.new
231
+ source_set.each do |iv|
232
+ # MUTATED is the only outcome for sequential insertions of disjoint,
233
+ # ordered intervals into an empty set; we don't need to inspect the
234
+ # return value but we still call insert to keep all invariants in
235
+ # one place.
236
+ new_set.insert(iv.lo, iv.hi)
237
+ end
238
+ @intervals[element] = new_set
239
+ @insertion_order << element
240
+ @ord[element] = source_ord
241
+ end
242
+
243
+ def set_version_after_copy(v)
244
+ @version = v
245
+ end
246
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rangeable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - ZhgChgLi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: |
42
+ Rangeable is a language-neutral, generic, integer-coordinate closed-interval
43
+ set container. It pairs hashable elements with their merged disjoint integer
44
+ ranges and answers three queries: by-element ranges, by-position active set,
45
+ and by-range transition events. The Ruby reference implementation follows
46
+ the Rangeable RFC normatively, including idempotent containment fast-path,
47
+ lazy boundary-event indexing, and first-insert deterministic ordering.
48
+ email:
49
+ - zhgchgli@gmail.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE
56
+ - README.md
57
+ - lib/rangeable.rb
58
+ - lib/rangeable/boundary_index.rb
59
+ - lib/rangeable/disjoint_set.rb
60
+ - lib/rangeable/interval.rb
61
+ - lib/rangeable/slot.rb
62
+ - lib/rangeable/version.rb
63
+ homepage: https://github.com/ZhgChgLi/RubyRangeable
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://github.com/ZhgChgLi/RubyRangeable
68
+ source_code_uri: https://github.com/ZhgChgLi/RubyRangeable
69
+ bug_tracker_uri: https://github.com/ZhgChgLi/RubyRangeable/issues
70
+ changelog_uri: https://github.com/ZhgChgLi/RubyRangeable/blob/main/CHANGELOG.md
71
+ documentation_uri: https://github.com/ZhgChgLi/RangeableRFC/blob/main/RFC.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.2'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.4.1
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Hashable-element interval set with first-insert ordered active queries.
92
+ test_files: []