disjoint_interval_tree 0.1.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: 2489f3da4496b1c3abb350eb582d52a40298e61fd9132f52f3ca80e69aeda976
4
+ data.tar.gz: a3a35b0752910b4ae0e5f2c5aa021bf2175b4ae3545041670ee1fb2f7fe20c96
5
+ SHA512:
6
+ metadata.gz: 8937708e32708573ed399b91722a9ccc1ff8c9d338f6e6655c089a996e4228b0685d653d6b4dd2729e121f384a0a622a0f9cba585475128304633bf80c1830b0
7
+ data.tar.gz: af0540688e96cc9aa9eebfa5951d4b04aafc04e137b7d7113e959fef4d2621eb81d1a63f7fcbfbbd81d8716cf7fba2d3507e4fc20669265f94fbb2821a7dc163
data/CITATION.cff ADDED
@@ -0,0 +1,67 @@
1
+ cff-version: 1.2.0
2
+ message: >-
3
+ If you use this software in academic work, please cite the papers that
4
+ introduced the structures it derives from, listed under `references` below.
5
+ title: disjoint_interval_tree
6
+ abstract: >-
7
+ A set of pairwise disjoint half-open intervals, backed by an augmented AVL
8
+ tree written in C, with Ruby bindings. Each node caches the hull of the
9
+ subtree it roots, so intersection queries prune whole subtrees in constant
10
+ time.
11
+ type: software
12
+ license: CECILL-C
13
+ repository-code: "https://github.com/anlsys/ruby-disjoint-interval-tree"
14
+ authors:
15
+ - family-names: Pereira
16
+ given-names: Romain
17
+ email: rpereira@anl.gov
18
+ affiliation: Argonne National Laboratory
19
+ orcid: "https://orcid.org/0000-0003-1856-2172"
20
+ keywords:
21
+ - interval tree
22
+ - AVL tree
23
+ - augmented tree
24
+ - ruby
25
+ - c
26
+
27
+ references:
28
+ - type: conference-paper
29
+ title: >-
30
+ Taskgrind: Heavyweight Dynamic Binary Instrumentation for Parallel
31
+ Programs Analysis
32
+ authors:
33
+ - family-names: Pereira
34
+ given-names: Romain
35
+ - family-names: Stelle
36
+ given-names: George
37
+ - family-names: Carribault
38
+ given-names: Patrick
39
+ collection-title: >-
40
+ SC24-W: Workshops of the International Conference for High Performance
41
+ Computing, Networking, Storage and Analysis
42
+ year: 2024
43
+ month: 11
44
+ start: 214
45
+ end: 221
46
+ location:
47
+ name: Atlanta, GA, USA
48
+ doi: 10.1109/SCW63240.2024.00033
49
+ notes: Introduced the SPMT, the disjoint interval tree this software rewrites.
50
+
51
+ - type: conference-paper
52
+ title: Multi-GPU Memory Coherence for BLAS Matrices
53
+ authors:
54
+ - family-names: Pereira
55
+ given-names: Romain
56
+ - family-names: Polet
57
+ given-names: Pierre-Etienne
58
+ - family-names: Gautier
59
+ given-names: Thierry
60
+ - family-names: Perarnau
61
+ given-names: Swann
62
+ collection-title: >-
63
+ IPDPS-W HIPS: 31st International Workshop on High-level Parallel
64
+ Programming Models and Supportive Environments
65
+ year: 2026
66
+ month: 5
67
+ notes: Introduced the LP-Tree, whose subtree hull augment this software reuses.
data/README.md ADDED
@@ -0,0 +1,340 @@
1
+ # disjoint\_interval\_tree
2
+
3
+ A set of **pairwise disjoint half-open intervals `[a..b[`**, implemented in C
4
+ and exposed to Ruby.
5
+
6
+ It is a self-balancing (AVL) binary search tree, augmented - as the LP-Tree it
7
+ is inspired from, see [References](#references) - with the *hull* of the
8
+ subtree each node roots, so that intersection queries prune whole subtrees in
9
+ `O(1)`.
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ gem install disjoint_interval_tree
15
+ ```
16
+
17
+ or, in a `Gemfile`:
18
+
19
+ ```ruby
20
+ gem 'disjoint_interval_tree'
21
+ ```
22
+
23
+ Ruby >= 2.7 and a C compiler are required: the extension is built at install
24
+ time.
25
+
26
+ ## Synopsis
27
+
28
+ ```ruby
29
+ require 'disjoint_interval_tree'
30
+
31
+ tree = DisjointIntervalTree.new
32
+ tree.insert(10, 20)
33
+ tree.insert(30, 40)
34
+
35
+ tree.intersect(15, 35) do |a, b|
36
+ puts "[#{a}..#{b}["
37
+ end
38
+ # => [10..20[
39
+ # => [30..40[
40
+
41
+ tree.remove(15, 35) # => 2
42
+ tree.to_a # => []
43
+ ```
44
+
45
+ ## Semantics
46
+
47
+ * Intervals are **half-open**: `[a..b[` contains `a` but not `b`. `[0..10[` and
48
+ `[10..20[` are adjacent, they do **not** intersect.
49
+ * Bounds are **unsigned 64 bits integers**, so intervals live in
50
+ `[0 .. DisjointIntervalTree::MAX[`. Negative or non-integer bounds raise.
51
+ * Stored intervals are **pairwise disjoint**. It is a usage contract that the
52
+ caller never inserts an interval overlapping an already inserted one.
53
+ Breaking it raises `DisjointIntervalTree::OverlapError` and leaves the tree
54
+ unchanged - intervals are never merged nor split implicitly.
55
+
56
+ ## Operations
57
+
58
+ With `n` intervals stored, `k` intervals reported by the call, and `m`
59
+ intervals handed to the constructor:
60
+
61
+ | operation | description | complexity |
62
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------- |
63
+ | `DisjointIntervalTree.new(intervals = nil)` | Build an empty tree, or one filled with the given `[a, b]` pairs. | `O(m.log m)` |
64
+ | `insert(a, b)` | Add `[a..b[`, raising `OverlapError` if it intersects an already stored interval. | `O(log n)` |
65
+ | `insert?(a, b)` | Same as `insert`, but returns `false` instead of raising when it would overlap. | `O(log n)` |
66
+ | `intersect(a, b, &blk)` | Yield every stored interval intersecting `[a..b[` in increasing order, or return them as an array when given no block. | `O(k + log n)` |
67
+ | `intersect?(a, b)` | Whether at least one stored interval intersects `[a..b[`. | `O(log n)` |
68
+ | `remove(a, b, &blk)` | Remove every stored interval intersecting `[a..b[` - whole, never split - and return how many went. | `O(k.log n)` |
69
+ | `at(x)` | The stored interval containing the point `x`, or `nil`. | `O(log n)` |
70
+ | `cover?(x)` | Whether the point `x` falls inside a stored interval. | `O(log n)` |
71
+ | `hull` | The smallest interval enclosing every stored one, read straight off the root augment. | `O(1)` |
72
+ | `size`, `length` | How many intervals are stored. | `O(1)` |
73
+ | `empty?` | Whether the tree holds no interval at all. | `O(1)` |
74
+ | `height` | Height of the underlying AVL tree, for tests and diagnostics. | `O(1)` |
75
+ | `coverage` | Total length covered, that is the sum of the lengths of the stored intervals. | `O(n)` |
76
+ | `each(&blk)` | Yield every stored interval in increasing order, or return an `Enumerator` when given no block. | `O(n)` |
77
+ | `to_a`, `entries` | Every stored interval as an array of `[a, b]` pairs, in increasing order. | `O(n)` |
78
+ | `map`, `select`, ... | Anything `Enumerable` provides, built on `each`. | `O(n)` |
79
+ | `clear` | Drop every interval, leaving the tree usable. | `O(n)` |
80
+ | `dup` | A deep copy, sharing no node with the original. | `O(n.log n)` |
81
+ | `==` | Whether two trees hold exactly the same intervals. | `O(n)` |
82
+ | `check!` | Re-derive every structural invariant, raising `CorruptedError` if one is broken. | `O(n)` |
83
+ | `inspect`, `to_s` | A short `#<DisjointIntervalTree size=... height=...>` summary. | `O(1)` |
84
+
85
+ ## Ruby API
86
+
87
+ ```ruby
88
+ tree = DisjointIntervalTree.new # empty
89
+ tree = DisjointIntervalTree.new([[0, 10], [20, 30]]) # pre-filled
90
+
91
+ # --- the three core operations -------------------------------------------
92
+
93
+ tree.insert(a, b) # -> self, raises OverlapError if it overlaps
94
+ tree.insert?(a, b) # -> true / false instead of raising
95
+
96
+ tree.intersect(a, b) { |x, y| ... } # -> self, yields in increasing order
97
+ tree.intersect(a, b) # -> [[x, y], ...] when no block given
98
+
99
+ tree.remove(a, b) # -> number of intervals removed
100
+ tree.remove(a, b) { |x, y| ... } # ... and yields each removed interval
101
+
102
+ # --- queries --------------------------------------------------------------
103
+
104
+ tree.intersect?(a, b) # -> true if any stored interval intersect [a..b[
105
+ tree.at(x) # -> [a, b] containing the point x, or nil
106
+ tree.cover?(x) # -> true if x is covered by a stored interval
107
+ tree.hull # -> [a, b] spanning every interval, or nil
108
+ tree.size # -> number of stored intervals
109
+ tree.empty?
110
+ tree.height # -> height of the underlying AVL tree
111
+
112
+ # --- iteration (DisjointIntervalTree includes Enumerable) -----------------
113
+
114
+ tree.each { |a, b| ... }
115
+ tree.to_a # -> [[a, b], ...], in increasing order
116
+ tree.map { |a, b| b - a }
117
+ tree.coverage # -> total length covered
118
+
119
+ # --- misc -----------------------------------------------------------------
120
+
121
+ tree.clear # -> self
122
+ tree.dup # -> deep copy
123
+ tree == other
124
+ tree.check! # -> self, raises CorruptedError if an invariant
125
+ # of the underlying tree is broken
126
+ ```
127
+
128
+ Aliases: `each_intersecting` (`intersect`), `overlaps?` (`intersect?`),
129
+ `length` (`size`), `entries` (`to_a`).
130
+
131
+ The tree **must not be modified from within `each` or `intersect`**: doing so
132
+ raises `DisjointIntervalTree::Error` rather than corrupting the structure. The
133
+ block given to `remove` is called after the removal, on a snapshot, so it may
134
+ modify the tree.
135
+
136
+ ## C API
137
+
138
+ The Ruby extension is a thin binding over a standalone, dependency-free C
139
+ library made of `ext/disjoint_interval_tree/dit.{c,h}`. It can be vendored as
140
+ is into a C or C++ project.
141
+
142
+ ```c
143
+ #include "dit.h"
144
+
145
+ static int print_cb(dit_value_t a, dit_value_t b, void * user)
146
+ {
147
+ (void) user;
148
+ printf("[%lu..%lu[\n", a, b);
149
+ return 0; /* non-zero stops the traversal */
150
+ }
151
+
152
+ dit_t tree;
153
+ dit_init(&tree);
154
+
155
+ if (dit_insert(&tree, 0, 10) != DIT_OK)
156
+ { /* DIT_EMPTY, DIT_OVERLAP or DIT_NOMEM */ }
157
+
158
+ dit_intersect(&tree, 5, 25, print_cb, NULL);
159
+ dit_remove(&tree, 5, 25);
160
+
161
+ dit_destroy(&tree);
162
+ ```
163
+
164
+ ### Node augments
165
+
166
+ Each node stores its own interval, its children, and - in a single `augment`
167
+ field - everything it caches about the subtree it roots:
168
+
169
+ ```c
170
+ typedef struct dit_augment_s
171
+ {
172
+ /* the englobing interval of the subtree, i.e. the smallest interval
173
+ * including every interval stored in that subtree */
174
+ struct { dit_value_t a, b; } hull;
175
+
176
+ /* height of the subtree, a leaf has 1 */
177
+ int32_t height;
178
+
179
+ /* number of nodes in the subtree */
180
+ uint32_t size;
181
+ } dit_augment_t;
182
+ ```
183
+
184
+ Augments are derived from the subtree alone and recomputed bottom-up after
185
+ every structural change, so a rotation only has to refresh the two nodes it
186
+ moves. `hull` is what lets `dit_intersect()` discard a whole subtree with a
187
+ single comparison; the root one is readable in O(1) through `dit_hull()`.
188
+
189
+ Customization points, to define before including `dit.h`:
190
+
191
+ | macro | default | purpose |
192
+ | --------------- | ---------------------- | ----------------------------------------------- |
193
+ | `DIT_VALUE_T` | `uint64_t` | interval bound type |
194
+ | `DIT_ASSERT` | `assert` | internal consistency assertions |
195
+ | `DIT_MALLOC` | `malloc` / `free` | node allocation |
196
+ | `DIT_PARANOID` | `0` | run `dit_check()` after every mutation |
197
+
198
+ ### Invariants
199
+
200
+ `dit_check()` validates, without ever aborting, that:
201
+
202
+ 1. every stored interval is non-empty (`a < b`),
203
+ 2. the binary search tree ordering holds, which - the bounds narrowing at each
204
+ level - also proves the intervals are pairwise disjoint,
205
+ 3. an in-order traversal yields increasing, non-overlapping intervals,
206
+ 4. the `height` augment of every node is correct,
207
+ 5. every node is AVL-balanced,
208
+ 6. the `size` augment of every node is correct,
209
+ 7. the `hull` augment of every node englobes its subtree exactly,
210
+ 8. that hull spans from the leftmost to the rightmost descendant,
211
+ 9. the cached cardinality matches the number of nodes,
212
+ 10. the tree depth is logarithmic in the number of nodes,
213
+ 11. no traversal is leaking.
214
+
215
+ On top of that, the implementation is littered with `DIT_ASSERT`s on the
216
+ invariants it relies on locally (rotations, augment refresh, deletion cases,
217
+ insertion contract, ...).
218
+
219
+ ## Building and testing
220
+
221
+ Building the extension needs the ruby development headers (`ruby-dev` /
222
+ `ruby-devel`, or any ruby built from source).
223
+
224
+ ```sh
225
+ rake compile # build the extension into lib/
226
+ rake recompile # force a full rebuild, use this when in doubt
227
+ rake test # ruby test suite
228
+ rake test:c # C test suite: debug, asan+ubsan, release
229
+ rake test:valgrind # C test suite under valgrind
230
+ rake test:paranoid # ruby test suite against a DIT_PARANOID build
231
+ rake test:gem # build, install into a sandbox, test the installed gem
232
+ rake test:files # every file the gem ships is present and tracked by git
233
+ rake # default: test:c + test
234
+ rake verify # everything above, plus a randomized seed sweep
235
+ ```
236
+
237
+ What each configuration actually proves:
238
+
239
+ | configuration | proves |
240
+ | -------------------------------------- | --------------------------------------------------------------------------------------------- |
241
+ | `test:c` debug (`-O0 -DDIT_PARANOID=1`) | all the invariants below are re-validated after *every* mutation, plus the inline `DIT_ASSERT`s |
242
+ | `test:c` sanitize | no undefined behaviour, no out of bounds access, no leak, still paranoid |
243
+ | `test:c` release (`-DNDEBUG`) | the behaviour is identical with every assertion compiled out |
244
+ | `test:valgrind` | no invalid access and no leak, independently of the sanitizers |
245
+ | `test` / `test:paranoid` | the ruby bindings, against the regular and the paranoid extension |
246
+ | `test:gem` | the *packaged* gem installs and works, which `test` cannot tell |
247
+ | `test:files` | the gem and the repository ship the same files, which neither of the above can tell |
248
+
249
+ The C test suite can also be driven directly:
250
+
251
+ ```sh
252
+ make -C test/c # debug, sanitized and release builds
253
+ make -C test/c debug SEED=42
254
+ ```
255
+
256
+ Both suites cross-check the tree against a naive reference implementation on
257
+ randomized workloads, and validate every structural invariant after each
258
+ operation. `rake verify SEEDS=100` widens the randomized sweep.
259
+
260
+ ## Releasing
261
+
262
+ Releases are cut from `main`, with CI green.
263
+
264
+ **1. Bump the version.**
265
+
266
+ ```ruby
267
+ # lib/disjoint_interval_tree/version.rb
268
+ VERSION = '0.2.0'
269
+ ```
270
+
271
+ **2. Commit, push, and wait for CI.** `rake release` re-runs everything
272
+ locally, but a release should not be cut from a commit CI has not seen.
273
+
274
+ **3. Publish.**
275
+
276
+ ```sh
277
+ rake release
278
+ ```
279
+
280
+ It refuses to do anything unless the working tree is clean, the branch is
281
+ `main`, and the tag is still free. It then runs `rake verify`, tags `vX.Y.Z`,
282
+ pushes the tag, and `gem push`es the gem it just built.
283
+
284
+ **4. Attach the gem to a GitHub release.** `rake release` prints this command
285
+ with the version filled in:
286
+
287
+ ```sh
288
+ gh release create vX.Y.Z pkg/disjoint_interval_tree-X.Y.Z.gem \
289
+ --title vX.Y.Z --generate-notes
290
+ ```
291
+
292
+ **5. Update the downstream packagers.** `rake release` prints the sha256 of
293
+ what it published, as a ready to paste spack `version(...)` line. For
294
+ [THAPI-spack](https://github.com/argonne-lcf/THAPI-spack), that is
295
+ `packages/ruby-disjoint-interval-tree/package.py`. The published artifact can
296
+ also be checksummed directly, which is the authoritative source:
297
+
298
+ ```sh
299
+ spack checksum ruby-disjoint-interval-tree X.Y.Z
300
+ ```
301
+
302
+ ## References
303
+
304
+ `dit` is a C rewrite of two structures published by the author:
305
+
306
+ * the **SPMT**, a red-black tree of disjoint memory intervals that merges
307
+ adjacent ranges, used to track memory accesses in Taskgrind:
308
+
309
+ > R. Pereira, G. Stelle and P. Carribault, *"Taskgrind: Heavyweight Dynamic
310
+ > Binary Instrumentation for Parallel Programs Analysis"*, SC24-W: Workshops
311
+ > of the International Conference for High Performance Computing, Networking,
312
+ > Storage and Analysis, Atlanta, GA, USA, 2024, pp. 214-221,
313
+ > doi: [10.1109/SCW63240.2024.00033](https://doi.org/10.1109/SCW63240.2024.00033).
314
+
315
+ * the **LP-Tree**, a k-dimensional interval tree where each node caches the
316
+ hyperrectangle hull of its subtree, used to track matrix tile coherence
317
+ across GPUs:
318
+
319
+ > R. Pereira, P.-E. Polet, T. Gautier and S. Perarnau, *"Multi-GPU Memory
320
+ > Coherence for BLAS Matrices"*, IPDPS-W HIPS: 31st International Workshop on
321
+ > High-level Parallel Programming Models and Supportive Environments, 2026.
322
+
323
+ What this library takes from each, and where it departs from them:
324
+
325
+ * from the **LP-Tree**, the `includes` augment - the hull of the subtree cached
326
+ in every node, here `augment.hull` - and the case analysis of the insertion
327
+ descent. `dit` is the `K = 1` case, so the hyperrectangle collapses to an
328
+ interval, and `includes.hyperrect[0]` to `augment.hull`;
329
+ * from the **SPMT**, the flat C style, the callback-based traversals and the
330
+ coherency-check approach (`dit_check()`);
331
+ * unlike both, `dit` never merges nor splits intervals. Keeping them disjoint
332
+ is a *caller contract*, checked at no extra cost during the insertion
333
+ descent, which is what lets a stored interval keep its identity;
334
+ * unlike the SPMT, the tree is an AVL rather than a red-black tree: rebalancing
335
+ is bottom-up and recursive, which makes the augment refresh and the deletion
336
+ paths markedly harder to get wrong, at the cost of slightly more rotations.
337
+
338
+ ## License
339
+
340
+ CeCILL-C, see the headers of the source files.
data/Rakefile ADDED
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'fileutils'
5
+ require 'rake/clean'
6
+ require 'rbconfig'
7
+
8
+ require_relative 'lib/disjoint_interval_tree/version'
9
+
10
+ EXT_NAME = 'disjoint_interval_tree'
11
+ EXT_DIR = File.expand_path("ext/#{EXT_NAME}", __dir__)
12
+ LIB_DIR = File.expand_path("lib/#{EXT_NAME}", __dir__)
13
+ BUILD_DIR = File.expand_path("tmp/#{RUBY_PLATFORM}/#{EXT_NAME}/#{RUBY_VERSION}", __dir__)
14
+ GEM_DIR = File.expand_path('tmp/gemtest', __dir__)
15
+ DLEXT = RbConfig::CONFIG['DLEXT']
16
+ SO_NAME = "#{EXT_NAME}.#{DLEXT}"
17
+ SO_PATH = File.join(LIB_DIR, SO_NAME)
18
+ RUBY_BIN = RbConfig.ruby
19
+ NPROC = Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4
20
+ VERSION = DisjointIntervalTree::VERSION
21
+ GEM_FILE = File.expand_path("pkg/#{EXT_NAME}-#{VERSION}.gem", __dir__)
22
+
23
+ SOURCES = FileList["#{EXT_DIR}/*.c", "#{EXT_DIR}/*.h", "#{EXT_DIR}/extconf.rb"]
24
+
25
+ CLEAN.include('tmp')
26
+ CLOBBER.include(SO_PATH, 'pkg')
27
+
28
+ # Number of distinct RNG seeds the randomized tests are replayed with by
29
+ # `rake verify`. Both suites take a seed, and those randomized cross-checks
30
+ # against a naive model are the strongest correctness signal there is here.
31
+ SEEDS = Integer(ENV.fetch('SEEDS', 20))
32
+
33
+ # `rake compile EXTOPTS=--enable-paranoid` to build an extension running the
34
+ # full coherency check after every mutation
35
+ EXTOPTS = (ENV['EXTOPTS'] || '').split
36
+
37
+ directory BUILD_DIR
38
+ directory LIB_DIR
39
+
40
+ file SO_PATH => SOURCES + [BUILD_DIR, LIB_DIR] do
41
+ Dir.chdir(BUILD_DIR) do
42
+ sh(RUBY_BIN, File.join(EXT_DIR, 'extconf.rb'), *EXTOPTS)
43
+ sh('make', "-j#{NPROC}")
44
+ end
45
+ FileUtils.cp(File.join(BUILD_DIR, SO_NAME), SO_PATH, verbose: true)
46
+ end
47
+
48
+ desc 'Build the C extension'
49
+ task compile: SO_PATH
50
+
51
+ # `clean` alone would not be enough: rake never considers a file task out of
52
+ # date because of a directory prerequisite, so the stale .so has to go
53
+ desc 'Rebuild the C extension from scratch'
54
+ task :recompile do
55
+ Rake::Task[:clean].invoke
56
+ FileUtils.rm_f(SO_PATH)
57
+ [BUILD_DIR, LIB_DIR, SO_PATH].each { |t| Rake::Task[t].reenable }
58
+ Rake::Task[SO_PATH].invoke
59
+ end
60
+
61
+ desc 'Run the ruby test suite'
62
+ task test: :compile do
63
+ sh(RUBY_BIN, '-Ilib', '-Itest', 'test/test_disjoint_interval_tree.rb')
64
+ end
65
+
66
+ namespace :test do
67
+ desc 'Run the C test suite (debug, sanitizers and release builds)'
68
+ task :c do
69
+ sh('make', '-C', 'test/c', 'all')
70
+ end
71
+
72
+ desc 'Run the C test suite under valgrind'
73
+ task :valgrind do
74
+ sh('make', '-C', 'test/c', 'valgrind')
75
+ end
76
+
77
+ # `EXTOPTS` is read when the Rakefile is loaded, hence the sub-invocation.
78
+ # The regular extension is then rebuilt, so that a paranoid - and much
79
+ # slower - build is never left behind in lib/
80
+ desc 'Run the ruby test suite against a paranoid build of the extension'
81
+ task :paranoid do
82
+ sh({ 'EXTOPTS' => '--enable-paranoid' }, RUBY_BIN, $PROGRAM_NAME, 'recompile', 'test')
83
+ ensure
84
+ sh(RUBY_BIN, $PROGRAM_NAME, 'recompile')
85
+ end
86
+
87
+ # This is the only task exercising what users actually get: it runs the suite
88
+ # without `-Ilib`, so `require 'disjoint_interval_tree'` resolves through the
89
+ # installed gem. A file missing from `spec.files`, a broken `extconf.rb` or a
90
+ # wrong `require_paths` fails here and nowhere else.
91
+ desc 'Install the built gem in a sandbox and run the ruby test suite against it'
92
+ task gem: :build do
93
+ FileUtils.rm_rf(GEM_DIR)
94
+ FileUtils.mkdir_p(GEM_DIR)
95
+
96
+ # the sandbox is the install target, but the default gem dirs stay on the
97
+ # path so that the test suite can still find minitest
98
+ env = {
99
+ 'GEM_HOME' => GEM_DIR,
100
+ 'GEM_PATH' => ([GEM_DIR] + Gem.path).uniq.join(File::PATH_SEPARATOR)
101
+ }
102
+
103
+ sh(env, 'gem', 'install', '--norc', '--no-document', '--local', GEM_FILE)
104
+
105
+ # the gem must come from the sandbox, not from a copy installed elsewhere
106
+ sh(env, RUBY_BIN, '-e', <<~RUBY)
107
+ gem 'disjoint_interval_tree', '= #{VERSION}'
108
+ require 'disjoint_interval_tree'
109
+
110
+ path = Gem.loaded_specs['disjoint_interval_tree'].full_gem_path
111
+ raise "loaded \#{path}, expected it under #{GEM_DIR}" unless path.start_with?('#{GEM_DIR}')
112
+
113
+ tree = DisjointIntervalTree.new([[0, 10], [20, 30]])
114
+ raise 'unexpected content' unless tree.intersect(5, 25) == [[0, 10], [20, 30]]
115
+ raise 'unexpected removal' unless tree.remove(5, 25) == 2
116
+ tree.check!
117
+
118
+ puts "installed gem \#{DisjointIntervalTree::VERSION} loads from \#{path}"
119
+ RUBY
120
+
121
+ sh(env, RUBY_BIN, '-Itest', 'test/test_disjoint_interval_tree.rb')
122
+ end
123
+
124
+ desc 'Replay the randomized tests over SEEDS distinct seeds (default 20)'
125
+ task seeds: :compile do
126
+ (1..SEEDS).each do |seed|
127
+ sh('make', '-C', 'test/c', 'debug', "SEED=#{seed}")
128
+ sh({ 'SEED' => seed.to_s }, RUBY_BIN, '-Ilib', '-Itest',
129
+ 'test/test_disjoint_interval_tree.rb')
130
+ end
131
+ end
132
+
133
+ # `spec.files` is a `Dir[]` glob over the working tree, so a file that is
134
+ # gitignored, or simply never added, still ends up in the gem while being
135
+ # absent from the repository. That asymmetry is silent, and it is how
136
+ # `test/c/Makefile` - matched by a bare `Makefile` ignore rule - shipped in
137
+ # the gem but broke every CI job.
138
+ desc 'Check that every file the gem ships is present and tracked by git'
139
+ task :files do
140
+ spec = Gem::Specification.load("#{EXT_NAME}.gemspec")
141
+ problems = []
142
+
143
+ spec.files.sort.each do |path|
144
+ problems << "#{path}: listed by the gemspec but missing on disk" unless File.exist?(path)
145
+
146
+ ignored = !`git check-ignore -- #{path}`.empty?
147
+ problems << "#{path}: shipped in the gem but matched by .gitignore" if ignored
148
+
149
+ tracked = system("git ls-files --error-unmatch -- #{path} > /dev/null 2>&1")
150
+ problems << "#{path}: shipped in the gem but not tracked by git" if !tracked && !ignored
151
+ end
152
+
153
+ # the repository needs these too, even though the gem does not ship them
154
+ ['.gitignore', 'Rakefile', '.github/workflows/ci.yml'].each do |path|
155
+ next if `git check-ignore -- #{path}`.empty?
156
+
157
+ problems << "#{path}: matched by .gitignore"
158
+ end
159
+
160
+ unless problems.empty?
161
+ problems.each { |p| warn " #{p}" }
162
+ abort "test:files: #{problems.size} problem(s)"
163
+ end
164
+
165
+ puts "test:files: the #{spec.files.size} files the gem ships are all present and tracked"
166
+ end
167
+ end
168
+
169
+ desc 'Run both the C and the ruby test suites'
170
+ task default: ['test:c', :test]
171
+
172
+ desc 'Run every test suite: C, sanitizers, valgrind, ruby, paranoid, packaged gem, seed sweep'
173
+ task verify: ['test:files', 'test:c', 'test:valgrind', :test, 'test:paranoid', 'test:gem',
174
+ 'test:seeds'] do
175
+ puts
176
+ puts "#{EXT_NAME} #{VERSION}: everything passed"
177
+ end
178
+
179
+ # `gem build` stamps the gem with the current date, which makes it a different
180
+ # file on every run. Pinning SOURCE_DATE_EPOCH to the last commit makes the
181
+ # build byte-reproducible, so the sha256 a downstream packager - spack, nix,
182
+ # a distro - records for a release can be recomputed and checked.
183
+ def source_date_epoch
184
+ ENV['SOURCE_DATE_EPOCH'] || begin
185
+ epoch = `git log -1 --format=%ct 2> /dev/null`.strip
186
+ epoch.empty? ? Time.now.to_i.to_s : epoch
187
+ end
188
+ end
189
+
190
+ desc 'Build the gem into pkg/'
191
+ task :build do
192
+ require 'digest'
193
+
194
+ FileUtils.mkdir_p('pkg')
195
+ sh({ 'SOURCE_DATE_EPOCH' => source_date_epoch }, 'gem', 'build', '--norc', "#{EXT_NAME}.gemspec")
196
+ FileUtils.mv(FileList["#{EXT_NAME}-*.gem"].to_a, 'pkg', verbose: true)
197
+
198
+ puts
199
+ puts "sha256(#{File.basename(GEM_FILE)}) = #{Digest::SHA256.file(GEM_FILE).hexdigest}"
200
+ puts 'spack recipe line:'
201
+ puts %( version("#{VERSION}", sha256="#{Digest::SHA256.file(GEM_FILE).hexdigest}", expand=False))
202
+ puts
203
+ end
204
+
205
+ task gem: :build
206
+
207
+ desc "Verify, tag and publish v#{DisjointIntervalTree::VERSION} to rubygems.org"
208
+ task :release do
209
+ abort 'release: the working tree has uncommitted changes' unless `git status --porcelain`.empty?
210
+
211
+ branch = `git rev-parse --abbrev-ref HEAD`.strip
212
+ abort "release: on branch #{branch}, expected main" unless branch == 'main'
213
+
214
+ tag = "v#{VERSION}"
215
+ abort "release: #{tag} already exists" if system("git rev-parse -q --verify refs/tags/#{tag} > /dev/null")
216
+
217
+ Rake::Task[:verify].invoke
218
+
219
+ sh('git', 'tag', '-a', tag, '-m', "#{EXT_NAME} #{VERSION}")
220
+ sh('git', 'push', 'origin', 'main', '--follow-tags')
221
+ sh('gem', 'push', GEM_FILE)
222
+
223
+ require 'digest'
224
+ puts
225
+ puts "published #{tag}."
226
+ puts
227
+ puts 'attach the gem to the github release with:'
228
+ puts " gh release create #{tag} #{GEM_FILE} --title #{tag} --generate-notes"
229
+ puts
230
+ puts 'and record this in the spack recipe:'
231
+ puts %( version("#{VERSION}", sha256="#{Digest::SHA256.file(GEM_FILE).hexdigest}", expand=False))
232
+ end