Skip to content

perf(btreemap): add insert_many for batched insertion#440

Open
hpeebles wants to merge 9 commits into
dfinity:mainfrom
hpeebles:insert-many
Open

perf(btreemap): add insert_many for batched insertion#440
hpeebles wants to merge 9 commits into
dfinity:mainfrom
hpeebles:insert-many

Conversation

@hpeebles

@hpeebles hpeebles commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

insert writes the target node to stable memory on every call, and when a node splits it writes the new sibling and the parent too. Inserting a run of keys therefore rewrites the same nodes repeatedly: building a 10k-entry map one key at a time performs 15,976 node writes across just 1,997 distinct nodes.

insert_many keeps the current root-to-leaf path in memory and saves a modified node to stable memory only once the input has moved past it. The same 10k keys come down to 1,997 writes — exactly the number of distinct nodes, so every node is written once and none is rewritten. The header is likewise written once per call rather than once per key.

map.insert_many((0..1_000_000).map(|i| (i, compute(i))));

The iterator is consumed lazily and never collected, so the memory cost is one tree path however many pairs are supplied. BTreeSet::insert_many delegates to it.

Ordering

Coalescing pays off when consecutive keys land in the same node, so keys should be supplied in sorted order. Either direction works: a descending run unwinds the path leftwards instead of rightwards and performs an identical number of writes.

Descending does cost more CPU, as the table below shows — not from any extra I/O, but because an ascending key appends to a node's entry vector while a descending one inserts at index 0 and shifts everything after it. The bench_scope build attributes the whole difference to that shift.

Out-of-order keys stay correct rather than corrupting the tree. Each level of the held path records the key range its node owns at both ends, so a key arriving early unwinds to an ancestor that covers it, as far as the root if necessary. Tracking the lower bound as well as the upper one costs a comparison per key.

Those recorded ranges are also what lets the fast path skip re-searching the ancestors it is still holding. A key stored in an ancestor is one of that ancestor's separators, and the separators are exactly the exclusive bounds handed down to the children, so a key that any held node covers cannot be sitting in a node above it.

Measurements

canbench. The first five rows insert 10k u64 pairs; the last overwrites 2k existing keys whose 1 KiB values live on V2 overflow pages.

workload insert loop insert_many
ascending, empty map 333.94M 34.23M 9.8x
descending, empty map 386.40M 50.88M 7.6x
unordered, caller sorts 356.18M 43.76M 8.1x
unordered, fed as-is 356.18M 335.18M 1.1x
ascending run into 100k 293.82M 47.37M 6.2x
overwrite, 1 KiB values 74.29M 10.08M 7.4x

The sort in the third row is inside the measured section. Unordered input fed straight in forfeits the coalescing, but is not slower than the insert loop it replaces.

The overwrite row gains from a second effect. insert has to read the displaced value back in order to return it, which for a value on overflow pages means reading it out of stable memory; insert_many returns nothing, so the new Node::set_value replaces the value without loading the old one. Measured separately, coalescing alone accounts for 74.29M -> 14.64M and skipping the discarded reads takes it the rest of the way to 10.08M.

Implementation notes

Holding the whole path rather than only the leaf is what makes this work. Two thirds of the writes in a split-heavy workload are the sibling and the parent, and a held parent absorbs many child splits before it is written; holding only the leaf coalesces 2x rather than 9x.

Splits are handled entirely in place. A leaf split promotes a median into its parent, which may itself be full, so the split cascades: split_leaf walks up to the topmost full level, grows a new root if the whole path is full, then splits top-down — each split leaves its node half empty, so the level below always has room for the median it promotes. The levels below a split keep the bounds they already hold, since the separators either side of them survive and simply land in one half or the other; only their index among the parent's children moves, by the number of children that stayed with the left half.

The batch machinery lives in src/btreemap/bulk_insert.rs, leaving BTreeMap::insert_many in btreemap.rs as a thin wrapper over it. Dropping BulkInsert writes back whatever it still holds, which is how a batch is completed: the drop is load-bearing on every call.

A batch is split into four scopes — bulk_insert_release, bulk_insert_descend, bulk_insert_leaf and bulk_insert_split — behind the existing bench_scope feature, as node/v1.rs and node/v2.rs already do for node loads and saves. The feature is off in the benchmark build, so the default wasm is unchanged and the committed results re-run at change: max 0.

The scopes have already paid for themselves: bulk_insert_leaf minus bulk_insert_split isolates the cost of placing an entry, and it was far larger than a binary search over at most eleven keys could explain — which turned out to be a second, redundant search of the leaf on every insert that did not split.

insert is untouched — the diff to btreemap.rs is purely additive. BulkInsert carries the value unserialized and encodes it at the point of use. The new benchmarks name their plain-loop baselines insert_loop_* so they read as the counterparts of insert_many_*. All 252 pre-existing benchmarks are unchanged to within noise.

Public API added

  • BTreeMap::insert_many
  • BTreeSet::insert_many

Testing

Differential proptests and unit tests build the same map with repeated insert and with insert_many, and require the results to be indistinguishable: across node cache sizes 0/1/16, the V1, V1-migrated-to-V2 and V2 layouts, unbounded values large enough to spill onto overflow pages, duplicate and overwriting batches, and batch sizes chosen to hit the cascade at the node capacity boundaries. Ordering is covered by ascending, descending, zigzag, sawtooth and shuffled inputs. Batches large enough to grow the root repeatedly are drained back to empty afterwards, so a mislinked child or a stale index would show up as chunks the allocator never gets back.

Those all check behaviour, and so would notice a broken tree only through its consequences. assert_btree_invariants checks the shape itself: it walks the tree out of memory and asserts that every node is within its occupancy bounds, that keys are sorted and fall strictly between the separators either side of them in the parent, that an internal node has one more child than it has entries, that every leaf sits at the same depth, and that the entries counted match the length in the header. Its driver interleaves batches of varying shape and ordering with single inserts and removes, walking the tree after every step, so a split that linked a child wrongly fails at that node rather than wherever it happens to surface.

🤖 Generated with Claude Code

`insert` writes the target node to stable memory on every call, and when
a node splits it writes the new sibling and the parent too. Inserting a
run of keys therefore rewrites the same nodes repeatedly: building a
10k-entry map one key at a time performs 15,976 node writes across just
1,997 distinct nodes.

`insert_many` keeps the current root-to-leaf path in memory and writes a
node out only once the input has moved past it, bringing those same 10k
keys down to 4,032 writes. The header is likewise written once per call
rather than once per key.

    map.insert_many((0..1_000_000).map(|i| (i, compute(i))));

The iterator is consumed lazily and never collected, so the memory cost
is one tree path however many pairs are supplied.

## Ordering

Coalescing pays off when consecutive keys land in the same node, so keys
should be supplied in sorted order. Direction is irrelevant: a
descending run unwinds the path leftwards instead of rightwards and
produces an identical write count.

Out-of-order keys stay correct rather than corrupting the tree. Each
level of the held path records the key range its node owns at both ends,
so a key arriving early unwinds to an ancestor that covers it, as far as
the root if necessary. Tracking the lower bound as well as the upper one
costs a comparison per key.

## Measurements

canbench, 10k u64 pairs each:

  ascending, empty map      334.19M -> 76.91M instructions  (4.3x)
  descending, empty map     386.65M -> 96.43M instructions  (4.0x)
  unordered, caller sorts   356.43M -> 86.45M instructions  (4.1x)
  unordered, fed as-is      356.43M -> 341.07M instructions (1.0x)
  ascending run into 100k   294.07M -> 49.06M instructions  (6.0x)

The sort in the third row is inside the measured section. Unordered
input fed straight in forfeits the coalescing, but is not slower than
the insert loop it replaces.

## Implementation notes

Holding the whole path rather than only the leaf is what makes this
work. Two thirds of the writes in a split-heavy workload are the sibling
and the parent, and a held parent absorbs many child splits before it is
written; holding only the leaf coalesces 2x rather than 4x.

Node splits are handled in place while the path is held, except in two
cases that fall back to the ordinary insert path: a full root leaf, and
a parent with no room for a promoted median. Those fire 277 times per
10k ascending keys, about one insert in 36. A fallback flushes the path,
so nodes it had buffered are written again when the batch re-descends
into them; that accounts for the whole gap between the 4,032 writes
above and the 1,997 that writing each dirty node exactly once would
take. Handling the split cascade in place would close it, at the cost of
considerably more intricate code in the riskiest part of the tree, so it
is left as a possible follow-up.

The batch machinery lives in `src/btreemap/bulk_insert.rs`, leaving
`BTreeMap::insert_many` in `btreemap.rs` as a thin wrapper over it.
Dropping `BulkInsert` writes back whatever it still holds, which is how
a batch is completed: the drop is load-bearing on every call.

`insert` is unchanged; its body moves into `insert_serialized` so the
fallback can reuse it with an already-serialized value. All 252
pre-existing benchmarks are unchanged to within noise.

## Testing

Differential proptests and unit tests build the same map with repeated
`insert` and with `insert_many`, and require the results to be
indistinguishable: across node cache sizes 0/1/16, the V1,
V1-migrated-to-V2 and V2 layouts, unbounded values large enough to spill
onto overflow pages, duplicate and overwriting batches, and batch sizes
chosen to hit both fallbacks. Ordering is covered by ascending,
descending, zigzag, sawtooth and shuffled inputs. Allocator chunk counts
return to zero after draining a map built by `insert_many`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hpeebles
hpeebles requested a review from a team as a code owner July 25, 2026 22:09
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/nns) 90e94fa 2026-07-27 09:43:37 UTC

./benchmarks/nns/canbench_results.yml is up to date
📦 canbench_results_nns.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/memory_manager) 90e94fa 2026-07-27 09:43:32 UTC

./benchmarks/memory_manager/canbench_results.yml is up to date
📦 canbench_results_memory-manager.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/btreeset) 90e94fa 2026-07-27 09:43:37 UTC

./benchmarks/btreeset/canbench_results.yml is up to date
📦 canbench_results_btreeset.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/io_chunks) 90e94fa 2026-07-27 09:43:51 UTC

./benchmarks/io_chunks/canbench_results.yml is up to date
📦 canbench_results_io_chunks.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/btreemap) 90e94fa 2026-07-27 09:44:50 UTC

./benchmarks/btreemap/canbench_results.yml is up to date
📦 canbench_results_btreemap.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max +3.92M | p75 +7.14K | median 0 | p25 0 | min -5.49M]
    change %: [max +0.70% | p75 0.01% | median 0.00% | p25 0.00% | min -0.30%]

  heap_increase:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------

Only significant changes:
| status | name                                          | calls |     ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
|--------|-----------------------------------------------|-------|---------|---------|----|--------|-----|---------|
|  new   | btreemap_v2_insert_loop_desc_u64_u64          |       | 386.40M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_loop_into_existing_u64_u64 |       | 293.82M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_loop_overwrite_1kib_values |       |  74.29M |         |  1 |        |   0 |         |
|  new   | btreemap_v2_insert_loop_random_u64_u64        |       | 356.18M |         |  0 |        |   6 |         |
|  new   | btreemap_v2_insert_many_desc_u64_u64          |       |  50.88M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_many_into_existing_u64_u64 |       |  47.37M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_many_overwrite_1kib_values |       |  10.08M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_many_seq_u64_u64           |       |  34.23M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_many_sorted_random_u64_u64 |       |  43.76M |         |  3 |        |   8 |         |
|  new   | btreemap_v2_insert_many_unsorted_u64_u64      |       | 335.18M |         |  0 |        |   6 |         |

ins = instructions, HI = heap_increase, SMI = stable_memory_increase, Δ% = percent change

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/vec) 90e94fa 2026-07-27 09:43:34 UTC

./benchmarks/vec/canbench_results.yml is up to date
📦 canbench_results_vec.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

hpeebles and others added 8 commits July 25, 2026 23:50
A leaf split promotes a median into its parent, so a full parent used to
end the batch: the path was flushed and the ordinary insert path grew the
tree instead. Every node the path had buffered was then written again on
the way back down, which cost roughly half of the writes `insert_many`
was saving.

The split now cascades. `split_leaf` walks up to the topmost full level,
grows a new root if the whole path is full, then splits top-down: each
split leaves its node half empty, so the level below always has room for
the median it promotes.

Writing each dirty node exactly once is now reached rather than
approached — 10k ascending keys perform 1,997 writes, matching the
number of distinct nodes in the resulting tree, down from 4,032. An
`insert` loop over the same keys performs 15,976.

  ascending, empty map      77.17M -> 35.88M instructions  (4.3x -> 9.3x)
  unordered, caller sorts   86.71M -> 45.42M instructions  (4.1x -> 7.8x)
  descending, empty map     96.58M -> 52.53M instructions  (4.0x -> 7.4x)

Nothing else moves: 262 benchmarks, 0 regressed, 3 improved. The
workloads that barely split — inserting into gaps in an existing map,
pure overwrites, unordered input — are unchanged, which is where the
saving was expected to come from.

The levels below a split keep the bounds they already hold: the
separators either side of them survive, landing in one half or the other.
Only their index among the parent's children moves, by the number of
children that stayed with the left half.

Removing the fallback also removes the last call from `BulkInsert` back
into `BTreeMap::insert`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits a batch into four canbench scopes behind the existing
`bench_scope` feature, matching what `node/v1.rs` and `node/v2.rs`
already do for node loads and saves:

  bulk_insert_release  a node leaving the path, written or cached
  bulk_insert_descend  taking one child out of the cache
  bulk_insert_leaf     placing the entry, including any split
  bulk_insert_split    the split cascade, nested in the previous

The feature is off in the benchmark build, so the default wasm is
unchanged: re-running the `insert_many` benchmarks without it reports
`change: max 0` against the committed results.

The immediate use is attributing the gap between ascending and
descending input, which is otherwise only inferable. Over 10k keys the
whole 16.4M difference sits in `bulk_insert_leaf`, while writes and path
management are identical:

                        asc      desc     delta
  bulk_insert_leaf      44.51M   60.93M   +16.42M
    of which split      25.60M   28.35M    +2.75M
    placing the entry   18.91M   32.58M   +13.67M
  bulk_insert_release    0.07M    0.07M    +0.00M
  node_save_v2          11.99M   12.00M    +0.01M

Ascending keys append to a node's entry vector; descending keys insert
at index 0 and shift everything after it. The 2.75M charged to the split
is the same shift promoting a median into a parent. Nothing else moves —
`node_save_v2` confirms both directions write identically, as the node
counts already showed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`insert_into_leaf` searched the leaf for the insertion point, then
searched it a second time before inserting. The second search exists
because a split invalidates the first answer, but it ran on every
insert, including the majority where no split happens and nothing has
touched the leaf in between.

Reuse the first answer when the leaf was not full, and re-search only
after a split.

  ascending, empty map      35.88M -> 34.23M instructions  (-4.6%)
  ascending run into 100k   49.33M -> 47.37M instructions  (-4.0%)
  unordered, caller sorts   45.42M -> 43.76M instructions  (-3.7%)
  descending, empty map     52.53M -> 50.88M instructions  (-3.2%)

262 benchmarks, 0 regressed. The overwrite and unsorted batches are
unchanged, as expected: overwriting returns on the first search without
ever reaching the second, and unsorted input is dominated by re-descents
rather than by anything in the leaf.

The `bench_scope` scopes added in the previous commit are what made this
visible — `bulk_insert_leaf` minus `bulk_insert_split` isolates the cost
of placing an entry, and it was larger than a single binary search over
at most eleven keys could account for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`split_level` re-indexes the level below when it travels into the right
half, by subtracting the number of children the left half kept. That the
subtraction cannot underflow rests on a several-step argument — the level
below covers the key, the key is above the median, so its child index was
at least the left half's child count — which is now stated where it is
relied upon rather than left to be re-derived.

Behaviour is unchanged; the assertion is compiled out of release builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing tests check behaviour — iteration matches `std`, lookups
agree, draining returns every allocator chunk — so they notice a broken
tree only through its consequences. `insert_many` manipulates the shape
itself when it splits a node and cascades the split upwards, which is
worth checking directly.

`assert_btree_invariants` walks the tree out of memory and asserts that
every node is within its occupancy bounds, that keys are sorted and fall
strictly between the separators either side of them in the parent, that
an internal node has one more child than it has entries, that every leaf
sits at the same depth, and that the entries counted match the length in
the header. The driver interleaves batches of varying shape and ordering
with single inserts and removes, walking the tree after every step.

The value is in where a failure surfaces. Injecting a wrong child index
after a split, a bound left too wide, a cascade run in the wrong
direction, or a dropped ordering check all fail here at the node that
broke, rather than downstream as a key that came back missing.

`B` and `CAPACITY` become `pub(super)` so the occupancy assertions use
the real bounds instead of a copy of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant