From 444fbdd5323514d45c2ce1f2bacf08981500c28f Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:44:50 +0900 Subject: [PATCH 1/8] Make ngram database more robust --- include/iris/ngram/database.hpp | 140 +++++++++++++++++++------ include/iris/ngram/detail/id_store.hpp | 27 +++-- include/iris/ngram/detail/index.hpp | 7 +- 3 files changed, 132 insertions(+), 42 deletions(-) diff --git a/include/iris/ngram/database.hpp b/include/iris/ngram/database.hpp index 34add63..dea3d89 100644 --- a/include/iris/ngram/database.hpp +++ b/include/iris/ngram/database.hpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -24,15 +25,31 @@ #include #include +#include namespace iris::ngram { +namespace detail { + +struct [[nodiscard]] search_result_cache_guard +{ + search_result_cache& cache; + + ~search_result_cache_guard() noexcept + { + cache.reset(); + } +}; + +} // detail + template class database { public: using document_id_type = document_id; + // NOT thread-safe [[nodiscard]] document_id add_document(std::basic_string_view const doc_text) { auto transaction = id_store_.add_document(); @@ -41,6 +58,7 @@ class database return transaction.new_id(); } + // NOT thread-safe void update_document(document_id const doc_id, std::basic_string_view const doc_text) { auto transaction = id_store_.update_document(doc_id); @@ -48,60 +66,106 @@ class database transaction.commit(); } + // NOT thread-safe void remove_document(document_id const doc_id) { id_store_.remove_document(doc_id); } + // Returns whether `doc_id` refers to an existing (non-erased) document. + // NOT thread-safe [[nodiscard]] bool has_document(document_id const doc_id) const { return id_store_.has_document(doc_id); } + // NOT thread-safe [[nodiscard]] bool is_visible(document_id const doc_id) const { return id_store_.is_visible(doc_id); } + // NOT thread-safe void set_visible(document_id const doc_id, bool const flag) { id_store_.set_visible(doc_id, flag); } + // NOT thread-safe void clear() noexcept { id_store_.clear(); store_.clear(); } + // Concurrent `search` calls are thread-safe unless other non-thread-safe functions are called at the same time + template + bool search(this auto const& db, std::basic_string_view const query, search_result& search_res) + { + return db.search(std::views::single(query), search_res); + } + + // Concurrent `search` calls are thread-safe unless other non-thread-safe functions are called at the same time template bool search(this auto const& db, search_query const& query, search_result& search_res) + { + return db.search(query.words(), search_res); + } + + // Concurrent `search` calls are thread-safe unless other non-thread-safe functions are called at the same time + template + requires std::convertible_to, std::basic_string_view> + bool search(this auto const& db, SearchWordRange&& query_words, search_result& search_res) { search_res.clear(); - if (query.empty()) return false; + if (std::ranges::empty(query_words)) return false; if (db.store_.empty()) return false; - db.search_res_cache_.reset(); + auto& search_res_cache = get_search_res_cache(); + detail::search_result_cache_guard const search_res_cache_guard{search_res_cache}; + search_res_cache.reset(); - int word_id = 0; - auto it = query.words().begin(); - assert(!it->empty()); - db.template search_word(word_id++, *it++); - if (db.search_res_cache_.empty()) { - db.search_res_cache_.reset(); // remove tombstones - return false; - } + { + int word_id = 0; + auto word_it = std::ranges::begin(query_words); - for (; it != query.words().end(); ++it) { - assert(!it->empty()); - db.template search_word(word_id++, *it); - if (db.search_res_cache_.empty()) { - db.search_res_cache_.reset(); // remove tombstones + // Find first non-empty word + for (; word_it != std::ranges::end(query_words); ++word_it) { + std::basic_string_view const word{*word_it}; + if (word.empty()) continue; + + db.template search_word(word_id++, word); + + // If the first word's search yielded zero result, the entire search result shall be empty + // because it's AND search. + if (search_res_cache.empty()) { + return false; + } + + break; + } + if (word_id == 0) [[unlikely]] { + // If the first non-empty word never occurred, the entire search result shall be empty + assert(word_it == std::ranges::end(query_words)); + assert(search_res_cache.empty()); return false; } - } + ++word_it; + + for (; word_it != std::ranges::end(query_words); ++word_it) { + std::basic_string_view const word{*word_it}; + if (word.empty()) continue; + + db.template search_word(word_id++, word); - assert(!db.search_res_cache_.empty()); + // If the current search word shaved off the result to zero, the entire search result + // shall be empty because it's AND search. + if (search_res_cache.empty()) { + return false; + } + } + } + assert(!search_res_cache.empty()); assert(search_res.empty()); using document_id_maybe_ref_t = std::conditional_t< @@ -111,16 +175,15 @@ class database >; // Convert cache into real result search_res.assign( - db.search_res_cache_.doc_matches() | std::views::transform([&db](auto&& kv) - -> std::pair&&> { + search_res_cache.doc_matches() | std::views::transform([&db](auto&& kv) + -> std::pair&&> + { return std::pair&&>{ db.make_document_id(db.id_store_.get_info(std::get<0>(kv)).doc_id), std::move(std::get<1>(kv)) }; }) ); - // Clear the cache (only contains the moved-from buffer though) - db.search_res_cache_.reset(); return true; } @@ -131,6 +194,13 @@ class database return doc_id; } + // non-templated function to allow class instance wide cache + static detail::search_result_cache& get_search_res_cache() + { + thread_local detail::search_result_cache search_res_cache; + return search_res_cache; + } + template void search_word(int const word_id, std::basic_string_view const word) const { @@ -147,27 +217,30 @@ class database void search_word_impl(int const word_id, std::basic_string_view const word) const { assert(word.size() >= N); + + auto& search_res_cache = get_search_res_cache(); + auto ng = gram::from_copy_n(word.begin()); if constexpr (IsFirstWord) { store_.search(ng, [&](detail::document_slot const doc_slot, std::span const positions) { auto const& slot_info = id_store_.get_info(doc_slot); if (!slot_info.is_used_for_search()) return; - (void)search_res_cache_.init_word_matches(doc_slot, word_id, positions); + (void)search_res_cache.init_word_matches(doc_slot, word_id, positions); }); - if (search_res_cache_.empty()) return; + if (search_res_cache.empty()) return; } else { std::size_t available_doc_count = 0; store_.search(ng, [&](detail::document_slot const doc_slot, std::span const positions) { auto const& slot_info = id_store_.get_info(doc_slot); if (!slot_info.is_used_for_search()) return; - if (search_res_cache_.init_word_matches(doc_slot, word_id, positions)) { + if (search_res_cache.init_word_matches(doc_slot, word_id, positions)) { ++available_doc_count; } }); if (available_doc_count == 0) { - search_res_cache_.reset(); + search_res_cache.reset(); return; } } @@ -186,7 +259,7 @@ class database // For example, when the document is "今日は晴れです" and current `ng` is "は晴", // - When previous `ng` was "昨日", `search_res` contians no matches => omit further sequence // - When previous `ng` was "今日", `search_res` contains matches => proceed with "は晴" - auto word_match = search_res_cache_.get_word_matches(doc_slot, word_id); + auto word_match = search_res_cache.get_word_matches(doc_slot, word_id); if (!word_match) return detail::search_continuation::proceed; // Prevent *resurrecting* the false-positive match on "match -> unmatch -> match" pattern. @@ -195,8 +268,8 @@ class database // - gram{"XX"} -> no match (successful_ngrams is untouched) // - gram{"ef"} -> successful_ngrams does not match current_ngram! if (word_match->successful_ngrams_ != current_ngram) { - search_res_cache_.erase_document(word_match); - if (search_res_cache_.empty()) return detail::search_continuation::abort; + search_res_cache.erase_document(word_match); + if (search_res_cache.empty()) return detail::search_continuation::abort; return detail::search_continuation::proceed; } @@ -219,8 +292,8 @@ class database // [previous ng, ...some unrelated chars..., current ng], // we can always remove the entire document from the candidate pool. if (word_match->spans_.empty()) { - search_res_cache_.erase_document(word_match); - if (search_res_cache_.empty()) return detail::search_continuation::abort; + search_res_cache.erase_document(word_match); + if (search_res_cache.empty()) return detail::search_continuation::abort; return detail::search_continuation::proceed; } @@ -233,7 +306,7 @@ class database for (; i + N <= word.size(); i += N) { ng.copy_n(word.begin() + i); store_.search(ng, do_search(N)); - if (search_res_cache_.empty()) return; + if (search_res_cache.empty()) return; ++current_ngram; } @@ -257,7 +330,7 @@ class database assert(remaining_chars < int(N)); ng.shift_copy(word.begin() + i, remaining_chars); store_.search(ng, do_search(remaining_chars)); - if (search_res_cache_.empty()) return; + if (search_res_cache.empty()) return; ++current_ngram; } } @@ -266,12 +339,11 @@ class database // A first word of exactly one n-gram runs no continuation searches if (current_ngram == 1) return; } - search_res_cache_.remove_stale_document_matches(word_id, current_ngram); + search_res_cache.remove_stale_document_matches(word_id, current_ngram); } detail::id_store id_store_; detail::index_storage store_; - mutable detail::search_result_cache search_res_cache_; }; } // iris::ngram diff --git a/include/iris/ngram/detail/id_store.hpp b/include/iris/ngram/detail/id_store.hpp index 4e59663..0a97c01 100644 --- a/include/iris/ngram/detail/id_store.hpp +++ b/include/iris/ngram/detail/id_store.hpp @@ -80,8 +80,13 @@ class id_store void commit() { auto const doc_id = old_slot_info_.doc_id; - old_slot_info_.doc_id = document_id::tombstone; - store_->slot_infos_.emplace_back(doc_id, old_slot_info_.is_visible); + auto const is_visible = old_slot_info_.is_visible; + + store_->slot_infos_.emplace_back(doc_id, is_visible); + + // emplace_back may invalidate `old_slot_info_` + store_->slot_infos_[to_index(store_->id_to_slot_[to_index(doc_id)])].doc_id = document_id::tombstone; + store_->id_to_slot_[to_index(doc_id)] = new_slot_; } @@ -135,7 +140,17 @@ class id_store [[nodiscard]] bool has_document(document_id const doc_id) const noexcept { - return detail::to_index(doc_id) < id_to_slot_.size(); + if (doc_id == document_id::tombstone) [[unlikely]] { + return false; + } + if (to_index(doc_id) >= id_to_slot_.size()) [[unlikely]] { + // `doc_id` points to a unissued slot, perhaps due to logic error such as + // mixing id namespace on application side. + return false; + } + document_slot const doc_slot = id_to_slot_[to_index(doc_id)]; + assert(doc_slot != document_slot::sentinel); + return doc_slot != document_slot::tombstone; } // This method is intentionally `noexcept` and invokes only assertion because @@ -161,10 +176,10 @@ class id_store if (doc_slot == document_slot::tombstone) { throwf("cannot fetch the visibility of already-removed document"); } - return get_info(doc_slot).is_visible; + return this->get_info(doc_slot).is_visible; } - void set_visible(document_id const doc_id, bool flag) + void set_visible(document_id const doc_id, bool const flag) { if (to_index(doc_id) >= id_to_slot_.size()) { throwf("document id #{} is beyond the namespace acquired by the database", doc_id); @@ -174,7 +189,7 @@ class id_store if (doc_slot == document_slot::tombstone) { throwf("cannot change the visibility of already-removed document"); } - get_info(doc_slot).is_visible = flag; + this->get_info(doc_slot).is_visible = flag; } void clear() noexcept diff --git a/include/iris/ngram/detail/index.hpp b/include/iris/ngram/detail/index.hpp index 825b0aa..2f81a84 100644 --- a/include/iris/ngram/detail/index.hpp +++ b/include/iris/ngram/detail/index.hpp @@ -104,7 +104,10 @@ class gram_index static constexpr std::size_t side_merge_threshold = 2048; public: - [[nodiscard]] auto find_list(this auto&& self, gram const ng) + template + [[nodiscard]] auto find_list(this Self&& self, gram const ng) + noexcept + -> std::conditional_t>, posting_list const*, posting_list*> { if (auto const it = self.gram_entries_.find(ng); it != self.gram_entries_.end()) { return it->second.get(); @@ -112,7 +115,7 @@ class gram_index if (auto const it = self.side_entries_.find(ng); it != self.side_entries_.end()) { return it->second.get(); } - return static_cast(nullptr); + return nullptr; } template From 9f7b05262a6baa1d43a258089dbd0e9fea9f4040 Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:05:34 +0900 Subject: [PATCH 2/8] Fix dangling refs and id management --- include/iris/ngram/database.hpp | 12 ++++++++---- include/iris/ngram/detail/id_store.hpp | 5 +++-- include/iris/ngram/keyed_database.hpp | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/include/iris/ngram/database.hpp b/include/iris/ngram/database.hpp index dea3d89..013a860 100644 --- a/include/iris/ngram/database.hpp +++ b/include/iris/ngram/database.hpp @@ -66,15 +66,17 @@ class database transaction.commit(); } + // Remove the document specified by `doc_id`. + // Removing an already-erased document is noop. // NOT thread-safe - void remove_document(document_id const doc_id) + void remove_document(document_id const doc_id) noexcept { id_store_.remove_document(doc_id); } // Returns whether `doc_id` refers to an existing (non-erased) document. // NOT thread-safe - [[nodiscard]] bool has_document(document_id const doc_id) const + [[nodiscard]] bool has_document(document_id const doc_id) const noexcept { return id_store_.has_document(doc_id); } @@ -131,7 +133,8 @@ class database // Find first non-empty word for (; word_it != std::ranges::end(query_words); ++word_it) { - std::basic_string_view const word{*word_it}; + auto&& word_value = *word_it; + std::basic_string_view const word{word_value}; if (word.empty()) continue; db.template search_word(word_id++, word); @@ -153,7 +156,8 @@ class database ++word_it; for (; word_it != std::ranges::end(query_words); ++word_it) { - std::basic_string_view const word{*word_it}; + auto&& word_value = *word_it; + std::basic_string_view const word{word_value}; if (word.empty()) continue; db.template search_word(word_id++, word); diff --git a/include/iris/ngram/detail/id_store.hpp b/include/iris/ngram/detail/id_store.hpp index 0a97c01..17e1ff2 100644 --- a/include/iris/ngram/detail/id_store.hpp +++ b/include/iris/ngram/detail/id_store.hpp @@ -117,10 +117,11 @@ class id_store }; } - void remove_document(document_id const doc_id) + void remove_document(document_id const doc_id) noexcept { + assert(to_index(doc_id) < id_to_slot_.size() && "document id is beyond the range of ids issued by this database"); if (to_index(doc_id) >= id_to_slot_.size()) { - throwf("document id #{} is beyond the range of ids issued by this database", doc_id); + return; // safely noop on Release build } document_slot& doc_slot = id_to_slot_[to_index(doc_id)]; diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index 37fb17c..f142197 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -65,12 +65,22 @@ class keyed_database : private database template void remove_document(KeyLikeT const& key_like) + noexcept(std::is_nothrow_constructible_v) { - base_type::remove_document(this->get_document_id(key_like)); + auto const it = key_to_doc_id_.find(key_like); + if (it == key_to_doc_id_.end()) return; + + auto const doc_id = it->second; + auto const doc_id_index = detail::to_index(doc_id); + assert(doc_id_index < doc_id_to_key_.size()); + doc_id_to_key_[doc_id_index] = nullptr; + key_to_doc_id_.erase(it); + + base_type::remove_document(doc_id); } template - [[nodiscard]] bool has_document(KeyLikeT const& key_like) const + [[nodiscard]] bool has_document(KeyLikeT const& key_like) const noexcept { return key_to_doc_id_.find(key_like) != key_to_doc_id_.end(); } From e2af77158867642f4e25149e72607e3360ef2d7c Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:16:17 +0900 Subject: [PATCH 3/8] Improve exception safety on `keyed_database::add_document` --- include/iris/ngram/keyed_database.hpp | 42 +++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index f142197..721027d 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -28,8 +28,9 @@ class keyed_database : private database requires std::is_constructible_v void add_document(KeyLikeT&& key_like, std::basic_string_view const doc_text) { - auto const doc_id = base_type::add_document(doc_text); - auto const [it, inserted] = key_to_doc_id_.emplace(std::forward(key_like), doc_id); + // First, insert the fake `doc_id` to check the key existence. + // This makes sure that the key construction and lookup only occurs once regardless of the existence of the key + auto const [it, inserted] = key_to_doc_id_.emplace(std::forward(key_like), document_id::tombstone); if (!inserted) { if constexpr (std::is_pointer_v) { throwf("key `{}` already exists in keyed_database", static_cast(it->first)); @@ -39,8 +40,14 @@ class keyed_database : private database throwf("key `???` already exists in keyed_database"); } } + + key_to_doc_id_addition_guard addition_guard{key_to_doc_id_, it}; + auto const doc_id = base_type::add_document(doc_text); + assert(doc_id_to_key_.size() == detail::to_index(doc_id)); doc_id_to_key_.emplace_back(std::addressof(it->first)); + + addition_guard.commit(); } template @@ -122,11 +129,34 @@ class keyed_database : private database return it->second; } - std::unordered_map - key_to_doc_id_; + using key_to_doc_id_type = std::unordered_map; + key_to_doc_id_type key_to_doc_id_; + + struct key_to_doc_id_addition_guard + { + key_to_doc_id_addition_guard(key_to_doc_id_type& key_to_doc_id, key_to_doc_id_type::iterator it) + : key_to_doc_id_(key_to_doc_id) + , it_(std::move(it)) + {} + + void commit() + { + it_ = key_to_doc_id_.end(); + } + + ~key_to_doc_id_addition_guard() + { + if (it_ != key_to_doc_id_.end()) { + key_to_doc_id_.erase(it_); + } + } + + private: + key_to_doc_id_type& key_to_doc_id_; + key_to_doc_id_type::iterator it_; + }; - std::vector - doc_id_to_key_; + std::vector doc_id_to_key_; }; } // iris::ngram From cd71c79eef4836f839243b6d01947146e2f5e8ae Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:20:37 +0900 Subject: [PATCH 4/8] Fix document id bug --- include/iris/ngram/keyed_database.hpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index 721027d..a16814e 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -47,7 +47,7 @@ class keyed_database : private database assert(doc_id_to_key_.size() == detail::to_index(doc_id)); doc_id_to_key_.emplace_back(std::addressof(it->first)); - addition_guard.commit(); + addition_guard.commit(doc_id); } template @@ -72,7 +72,6 @@ class keyed_database : private database template void remove_document(KeyLikeT const& key_like) - noexcept(std::is_nothrow_constructible_v) { auto const it = key_to_doc_id_.find(key_like); if (it == key_to_doc_id_.end()) return; @@ -87,7 +86,7 @@ class keyed_database : private database } template - [[nodiscard]] bool has_document(KeyLikeT const& key_like) const noexcept + [[nodiscard]] bool has_document(KeyLikeT const& key_like) const { return key_to_doc_id_.find(key_like) != key_to_doc_id_.end(); } @@ -139,8 +138,9 @@ class keyed_database : private database , it_(std::move(it)) {} - void commit() + void commit(document_id const doc_id) { + it_->second = doc_id; it_ = key_to_doc_id_.end(); } From 529d2e41fdd7013d682771c3f3a16da6b7dbfc25 Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:17:41 +0900 Subject: [PATCH 5/8] Drop double buffering on `std::flat_map` and use `std::unordered_map` --- include/iris/ngram/detail/index.hpp | 61 +++-------------------------- include/iris/ngram/gram.hpp | 17 ++++++++ 2 files changed, 23 insertions(+), 55 deletions(-) diff --git a/include/iris/ngram/detail/index.hpp b/include/iris/ngram/detail/index.hpp index 2f81a84..1e49d57 100644 --- a/include/iris/ngram/detail/index.hpp +++ b/include/iris/ngram/detail/index.hpp @@ -15,7 +15,7 @@ #include #include #include -#include +#include #include #include #include @@ -100,9 +100,6 @@ class posting_list template class gram_index { - using gram_posting_map = std::flat_map, std::unique_ptr>; - static constexpr std::size_t side_merge_threshold = 2048; - public: template [[nodiscard]] auto find_list(this Self&& self, gram const ng) @@ -112,9 +109,6 @@ class gram_index if (auto const it = self.gram_entries_.find(ng); it != self.gram_entries_.end()) { return it->second.get(); } - if (auto const it = self.side_entries_.find(ng); it != self.side_entries_.end()) { - return it->second.get(); - } return nullptr; } @@ -128,69 +122,26 @@ class gram_index [[nodiscard]] bool empty() const noexcept { - return gram_entries_.empty() && side_entries_.empty(); + return gram_entries_.empty(); } void clear() noexcept { gram_entries_.clear(); - side_entries_.clear(); } void merge_new_entries(std::vector, std::unique_ptr>>& pending) { - if (pending.empty()) return; // vocabulary saturated - for (auto& [key, pl] : pending) { - [[maybe_unused]] auto const it = side_entries_.try_emplace( - side_entries_.end(), // hint - key, std::move(pl) - ); + [[maybe_unused]] auto const [it, inserted] = gram_entries_.try_emplace(key, std::move(pl)); + assert(inserted); assert(it->second != nullptr && pl == nullptr); } - if (side_entries_.size() >= side_merge_threshold) { - this->flush_side(); - } } private: - void flush_side() - { - if (side_entries_.empty()) return; - - auto [skeys, svalues] = std::move(side_entries_).extract(); - auto [keys, values] = std::move(gram_entries_).extract(); - - std::size_t const old_size = keys.size(); - std::size_t const add = skeys.size(); - keys.resize(old_size + add); - values.resize(old_size + add); - - // Backward merge - std::size_t out = old_size + add; - std::size_t i = old_size; - std::size_t j = add; - while (j > 0) { - if (i > 0 && skeys[j - 1] < keys[i - 1]) { - --out; - --i; - keys[out] = keys[i]; - values[out] = std::move(values[i]); - } else { - assert(i == 0 || keys[i - 1] < skeys[j - 1]); - --out; - --j; - keys[out] = skeys[j]; - values[out] = std::move(svalues[j]); - } - } - assert(out == i); - - gram_entries_.replace(std::move(keys), std::move(values)); - } - - // Double-buffered to reduce insertion cost - gram_posting_map gram_entries_, side_entries_; + std::unordered_map, std::unique_ptr> + gram_entries_; }; template diff --git a/include/iris/ngram/gram.hpp b/include/iris/ngram/gram.hpp index 650daed..ac17565 100644 --- a/include/iris/ngram/gram.hpp +++ b/include/iris/ngram/gram.hpp @@ -5,6 +5,9 @@ #include // IWYU pragma: keep +#include + +#include #include #include #include @@ -15,6 +18,7 @@ #include #include +#include // IWYU pragma: keep namespace iris::ngram { @@ -155,6 +159,19 @@ struct gram<2, CharT> } // iris::ngram +template +struct std::hash> +{ + [[nodiscard]] static std::size_t operator()(iris::ngram::gram const& ng) noexcept + { + if constexpr (N == 1 || N == 2) { + return std::hash{}(ng.data); + } else { + return iris::hash_value(ng.data); + } + } +}; + namespace iris { template From 9c64e2f477803fac92a35bdfe47a602974c944e5 Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:10:03 +0900 Subject: [PATCH 6/8] Add swap functions --- include/iris/ngram/database.hpp | 14 ++++ include/iris/ngram/detail/id_store.hpp | 12 ++++ include/iris/ngram/detail/index.hpp | 97 ++++++++++++++++++++------ include/iris/ngram/keyed_database.hpp | 14 ++++ 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/include/iris/ngram/database.hpp b/include/iris/ngram/database.hpp index 013a860..41817c0 100644 --- a/include/iris/ngram/database.hpp +++ b/include/iris/ngram/database.hpp @@ -100,6 +100,14 @@ class database store_.clear(); } + // NOT thread-safe + void swap(database& other) noexcept + { + using std::swap; + swap(id_store_, other.id_store_); + swap(store_, other.store_); + } + // Concurrent `search` calls are thread-safe unless other non-thread-safe functions are called at the same time template bool search(this auto const& db, std::basic_string_view const query, search_result& search_res) @@ -350,6 +358,12 @@ class database detail::index_storage store_; }; +template +void swap(database& a, database& b) noexcept +{ + a.swap(b); +} + } // iris::ngram #endif diff --git a/include/iris/ngram/detail/id_store.hpp b/include/iris/ngram/detail/id_store.hpp index 17e1ff2..2d8dfec 100644 --- a/include/iris/ngram/detail/id_store.hpp +++ b/include/iris/ngram/detail/id_store.hpp @@ -199,11 +199,23 @@ class id_store slot_infos_.clear(); } + void swap(id_store& other) noexcept + { + using std::swap; + swap(id_to_slot_, other.id_to_slot_); + swap(slot_infos_, other.slot_infos_); + } + private: std::vector id_to_slot_; std::vector slot_infos_; }; +inline void swap(id_store& a, id_store& b) noexcept +{ + a.swap(b); +} + } // iris::ngram::detail #endif diff --git a/include/iris/ngram/detail/index.hpp b/include/iris/ngram/detail/index.hpp index 1e49d57..2400046 100644 --- a/include/iris/ngram/detail/index.hpp +++ b/include/iris/ngram/detail/index.hpp @@ -87,6 +87,13 @@ class posting_list } } + void swap(posting_list& other) noexcept + { + using std::swap; + swap(postings_, other.postings_); + swap(positions_, other.positions_); + } + private: struct posting_t { @@ -97,6 +104,11 @@ class posting_list std::vector positions_; }; +inline void swap(posting_list& a, posting_list& b) noexcept +{ + a.swap(b); +} + template class gram_index { @@ -130,6 +142,12 @@ class gram_index gram_entries_.clear(); } + void swap(gram_index& other) noexcept + { + using std::swap; + swap(gram_entries_, other.gram_entries_); + } + void merge_new_entries(std::vector, std::unique_ptr>>& pending) { for (auto& [key, pl] : pending) { @@ -144,6 +162,12 @@ class gram_index gram_entries_; }; +template +void swap(gram_index& a, gram_index& b) noexcept +{ + a.swap(b); +} + template struct gram_pos_t { @@ -154,6 +178,40 @@ struct gram_pos_t [[nodiscard]] constexpr std::strong_ordering operator<=>(gram_pos_t const&) const noexcept = default; }; +template +struct gram_index_storage +{ + gram_index idx; + + // Caches + std::vector, default_init_allocator>> + batch_grams; + + std::vector, std::unique_ptr>> + batch_pending; + + void clear() noexcept + { + idx.clear(); + batch_grams.clear(); + batch_pending.clear(); + } + + void swap(gram_index_storage& other) noexcept + { + using std::swap; + swap(idx, other.idx); + swap(batch_grams, other.batch_grams); + swap(batch_pending, other.batch_pending); + } +}; + +template +void swap(gram_index_storage& a, gram_index_storage& b) noexcept +{ + a.swap(b); +} + template struct index_storage { @@ -169,6 +227,13 @@ struct index_storage bi_data_.clear(); } + void swap(index_storage& other) noexcept + { + using std::swap; + swap(uni_data_, other.uni_data_); + swap(bi_data_, other.bi_data_); + } + [[nodiscard]] bool empty() const noexcept { return @@ -189,28 +254,8 @@ struct index_storage } private: - template - struct gram_index_storage - { - gram_index idx; - - // Caches - std::vector, default_init_allocator>> - batch_grams; - - std::vector, std::unique_ptr>> - batch_pending; - - void clear() noexcept - { - idx.clear(); - batch_grams.clear(); - batch_pending.clear(); - } - }; - - gram_index_storage<1> uni_data_; - gram_index_storage<2> bi_data_; + gram_index_storage<1, CharT> uni_data_; + gram_index_storage<2, CharT> bi_data_; template [[nodiscard]] auto& get_data(this auto& self IRIS_LIFETIMEBOUND) noexcept @@ -231,7 +276,7 @@ struct index_storage ) { if (input.size() < N) return; - gram_index_storage& data = this->template get_data(); + gram_index_storage& data = this->template get_data(); // Naive per-gram insertion into flat_map is expensive: each *new* key // shifts the underlying vectors, so building an index of vocabulary @@ -288,6 +333,12 @@ struct index_storage } }; +template +void swap(index_storage& a, index_storage& b) noexcept +{ + a.swap(b); +} + } // iris::ngram::detail #endif diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index a16814e..759f26f 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -110,6 +110,14 @@ class keyed_database : private database doc_id_to_key_.clear(); } + void swap(keyed_database& other) noexcept + { + using std::swap; + swap(static_cast(*this), static_cast(other)); + swap(key_to_doc_id_, other.key_to_doc_id_); + swap(doc_id_to_key_, other.doc_id_to_key_); + } + using base_type::search; private: @@ -159,6 +167,12 @@ class keyed_database : private database std::vector doc_id_to_key_; }; +template +void swap(keyed_database& a, keyed_database& b) noexcept +{ + a.swap(b); +} + } // iris::ngram #endif From 1aea2e17909fc738097dd9eabf6f1e33d7ea2d20 Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:15:50 +0900 Subject: [PATCH 7/8] Add more swap functions, fix obsolete comment --- include/iris/ngram/detail/index.hpp | 24 +++++++++++++----------- include/iris/ngram/keyed_database.hpp | 5 +++++ include/iris/ngram/search_query.hpp | 12 ++++++++++++ include/iris/ngram/search_result.hpp | 12 ++++++++++++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/include/iris/ngram/detail/index.hpp b/include/iris/ngram/detail/index.hpp index 2400046..b131a5e 100644 --- a/include/iris/ngram/detail/index.hpp +++ b/include/iris/ngram/detail/index.hpp @@ -278,20 +278,22 @@ struct index_storage if (input.size() < N) return; gram_index_storage& data = this->template get_data(); - // Naive per-gram insertion into flat_map is expensive: each *new* key - // shifts the underlying vectors, so building an index of vocabulary - // size V costs O(V^2) overall. Instead, per document: + // Per document, let G be the number of n-gram occurrences, + // D the number of distinct n-grams, and P the number of new keys: // - // 1. Collect grams+positions --- O(G) G = grams in this doc - // 2. Sort them ----------------- O(G log G) - // 3. Existing keys ------------- O(D log V) D = distinct grams (D <= G) - // 4. New keys ------------------ O(V + P) P = brand-new keys (P <= D) + // 1. Collect grams and positions: O(G). + // 2. Sort by gram, then position: O(G log G). + // 3. Group equal grams and look up each group: O(G + D) expected. + // 4. Append positions to posting lists: O(G) amortized. + // 5. Insert the P new keys into the index: O(P) expected amortized. // - // Once the vocabulary saturates (P ~ 0, typical after a few documents), - // step 4 is a no-op and each document costs only O(G log G + D log V). + // Since P <= D <= G, the overall cost is O(G log G) expected + // amortized. Hash-table bounds assume well-distributed hashes; + // individual insertions may rehash the table, and pathological + // collisions can make lookups and insertions linear in index size. // - // Note: initially implemented by @saki7, then the complexity math is - // double-checked by Claude. + // Sorting groups each gram's occurrences into one lookup and keeps + // its positions in ascending order, as required by search. data.batch_grams.clear(); data.batch_grams.resize(input.size() - N + 1); diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index 759f26f..95e4db1 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -8,10 +8,12 @@ #include #include +#include #include #include #include #include +#include namespace iris::ngram { @@ -112,6 +114,9 @@ class keyed_database : private database void swap(keyed_database& other) noexcept { + static_assert(std::is_nothrow_swappable_v); + static_assert(std::is_nothrow_swappable_v); + using std::swap; swap(static_cast(*this), static_cast(other)); swap(key_to_doc_id_, other.key_to_doc_id_); diff --git a/include/iris/ngram/search_query.hpp b/include/iris/ngram/search_query.hpp index 949c276..5adb4c6 100644 --- a/include/iris/ngram/search_query.hpp +++ b/include/iris/ngram/search_query.hpp @@ -56,6 +56,12 @@ struct search_query return words_.empty(); } + void swap(search_query& other) noexcept + { + using std::swap; + swap(words_, other.words_); + } + [[nodiscard]] bool operator==(search_query const& other) const noexcept { return words_ == other.words_; @@ -68,6 +74,12 @@ struct search_query template search_query(CharT const(&)[N]) -> search_query; +template +void swap(search_query& a, search_query& b) noexcept +{ + a.swap(b); +} + } // iris::ngram template diff --git a/include/iris/ngram/search_result.hpp b/include/iris/ngram/search_result.hpp index 98fc639..bba945c 100644 --- a/include/iris/ngram/search_result.hpp +++ b/include/iris/ngram/search_result.hpp @@ -92,6 +92,12 @@ struct [[nodiscard]] search_result doc_matches_.clear(); } + void swap(search_result& other) noexcept + { + using std::swap; + swap(doc_matches_, other.doc_matches_); + } + [[nodiscard]] bool empty() const noexcept { return doc_matches_.empty(); @@ -106,6 +112,12 @@ struct [[nodiscard]] search_result map_type doc_matches_; }; +template +void swap(search_result& a, search_result& b) noexcept +{ + a.swap(b); +} + } // iris::ngram template From 3b65b80697ce1799f00b1e68f68079059ecfd909 Mon Sep 17 00:00:00 2001 From: Nana Sakisaka <1901813+saki7@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:18:08 +0900 Subject: [PATCH 8/8] Fix swap noexcept --- include/iris/ngram/search_result.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/include/iris/ngram/search_result.hpp b/include/iris/ngram/search_result.hpp index bba945c..a7c982c 100644 --- a/include/iris/ngram/search_result.hpp +++ b/include/iris/ngram/search_result.hpp @@ -11,7 +11,6 @@ #include #include -#include #include #include #include @@ -92,7 +91,7 @@ struct [[nodiscard]] search_result doc_matches_.clear(); } - void swap(search_result& other) noexcept + void swap(search_result& other) noexcept(std::is_nothrow_swappable_v) { using std::swap; swap(doc_matches_, other.doc_matches_); @@ -113,7 +112,8 @@ struct [[nodiscard]] search_result }; template -void swap(search_result& a, search_result& b) noexcept +void swap(search_result& a, search_result& b) + noexcept(noexcept(a.swap(b))) { a.swap(b); }