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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28307dc01292f1215dcfb5466ba9fe182633346c622d69d336456983c00276e2
4
- data.tar.gz: 24520e65a734fb920588210d4d9b829ef0ec0997178e0ab88b708d49af604ad1
3
+ metadata.gz: 84880d06ec622bb6081d11f260b1e64cbfba37a9480a717208356afbfcc28108
4
+ data.tar.gz: 5e7c2cba1e6fb6ecfd5f6cc49c517ff038ca09a85dc81da268e69fe74142172d
5
5
  SHA512:
6
- metadata.gz: 8a29c867cdb4fa7f3e7d74c27f7556d8ecb50c08706f814d22c9ff43c90dfa776c4a4a502969a3708c67bdb9bccb8f6149c09d3656105ea97b3e925aaa378519
7
- data.tar.gz: 36f141d074afb7bce936876e301df6d1884881c9806803619fea3d9f20f86aa0627c2ef5baaeb6c7bb5ea23041748d72c9d2891084bc9c92fce65f0ecd094edf
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 sequences. It provides a generic summary B+ tree and a Unicode-aware text rope built on it. Every edit returns a new value while reusing untouched subtrees, so retaining a snapshot is an ordinary assignment.
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 and store only replacement text in memory; call `materialize` when an immutable `Rope` snapshot is needed. A file changed, removed, or replaced after opening raises `Denebola::Error`. `to_s` and `materialize` without a range intentionally load the complete logical file.
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:
@@ -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? = @file.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
- @file.close unless @file.closed?
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 @file.closed?
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