Skip to content
Draft
24 changes: 24 additions & 0 deletions mbo/container/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,30 @@ cc_library(
],
)

cc_library(
name = "hamt_replace_cc",
hdrs = ["internal/hamt_replace.h"],
deps = [
":hamt_hash_path_cc",
":hamt_lookup_cc",
":hamt_shared_node_cc",
"//mbo/memory:block_source_cc",
],
)

cc_test(
name = "hamt_replace_test",
size = "small",
srcs = ["internal/hamt_replace_test.cc"],
deps = [
":hamt_insert_cc",
":hamt_replace_cc",
"//mbo/memory:block_source_cc",
"@googletest//:gtest",
"@googletest//:gtest_main",
],
)

cc_test(
name = "hamt_clone_test",
size = "small",
Expand Down
13 changes: 13 additions & 0 deletions mbo/container/HAMT_STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,16 @@ Child staging uses bounded stack storage rather than another allocator. Repeated
references are copied independently; this primitive does not preserve graph aliasing or perform
cross-domain structural sharing. Public `clone_to(source)` wrappers also preserve hash/equality
state and container-level metadata; those responsibilities are not handled by this node primitive.

## Persistent value replacement

`TryReplaceHamtEntry` finds an existing key and copies only its affected path, replacing the
stored entry without changing the original snapshot. The replacement must preserve the key
and full hash; public map wrappers enforce immutable keys while replacing mapped values.
Collision siblings and unaffected branches remain unchanged.

Success returns an owned root and `replaced == true`. A missing key returns a retained original
root and `replaced == false`, without allocating; a null root is a successful missing-key result.
`std::nullopt` means allocation failure, with temporary nodes reclaimed and original ownership
unchanged. Release successful roots through their original block source. Copies, destruction,
hash/key extraction, and equality must be non-throwing; access remains externally synchronized.
102 changes: 102 additions & 0 deletions mbo/container/internal/hamt_replace.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
// SPDX-License-Identifier: Apache-2.0

#ifndef MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_
#define MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_

#include <concepts>
#include <cstddef>
#include <iterator>
#include <optional>
#include <type_traits>

#include "mbo/container/internal/hamt_hash_path.h"
#include "mbo/container/internal/hamt_lookup.h"
#include "mbo/container/internal/hamt_shared_node.h"
#include "mbo/memory/block_source.h"

namespace mbo::container::container_internal {

template<typename Node>
struct HamtReplaceResult final {
Node* root;
bool replaced;
};

namespace hamt_replace_internal {

// The target has already been found in this root using this hash.
template<std::size_t FragmentBits, typename Entry, std::unsigned_integral Hash, mbo::memory::BlockSource Source>
[[nodiscard]] std::optional<HamtSharedNode<FragmentBits, Entry>*> TryReplaceAt(
Source& source,
const HamtSharedNode<FragmentBits, Entry>& original,
const HamtHashPath<Hash, FragmentBits>& path,
std::size_t level,
const Entry* target,
const Entry& replacement) noexcept
requires std::is_nothrow_copy_constructible_v<Entry>
{
using Node = HamtSharedNode<FragmentBits, Entry>;
if (original.is_collision()) {
const auto position = static_cast<std::size_t>(std::distance(original.entries().data(), target));
return Node::TryReplaceEntry(source, original, position, replacement);
}
const std::size_t fragment = path.Fragment(level);
if (original.index().Kind(fragment) == HamtSlotKind::kData) {
return Node::TryReplaceEntry(source, original, original.index().DataIndex(fragment), replacement);
}
const std::size_t position = original.index().NodeIndex(fragment);
const Node* const child = original.children().subspan(position).front();
auto* const replaced = TryReplaceAt(source, *child, path, level + 1, target, replacement).value_or(nullptr);
if (replaced == nullptr) {
return std::nullopt;
}
const auto result = Node::TryReplaceChild(source, original, position, replaced);
Node::Release(source, replaced);
return result;
}

} // namespace hamt_replace_internal

// Replacement must preserve the existing key and full hash. Borrows original;
// success owns one root reference, including a retained root for missing keys.
template<
std::unsigned_integral Hash,
std::size_t FragmentBits,
typename Entry,
typename Key,
mbo::memory::BlockSource Source,
typename HashOf,
typename KeyOf,
typename Equal>
requires(
std::is_nothrow_copy_constructible_v<Entry> && std::is_nothrow_invocable_r_v<Hash, const HashOf&, const Entry&>
&& std::is_nothrow_invocable_v<const KeyOf&, const Entry&>
&& std::is_nothrow_invocable_r_v<bool, const Equal&, std::invoke_result_t<const KeyOf&, const Entry&>, const Key&>)
[[nodiscard]] std::optional<HamtReplaceResult<HamtSharedNode<FragmentBits, Entry>>> TryReplaceHamtEntry(
Source& source,
HamtSharedNode<FragmentBits, Entry>* original,
Hash hash,
const Key& key,
const Entry& replacement,
const HashOf& hash_of,
const KeyOf& key_of,
const Equal& equal) noexcept {
using Node = HamtSharedNode<FragmentBits, Entry>;
const Entry* const target = FindHamtEntry(original, hash, key, hash_of, key_of, equal);
if (target == nullptr) {
Node::Retain(original);
return HamtReplaceResult<Node>{.root = original, .replaced = false};
}
const HamtHashPath<Hash, FragmentBits> path(hash);
auto* const replaced =
hamt_replace_internal::TryReplaceAt(source, *original, path, 0, target, replacement).value_or(nullptr);
if (replaced == nullptr) {
return std::nullopt;
}
return HamtReplaceResult<Node>{.root = replaced, .replaced = true};
}

} // namespace mbo::container::container_internal

#endif // MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_
168 changes: 168 additions & 0 deletions mbo/container/internal/hamt_replace_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
// SPDX-License-Identifier: Apache-2.0

#include "mbo/container/internal/hamt_replace.h"

#include <cstdint>

#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "mbo/container/internal/hamt_insert.h"
#include "mbo/memory/block_source.h"

namespace mbo::container::container_internal {
namespace {

using ::testing::Eq;
using ::testing::Field;
using ::testing::IsNull;
using ::testing::NotNull;
using ::testing::Optional;

struct Entry final {
std::uint64_t hash;
int key;
int value;
};

struct HashOf final {
constexpr std::uint64_t operator()(const Entry& entry) const noexcept { return entry.hash; }
};

struct KeyOf final {
constexpr int operator()(const Entry& entry) const noexcept { return entry.key; }
};

struct Equal final {
constexpr bool operator()(int lhs, int rhs) const noexcept { return lhs == rhs; }
};

using Node = HamtSharedNode<5, Entry>;

struct HamtReplaceTest : ::testing::Test {
protected:
void TearDown() override { Node::Release(source, root); }

public:
void Insert(Entry entry) {
const auto inserted = TryInsertHamtEntry(source, root, entry.hash, entry.key, entry, HashOf{}, KeyOf{}, Equal{})
.value_or(HamtInsertResult<Node>{.root = nullptr, .inserted = false});
ASSERT_THAT(inserted.root, NotNull());
Node::Release(source, root);
root = inserted.root;
}

static const Entry* Find(const Node* node, std::uint64_t hash, int key) {
return FindHamtEntry(node, hash, key, HashOf{}, KeyOf{}, Equal{});
}

mbo::memory::NewDeleteBlockSource source;
Node* root = nullptr;
};

TEST_F(HamtReplaceTest, NullRootAndMissingKeysSucceedWithoutAllocation) {
mbo::memory::InlineBlockSource<1> exhausted;
constexpr Entry kReplacement{.hash = 1, .key = 10, .value = 99};
const auto empty = TryReplaceHamtEntry(
exhausted, root, kReplacement.hash, kReplacement.key, kReplacement, HashOf{}, KeyOf{}, Equal{})
.value_or(HamtReplaceResult<Node>{.root = nullptr, .replaced = true});
EXPECT_THAT(empty.root, IsNull());
EXPECT_THAT(empty.replaced, Eq(false));
Insert(Entry{.hash = 2, .key = 20, .value = 20});
const auto missing =
TryReplaceHamtEntry(
exhausted, root, kReplacement.hash, kReplacement.key, kReplacement, HashOf{}, KeyOf{}, Equal{})
.value_or(HamtReplaceResult<Node>{.root = nullptr, .replaced = true});
ASSERT_THAT(missing.root, NotNull());
EXPECT_THAT(missing.root, Eq(root));
EXPECT_THAT(missing.replaced, Eq(false));
EXPECT_THAT(root->use_count(), Eq(2));
Node::Release(source, missing.root);
}

TEST_F(HamtReplaceTest, ReplacesDeepCollisionValueWithoutChangingTheOriginalSnapshot) {
constexpr std::uint64_t kHash = 1 + (std::uint64_t{3} << 15);
Insert(Entry{.hash = 1, .key = 10, .value = 10});
Insert(Entry{.hash = kHash, .key = 20, .value = 20});
Insert(Entry{.hash = kHash, .key = 30, .value = 30});
constexpr Entry kReplacement{.hash = kHash, .key = 20, .value = 99};
const auto changed = TryReplaceHamtEntry(source, root, kHash, 20, kReplacement, HashOf{}, KeyOf{}, Equal{})
.value_or(HamtReplaceResult<Node>{.root = nullptr, .replaced = false});
ASSERT_THAT(changed.root, NotNull());
EXPECT_THAT(changed.replaced, Eq(true));
const Entry* original = Find(root, kHash, 20);
const Entry* updated = Find(changed.root, kHash, 20);
const Entry* other = Find(changed.root, kHash, 30);
ASSERT_THAT(original, NotNull());
ASSERT_THAT(updated, NotNull());
ASSERT_THAT(other, NotNull());
EXPECT_THAT(original->value, Eq(20));
EXPECT_THAT(updated->value, Eq(99));
EXPECT_THAT(other->value, Eq(30));
Node::Release(source, changed.root);
}

TEST_F(HamtReplaceTest, AllocationFailurePreservesDirectEntry) {
Insert(Entry{.hash = 1, .key = 10, .value = 10});
mbo::memory::InlineBlockSource<1> exhausted;
constexpr Entry kReplacement{.hash = 1, .key = 10, .value = 99};
EXPECT_THAT(
TryReplaceHamtEntry(exhausted, root, std::uint64_t{1}, 10, kReplacement, HashOf{}, KeyOf{}, Equal{}),
Eq(std::nullopt));
const Entry* unchanged = Find(root, 1, 10);
ASSERT_THAT(unchanged, NotNull());
EXPECT_THAT(unchanged->value, Eq(10));
EXPECT_THAT(root->use_count(), Eq(1));
}

TEST_F(HamtReplaceTest, DirectReplacementCanAliasTheExistingEntry) {
Insert(Entry{.hash = 1, .key = 10, .value = 10});
const Entry* const existing = Find(root, 1, 10);
ASSERT_THAT(existing, NotNull());
const auto changed = TryReplaceHamtEntry(source, root, std::uint64_t{1}, 10, *existing, HashOf{}, KeyOf{}, Equal{})
.value_or(HamtReplaceResult<Node>{.root = nullptr, .replaced = false});
ASSERT_THAT(changed.root, NotNull());
EXPECT_THAT(changed.replaced, Eq(true));
const Entry* const copied = Find(changed.root, 1, 10);
ASSERT_THAT(copied, NotNull());
EXPECT_THAT(copied == existing, Eq(false));
EXPECT_THAT(copied->value, Eq(10));
EXPECT_THAT(existing->value, Eq(10));
Node::Release(source, changed.root);
}

TEST_F(HamtReplaceTest, FailedChildCopyPreservesTheEntireOriginalPath) {
Insert(Entry{.hash = 1, .key = 10, .value = 10});
Insert(Entry{.hash = 33, .key = 20, .value = 20});
mbo::memory::InlineBlockSource<1> exhausted;
constexpr Entry kReplacement{.hash = 33, .key = 20, .value = 99};
EXPECT_THAT(
TryReplaceHamtEntry(exhausted, root, std::uint64_t{33}, 20, kReplacement, HashOf{}, KeyOf{}, Equal{}),
Eq(std::nullopt));
const Entry* const unchanged = Find(root, 33, 20);
const Entry* const sibling = Find(root, 1, 10);
ASSERT_THAT(unchanged, NotNull());
ASSERT_THAT(sibling, NotNull());
EXPECT_THAT(unchanged->value, Eq(20));
EXPECT_THAT(sibling->value, Eq(10));
EXPECT_THAT(root->use_count(), Eq(1));
EXPECT_THAT(root->children().front()->use_count(), Eq(1));
}

TEST_F(HamtReplaceTest, FailedParentCopyReclaimsTheAlreadyCopiedChild) {
Insert(Entry{.hash = 1, .key = 10, .value = 10});
Insert(Entry{.hash = 33, .key = 20, .value = 20});
mbo::memory::InlineBlockSource<4'096> single_allocation;
constexpr Entry kReplacement{.hash = 33, .key = 20, .value = 99};
EXPECT_THAT(
TryReplaceHamtEntry(single_allocation, root, std::uint64_t{33}, 20, kReplacement, HashOf{}, KeyOf{}, Equal{}),
Eq(std::nullopt));
const Entry* const unchanged = Find(root, 33, 20);
ASSERT_THAT(unchanged, NotNull());
EXPECT_THAT(unchanged->value, Eq(20));
EXPECT_THAT(root->use_count(), Eq(1));
EXPECT_THAT(single_allocation.TryAcquire(1, 1), Optional(Field("data", &mbo::memory::MemoryBlock::data, NotNull())));
}

} // namespace
} // namespace mbo::container::container_internal
Loading