denebola 0.2.1 → 0.3.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/CHANGELOG.md +9 -0
- data/README.md +39 -2
- data/lib/denebola/lazy_rope.rb +77 -4
- data/lib/denebola/sheet.rb +796 -0
- data/lib/denebola/sheet_range_index.rb +327 -0
- data/lib/denebola/version.rb +1 -1
- data/lib/denebola.rb +2 -0
- data/sig/denebola.rbs +33 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84880d06ec622bb6081d11f260b1e64cbfba37a9480a717208356afbfcc28108
|
|
4
|
+
data.tar.gz: 5e7c2cba1e6fb6ecfd5f6cc49c517ff038ca09a85dc81da268e69fe74142172d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26cf63ddd365e57f19af10f195db576f21de2e7c1cf865025c6c868cfdc876f33fe8bc055366513106b68b666973eb9cd7a596bd2856273e2de23e358ff22627
|
|
7
|
+
data.tar.gz: b26f9e6372cd7dea2413c82e29996692bca7a810f473b24cdb1fbe13e31e1702746f541b4d0a73d432880733bad5a149d6f42ea31d56c7b3ef2a2d271c4cdec4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Add persistent sparse two-dimensional sheets with range summaries.
|
|
6
|
+
- Add efficient batch writes for importing sparse or dense cell ranges.
|
|
7
|
+
|
|
8
|
+
## 0.2.2 — 2026-09-18
|
|
9
|
+
|
|
10
|
+
- Add persistent batched edits and bounded line-window access to `LazyRope`; related snapshots share one backing-file close lifecycle.
|
|
11
|
+
|
|
3
12
|
## 0.2.1 — 2026-09-16
|
|
4
13
|
|
|
5
14
|
- Keep same-offset batch edit ordering deterministic across ropes and anchors.
|
data/README.md
CHANGED
|
@@ -17,17 +17,19 @@
|
|
|
17
17
|
<a href="#installation">Installation</a> ·
|
|
18
18
|
<a href="#quick-start">Quick Start</a> ·
|
|
19
19
|
<a href="#text-rope">Text Rope</a> ·
|
|
20
|
+
<a href="#sparse-sheet">Sparse Sheet</a> ·
|
|
20
21
|
<a href="#generic-summary-tree">Summary Tree</a> ·
|
|
21
22
|
<a href="#benchmarks">Benchmarks</a>
|
|
22
23
|
</p>
|
|
23
24
|
|
|
24
25
|
---
|
|
25
26
|
|
|
26
|
-
Denebola is a library for immutable, structurally shared
|
|
27
|
+
Denebola is a library for immutable, structurally shared data. It provides a generic summary B+ tree, a Unicode-aware text rope, bounded-memory file editing, and sparse two-dimensional sheets. Every edit returns a new value while reusing untouched subtrees, so retaining a snapshot is an ordinary assignment.
|
|
27
28
|
|
|
28
29
|
## Features
|
|
29
30
|
|
|
30
31
|
- Persistent text editing with structural sharing
|
|
32
|
+
- Persistent sparse 2D sheets with range summaries and batched cell updates
|
|
31
33
|
- Bounded-memory, file-backed editing for multi-gigabyte text
|
|
32
34
|
- UTF-8 byte, Unicode codepoint, UTF-16, and line-based indexing
|
|
33
35
|
- Batched edits and explicit anchor transformation
|
|
@@ -86,7 +88,7 @@ text.materialize(0...1024) # an ordinary editable Rope
|
|
|
86
88
|
text.close
|
|
87
89
|
```
|
|
88
90
|
|
|
89
|
-
`LazyRope` reads 1 MiB chunks by default and keeps eight chunks in an LRU cache. `byteslice`, line access, byte/codepoint positions, UTF-16 positions, and anchors follow `Rope` semantics. `edit`, `insert`, `delete`, and `replace` mutate the open view
|
|
91
|
+
`LazyRope` reads 1 MiB chunks by default and keeps eight chunks in an LRU cache. `byteslice`, line access, byte/codepoint positions, UTF-16 positions, and anchors follow `Rope` semantics. `edit`, `insert`, `delete`, and `replace` mutate the open view; `apply_edits` returns a persistent view while storing only replacement text in memory. Views in that snapshot family share one backing-file lifetime, so closing any view closes the family. Call `materialize` for an independent `Rope` snapshot. A file changed, removed, or replaced after opening raises `Denebola::Error`. `to_s` and `materialize` without a range intentionally load the complete logical file.
|
|
90
92
|
|
|
91
93
|
## Text Rope
|
|
92
94
|
|
|
@@ -150,6 +152,33 @@ anchor.offset # => 5
|
|
|
150
152
|
|
|
151
153
|
`:left` keeps an anchor before text inserted at its position; `:right` keeps it after. Anchors covered by a replacement collapse to the corresponding side of the replacement. Offsets after an edit move by its byte-length delta. Transformation returns a new anchor; it does not mutate the original or automatically observe a rope.
|
|
152
154
|
|
|
155
|
+
## Sparse Sheet
|
|
156
|
+
|
|
157
|
+
`Sheet` stores only populated cells in persistent B+ trees. Empty row and column runs are represented by gaps, so the primary storage can split and join axis ranges without shifting cell nodes. Structural edits currently rebuild the derived 2D summary index from populated cells. Every update returns a new snapshot; `snapshot` returns the same immutable value in O(1).
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
sheet = Denebola::Sheet.new
|
|
161
|
+
sheet = sheet.set(0, 0, 12).set(4, 2, 30)
|
|
162
|
+
previous = sheet.snapshot
|
|
163
|
+
|
|
164
|
+
sheet[4, 2] # => 30
|
|
165
|
+
sheet.summary(0, 0, 4, 2).sum # => 42
|
|
166
|
+
sheet.each_in(0, 0, 4, 2).to_a # => [[Denebola::Point.new(0, 0), 12], [Denebola::Point.new(4, 2), 30]]
|
|
167
|
+
sheet = sheet.insert_rows(2, 1).delete(0, 0)
|
|
168
|
+
previous[0, 0] # => 12
|
|
169
|
+
|
|
170
|
+
sheet = Denebola::Sheet.new.set_many([[0, 0, 12], [0, 1, 8], [1_000_000, 3, "far"]])
|
|
171
|
+
sheet[1_000_000, 3] # => "far"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Coordinates are zero-based, and `each_in`/`summary` use inclusive bounds. `row_count` and `column_count` describe the current grid extent: setting a distant cell and inserting an axis extend it, clearing a cell preserves it, and deleting rows or columns shrinks it. `set(row, column, nil)` is equivalent to `delete`. Strings are copied and frozen; other values should be immutable to preserve snapshots. `summary` counts populated cells, sums Numeric values, reports comparable Numeric minimum/maximum, and counts values by Ruby class in `types`.
|
|
175
|
+
|
|
176
|
+
`each_in` yields `(Denebola::Point, value)`, so it can directly back a cell-source callback that accepts `(reference, value)`.
|
|
177
|
+
|
|
178
|
+
`set_many` accepts an enumerable of `[row, column, value]` edits, uses the last edit for duplicate coordinates, and returns one persistent snapshot. It bulk-builds the row tree after sorting the batch, avoiding a row-tree path rebuild for every cell; multi-column sheets also update the derived range index, while single-column sheets need no index. Untouched row objects and older snapshots remain reusable. Use `set` for isolated edits.
|
|
179
|
+
|
|
180
|
+
Whole-row summaries use the outer row-tree summary without visiting rows or cells. Arbitrary rectangles use a persistent 2D range index: a partial-column query visits O(log rows × log columns) summary nodes and does not enumerate rows or cells. Single-column sheets need no 2D index because every valid column range is the full width. Wider sheets maintain the index, building it when first widened from one column; point edits copy affected paths, and maintaining exact numeric extrema after deletion adds a logarithmic value-index update. Structural row/column insertion and deletion still rebuild this auxiliary index from populated cells, even though the primary sheet trees retain sparse gaps.
|
|
181
|
+
|
|
153
182
|
## Generic Summary Tree
|
|
154
183
|
|
|
155
184
|
Items expose `summary`. The summary class supplies `.zero` and `#+`, with associative addition and an identity. Items and their summaries must be immutable. A dimension is a summary attribute name, a callable, or an object with `from_summary(summary)`; its projection must be monotone along the sequence.
|
|
@@ -189,6 +218,14 @@ Text dimensions are available as `Denebola::Dimensions::BYTES`, `CHARACTERS`, `U
|
|
|
189
218
|
bundle exec rake bench
|
|
190
219
|
```
|
|
191
220
|
|
|
221
|
+
The sheet benchmark builds a sparse grid and reports build, snapshot, full-row summary, and partial-column summary times. `bench/sheet_range_index.rb` measures partial-column query scaling across sparse rows:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
bundle exec ruby -Ilib bench/sheet_range_index.rb
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Measured 2026-09-23 on arm64 macOS, Ruby 4.0.6 without YJIT: batches of 2,000 / 10,000 / 40,000 populated cells (1,000 / 5,000 / 20,000 occupied rows) built in 0.283 / 1.762 / 9.162 s. A one-column summary took 72.69 / 66.33 / 154.42 µs over 500 queries per size. Query time grows with tree depth rather than linearly with selected rows. This workload also shows the 2D index's material build/update cost; million-cell workloads have not yet been remeasured with the index enabled.
|
|
228
|
+
|
|
192
229
|
The benchmark compares fanouts 8/16/32/64 and chunk sizes 256/512/1024/2048 before exercising a 1,000,000-line ASCII document (11,000,000 bytes). Timings are five-batch medians after warmup. The defaults are fanout **16** and chunk size **1024 bytes**: smaller chunks improve some edits but allocate more nodes, while these defaults meet the edit and retained-memory budgets together. Override them with `Rope.new(text, branching: 8, chunk_size: 512)`.
|
|
193
230
|
|
|
194
231
|
Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
data/lib/denebola/lazy_rope.rb
CHANGED
|
@@ -10,6 +10,20 @@ module Denebola
|
|
|
10
10
|
TextPiece = Struct.new(:rope, keyword_init: true) do
|
|
11
11
|
def bytesize = rope.bytesize
|
|
12
12
|
end
|
|
13
|
+
class FileOwner
|
|
14
|
+
def initialize(file)
|
|
15
|
+
@file = file
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def closed? = @file.closed?
|
|
20
|
+
|
|
21
|
+
def close
|
|
22
|
+
@lock.synchronize { @file.close unless @file.closed? }
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
private_constant :FileOwner
|
|
13
27
|
|
|
14
28
|
attr_reader :chunk_size, :cached_bytes
|
|
15
29
|
|
|
@@ -25,6 +39,7 @@ module Denebola
|
|
|
25
39
|
flags = File::RDONLY | File::BINARY
|
|
26
40
|
flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE)
|
|
27
41
|
@file = File.open(path, flags)
|
|
42
|
+
@file_owner = FileOwner.new(@file)
|
|
28
43
|
@path = File.expand_path(path)
|
|
29
44
|
@chunk_size = chunk_size
|
|
30
45
|
@cache_chunks = cache_chunks
|
|
@@ -45,7 +60,7 @@ module Denebola
|
|
|
45
60
|
end
|
|
46
61
|
|
|
47
62
|
def lazy? = true
|
|
48
|
-
def closed? = @
|
|
63
|
+
def closed? = @file_owner.closed?
|
|
49
64
|
|
|
50
65
|
def bytesize
|
|
51
66
|
ensure_unchanged!
|
|
@@ -94,6 +109,26 @@ module Denebola
|
|
|
94
109
|
read_bytes(start, ending - start).force_encoding(Encoding::UTF_8).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
95
110
|
end
|
|
96
111
|
|
|
112
|
+
def line_end(row)
|
|
113
|
+
start = line_start(row)
|
|
114
|
+
index_until_line(row + 1)
|
|
115
|
+
ending = row + 1 < packed_line_count ? unpack_line_start(row + 1) : bytesize
|
|
116
|
+
tail = read_bytes([ending - 3, start].max, [ending - start, 3].min)
|
|
117
|
+
ending - (tail.end_with?("\r\n") ? 2 : tail.end_with?("\xE2\x80\xA8".b, "\xE2\x80\xA9".b) ? 3 : tail.end_with?("\r", "\n") ? 1 : 0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def line_window(row, from: 0, max_bytes: 16_384)
|
|
121
|
+
raise TypeError, "from must be an Integer" unless from.is_a?(Integer)
|
|
122
|
+
raise ArgumentError, "max_bytes must be a nonnegative integer" unless max_bytes.is_a?(Integer) && max_bytes >= 0
|
|
123
|
+
|
|
124
|
+
start, ending = line_start(row), line_end(row)
|
|
125
|
+
offset = (start + from).clamp(start, ending)
|
|
126
|
+
offset -= 1 while offset > start && offset < ending && (read_bytes(offset, 1).getbyte(0) & 0xC0) == 0x80
|
|
127
|
+
value = read_bytes(offset, [max_bytes, ending - offset].min)
|
|
128
|
+
value = value.byteslice(0, complete_utf8_length(value)) unless value.empty?
|
|
129
|
+
[value.force_encoding(Encoding::UTF_8), offset - start]
|
|
130
|
+
end
|
|
131
|
+
|
|
97
132
|
def byteslice(offset, length = nil)
|
|
98
133
|
start, finish = slice_bounds(offset, length)
|
|
99
134
|
Rope.new(read_bytes(start, finish - start).force_encoding(Encoding::UTF_8))
|
|
@@ -149,6 +184,34 @@ module Denebola
|
|
|
149
184
|
def delete(range) = edit(range, "")
|
|
150
185
|
def replace(range, text) = edit(range, text)
|
|
151
186
|
|
|
187
|
+
# Ranges address the original snapshot. The returned view has independent
|
|
188
|
+
# overlays, indexes, and cache. Snapshots share one backing-file lifetime:
|
|
189
|
+
# closing any snapshot closes the complete family.
|
|
190
|
+
def apply_edits(edits)
|
|
191
|
+
normalized = Edit.sort(edits.map do |range, text|
|
|
192
|
+
start, finish = byte_bounds(range)
|
|
193
|
+
validate_offset(start)
|
|
194
|
+
validate_offset(finish)
|
|
195
|
+
[start, finish, normalize_text(text)]
|
|
196
|
+
end)
|
|
197
|
+
previous = 0
|
|
198
|
+
normalized.each do |start, finish, _text|
|
|
199
|
+
raise ArgumentError, "overlapping edits" if start < previous
|
|
200
|
+
previous = finish
|
|
201
|
+
end
|
|
202
|
+
return self if normalized.empty?
|
|
203
|
+
|
|
204
|
+
updated = []
|
|
205
|
+
consumed = 0
|
|
206
|
+
normalized.each do |start, finish, text|
|
|
207
|
+
updated.concat(pieces_for(consumed, start))
|
|
208
|
+
updated << TextPiece.new(rope: Rope.new(text)).freeze unless text.empty?
|
|
209
|
+
consumed = finish
|
|
210
|
+
end
|
|
211
|
+
updated.concat(pieces_for(consumed, bytesize))
|
|
212
|
+
snapshot_with(coalesce(updated))
|
|
213
|
+
end
|
|
214
|
+
|
|
152
215
|
def point_at(byte_offset)
|
|
153
216
|
validate_offset(byte_offset)
|
|
154
217
|
index_to([byte_offset + 1, bytesize].min)
|
|
@@ -236,8 +299,7 @@ module Denebola
|
|
|
236
299
|
end
|
|
237
300
|
|
|
238
301
|
def close
|
|
239
|
-
@
|
|
240
|
-
nil
|
|
302
|
+
@file_owner.close
|
|
241
303
|
end
|
|
242
304
|
|
|
243
305
|
private
|
|
@@ -314,6 +376,17 @@ module Denebola
|
|
|
314
376
|
@piece_ends = @pieces.map { |piece| total += piece.bytesize }
|
|
315
377
|
end
|
|
316
378
|
|
|
379
|
+
def snapshot_with(pieces)
|
|
380
|
+
ensure_unchanged!
|
|
381
|
+
snapshot = dup
|
|
382
|
+
snapshot.instance_variable_set(:@cache, {})
|
|
383
|
+
snapshot.instance_variable_set(:@cached_bytes, 0)
|
|
384
|
+
snapshot.instance_variable_set(:@pieces, pieces.freeze)
|
|
385
|
+
snapshot.__send__(:rebuild_piece_ends)
|
|
386
|
+
snapshot.__send__(:reset_index)
|
|
387
|
+
snapshot
|
|
388
|
+
end
|
|
389
|
+
|
|
317
390
|
def read_bytes(offset, count)
|
|
318
391
|
ensure_unchanged!
|
|
319
392
|
return "".b if count.zero?
|
|
@@ -562,7 +635,7 @@ module Denebola
|
|
|
562
635
|
end
|
|
563
636
|
|
|
564
637
|
def ensure_unchanged!
|
|
565
|
-
raise IOError, "closed file" if
|
|
638
|
+
raise IOError, "closed file" if closed?
|
|
566
639
|
unchanged = file_stamp(@file.stat) == @stamp && file_stamp(File.stat(@path)) == @path_stamp
|
|
567
640
|
raise Error, "file changed on disk; reopen it" unless unchanged
|
|
568
641
|
rescue Errno::ENOENT
|