diff --git a/include/iris/container_traits.hpp b/include/iris/container_traits.hpp index a35e8f3..9e67123 100644 --- a/include/iris/container_traits.hpp +++ b/include/iris/container_traits.hpp @@ -665,6 +665,52 @@ concept has_shrink_to_fit = requires(std::remove_cvref_t& cont) { cont.shrink_to_fit(); }; +template +concept has_capacity = requires(std::remove_cvref_t const& cont) { + cont.capacity(); +}; + +template +concept has_max_size = requires(std::remove_cvref_t const& cont) { + cont.max_size(); +}; + +// Reserves capacity for at least `min_capacity` elements, growing it geometrically +// so that repeated calls with increasing sizes reallocate only a logarithmic number +// of times. +// +// Throws whatever `cont.reserve()` throws. +// +// The standard requires amortized constant time for `push_back()`, which needs the +// capacity to grow by at least a constant factor greater than 1. The factor 2 dates +// back to the HP STL (1994), apparently as an arbitrary choice: +// - https://eel.is/c++draft/sequence.reqmts +// - https://www.stepanovpapers.com/butler.hpl.hp/stl/wat_stl/VECTOR.H +// +// Note: libstdc++ bases the growth of `std::vector` on `size()` as +// `size() + max(size(), n)`, inherited from the code above. Inserting `n` elements at +// once after `clear()` thus allocates exactly `n`, so clearing and inserting slightly +// more elements each time reallocates every time, defeating the purpose of the +// geometric growth. It is likely a historical artifact rather than a deliberate +// design, as libstdc++'s own `std::string` grows based on the capacity. +template + requires + has_capacity && + has_max_size && + has_reserve +constexpr void reserve_geometric(ContainerT& cont, typename ContainerT::size_type const min_capacity) +{ + auto const capacity = cont.capacity(); + if (min_capacity <= capacity) return; + + // libstdc++ and libc++ use the factor 2, and MSVC STL uses 1.5. A libstdc++ + // discussion reported factors below 2 to be empirically worse: + // - https://gcc.gnu.org/pipermail/libstdc++/2013-March/039011.html + // - https://gcc.gnu.org/pipermail/libstdc++/2013-March/039012.html + auto const doubled = capacity > cont.max_size() - capacity ? min_capacity : capacity * 2; + cont.reserve(min_capacity > doubled ? min_capacity : doubled); +} + template concept compatible_iterator = diff --git a/include/iris/hash_fwd.hpp b/include/iris/hash_fwd.hpp index 8e0816c..313103a 100644 --- a/include/iris/hash_fwd.hpp +++ b/include/iris/hash_fwd.hpp @@ -9,8 +9,8 @@ #if !defined(_MSC_VER) && __cplusplus <= 202302L || \ (defined(_MSVC_STL_UPDATE) && _MSVC_STL_UPDATE < 202504L) || \ - (defined(__GLIBCXX__) && __GLIBCXX__ < 20241201) || \ - (defined(_LIBCPP_STD_VER) && _LIBCPP_STD_VER < 26) + (defined(__GLIBCXX__) && _GLIBCXX_RELEASE < 15) || \ + (defined(_LIBCPP_VERSION) && _LIBCPP_VERSION < 210000) # include // pre-C++26 #else diff --git a/include/iris/ngram/database.hpp b/include/iris/ngram/database.hpp index 41817c0..774e9da 100644 --- a/include/iris/ngram/database.hpp +++ b/include/iris/ngram/database.hpp @@ -16,91 +16,68 @@ #include #include -#include #include #include #include #include -#include +#include #include #include -#include +#include // IWYU pragma: keep 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 + // Leaves the documents unchanged if a database limit is exceeded [[nodiscard]] document_id add_document(std::basic_string_view const doc_text) { - auto transaction = id_store_.add_document(); - store_.append_index(transaction.new_slot(), doc_text); - transaction.commit(); - return transaction.new_id(); + this->compact(); + auto const doc_slot = id_store_.slot_for_new_document(); + store_.append_index(doc_slot, doc_text); + return id_store_.add_document(doc_text.size() + 1); } - // 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); - store_.append_index(transaction.new_slot(), doc_text); - transaction.commit(); + this->compact(); + auto const doc_slot = id_store_.slot_for_update(doc_id); + store_.append_index(doc_slot, doc_text); + id_store_.update_document(doc_id, doc_text.size() + 1); } - // Remove the document specified by `doc_id`. - // Removing an already-erased document is noop. - // NOT thread-safe + // No-op for a removed document 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 noexcept { 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(); } - // NOT thread-safe void swap(database& other) noexcept { using std::swap; @@ -108,95 +85,64 @@ class database 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) + 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) + 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 + // AND search; empty words are ignored. Returns whether any document matched. + template requires std::convertible_to, std::basic_string_view> - bool search(this auto const& db, SearchWordRange&& query_words, search_result& search_res) + bool search(this auto const& db, SearchWordRange&& query_words, search_result& search_res) { search_res.clear(); - if (std::ranges::empty(query_words)) return false; - if (db.store_.empty()) return false; - 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 word_it = std::ranges::begin(query_words); - - // Find first non-empty word - for (; word_it != std::ranges::end(query_words); ++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); - - // 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; + auto& plan = get_search_plan(); + plan.words.clear(); + plan.grams.clear(); + for (auto&& word_value : query_words) { + std::basic_string_view const word{word_value}; + if (word.empty()) continue; + if (!db.plan_word(plan, word)) return false; + } + if (plan.words.empty()) return false; + + // A single gram needs no candidate cache + if (plan.words.size() == 1 && plan.words.front().length <= 2) { + search_result_rollback rollback{search_res}; + db.search_single_gram(plan.grams.front().list, plan.words.front().length, search_res); + rollback.commit(); + return !search_res.empty(); + } - for (; word_it != std::ranges::end(query_words); ++word_it) { - auto&& word_value = *word_it; - std::basic_string_view const word{word_value}; - if (word.empty()) continue; + // Start from the word whose rarest gram is in the fewest documents + std::ranges::sort(plan.words, {}, [&plan](word_plan const& wp) { return plan.grams[wp.gram_begin].list.postings.size(); }); - db.template search_word(word_id++, word); + std::size_t const word_count = plan.words.size(); + auto& cache = get_search_res_cache(); + cache.reset(word_count); + for (std::size_t i = 0; i < word_count; ++i) { + db.search_word(cache, plan, plan.words[i], i == 0); + if (cache.empty()) return false; + } - // 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; - } + search_result_rollback rollback{search_res}; + search_res.reset(word_count); + search_res.reserve_documents(cache.size()); + for (std::size_t candidate = 0; candidate < cache.size(); ++candidate) { + search_res.add_document(db.make_document_id(db.id_store_.get_info(cache.slot(candidate)).doc_id)); + for (std::size_t word = 0; word < word_count; ++word) { + search_res.add_word_spans(cache.spans(candidate, word)); } } - assert(!search_res_cache.empty()); - assert(search_res.empty()); - - using document_id_maybe_ref_t = std::conditional_t< - std::is_reference_v()))>, - DocumentID const&, - DocumentID - >; - // Convert cache into real result - search_res.assign( - 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)) - }; - }) - ); - + rollback.commit(); return true; } @@ -206,152 +152,135 @@ class database return doc_id; } - // non-templated function to allow class instance wide cache - static detail::search_result_cache& get_search_res_cache() + template + struct search_result_rollback + { + explicit search_result_rollback(search_result& search_res) noexcept + : search_res_(&search_res) + {} + + void commit() noexcept + { + search_res_ = nullptr; + } + + ~search_result_rollback() + { + if (search_res_) { + search_res_->clear(); + } + } + + private: + search_result* search_res_; + }; + + void compact() + { + if (!id_store_.needs_compaction()) return; + + auto const slot_map = id_store_.compaction_map(); + store_.compact(slot_map); + id_store_.compact(slot_map); + } + + [[nodiscard]] 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 + struct gram_plan { - assert(!word.empty()); + int offset; // in the word + detail::posting_list list; + }; - if (word.size() == 1) { - this->search_word_impl(word_id, word); - } else { - this->search_word_impl(word_id, word); - } + struct word_plan + { + std::size_t word_id; // the index among the non-empty words + int length; + + // Into `search_plan::grams`, sorted by the number of postings + std::size_t gram_begin; + std::size_t gram_end; + }; + + struct search_plan + { + std::vector words; + std::vector grams; + }; + + [[nodiscard]] static search_plan& get_search_plan() + { + thread_local search_plan plan; + return plan; } - template - void search_word_impl(int const word_id, std::basic_string_view const word) const + // Returns false if the word cannot match + [[nodiscard]] bool plan_word(search_plan& plan, std::basic_string_view const word) const { - assert(word.size() >= N); + if (word.size() > static_cast(std::numeric_limits::max())) return false; + + std::size_t const gram_begin = plan.grams.size(); + int const length = static_cast(word.size()); + + // Use disjoint bigrams; overlap the final bigram for odd lengths + for (int offset = 0;; offset += 2) { + offset = std::max(std::min(offset, length - 2), 0); + auto const list = store_.find(word.substr(static_cast(offset), std::min(length, 2))); + if (list.postings.empty()) return false; + plan.grams.emplace_back(offset, list); + if (offset + 2 >= length) break; + } - auto& search_res_cache = get_search_res_cache(); + auto const grams = std::span{plan.grams}.subspan(gram_begin); + std::ranges::sort(grams, {}, [](gram_plan const& gp) { return gp.list.postings.size(); }); + plan.words.emplace_back(plan.words.size(), length, gram_begin, plan.grams.size()); + return true; + } - auto ng = gram::from_copy_n(word.begin()); + template + void search_single_gram(this auto const& db, detail::posting_list const& list, int const length, search_result& search_res) + { + search_res.reset(1); + for (auto const& posting : list.postings) { + auto const& slot_info = db.id_store_.get_info(posting.doc_slot); + if (!slot_info.is_used_for_search()) continue; - 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); - }); - if (search_res_cache.empty()) return; + search_res.add_document(db.make_document_id(slot_info.doc_id)); + search_res.add_word_positions(list.positions(posting), length); + } + } - } 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)) { - ++available_doc_count; - } - }); - if (available_doc_count == 0) { - search_res_cache.reset(); - return; + // Anchor spans at word starts so grams can be checked in rarity order + void search_word(detail::search_result_cache& cache, search_plan const& plan, word_plan const& wp, bool const is_first) const + { + auto const grams = std::span{plan.grams}.subspan(wp.gram_begin, wp.gram_end - wp.gram_begin); + auto const& first = grams.front(); + if (is_first) { + for (auto const& posting : first.list.postings) { + if (!id_store_.get_info(posting.doc_slot).is_used_for_search()) continue; + cache.add_candidate(posting.doc_slot, wp.word_id, first.list.positions(posting), first.offset, wp.length); } - } - unsigned current_ngram = 1; - auto const do_search = [&](int remaining_chars) { - return [&, remaining_chars, overlapping_chars = int(N) - remaining_chars](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 detail::search_continuation::proceed; - } - - // Find the existing match set from the previous iteration. - // If none exists, any subsequent characters of the document will not match. - // - // 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); - if (!word_match) return detail::search_continuation::proceed; - - // Prevent *resurrecting* the false-positive match on "match -> unmatch -> match" pattern. - // For example, when the document is "abef" and the query is "abXXef", - // - gram{"ab"} -> match (successful_ngrams = 1) - // - 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; - return detail::search_continuation::proceed; - } - - // Find contiguous match; document has [previous ng, current ng] - for (auto it = word_match->spans_.begin(); it != word_match->spans_.end();) { - auto& prev_pos = *it; - - if (std::ranges::binary_search(positions, prev_pos.upper - overlapping_chars)) { - // Matched; the current word's current n-gram is contiguous to the previous n-gram - prev_pos.upper += remaining_chars; - ++it; - continue; - } - // Erase exiting match that indicates the below structure - // [previous ng, ...some unrelated chars..., current ng] - it = word_match->spans_.erase(it); - } - - // Even if *all* existing matches fit - // [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; - return detail::search_continuation::proceed; - } - - ++word_match->successful_ngrams_; - return detail::search_continuation::proceed; - }; - }; - - std::size_t i = N; - 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; - ++current_ngram; + } else { + cache.narrow(first.list, [&](std::size_t const candidate, std::span const positions) { + return cache.set_word_spans(candidate, wp.word_id, positions, first.offset, wp.length); + }); } - if constexpr (N >= 2) { - // When the remaining character count is remainder of `word.size() % N`, - // search by the *slided* remaining characters. - // - // For example, when the document is "今日は晴れです": - // - // When doing 3-gram search with "今日は雨": - // 1. Search by "今日は" in the normal loop - // - // 2. Then, - // i == 3 - // remaining_chars == word.size() - i == 1 - // overlapping_chars == N - remaining_chars == 2 - // next_search_pos = i - overlapping_chars == 1 - // - // 3. Try to match "日は雨" in the last loop - if (int const remaining_chars = static_cast(word.size() - i); remaining_chars > 0) { - 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; - ++current_ngram; - } + for (auto const& gp : grams.subspan(1)) { + if (cache.empty()) return; + cache.narrow(gp.list, [&](std::size_t const candidate, std::span const positions) { + return cache.keep_word_spans(candidate, wp.word_id, positions, gp.offset); + }); } - if constexpr (IsFirstWord) { - // A first word of exactly one n-gram runs no continuation searches - if (current_ngram == 1) return; + if (wp.length >= 2) { + cache.remove_overlapping_word_spans(wp.word_id); } - search_res_cache.remove_stale_document_matches(word_id, current_ngram); } detail::id_store id_store_; diff --git a/include/iris/ngram/detail/id_store.hpp b/include/iris/ngram/detail/id_store.hpp index 2d8dfec..e1cdcb9 100644 --- a/include/iris/ngram/detail/id_store.hpp +++ b/include/iris/ngram/detail/id_store.hpp @@ -11,8 +11,13 @@ #include #include +#include +#include +#include #include +#include +#include namespace iris::ngram::detail { @@ -28,93 +33,47 @@ class id_store [[nodiscard]] bool is_used_for_search() const noexcept { return doc_id != document_id::tombstone && is_visible; } }; -private: - struct [[nodiscard]] add_document_transaction; - friend add_document_transaction; - struct add_document_transaction - { - add_document_transaction(id_store* store, document_id new_id, document_slot new_slot) noexcept - : store_(store) - , new_id_(new_id) - , new_slot_(new_slot) - {} - - [[nodiscard]] document_id new_id() const noexcept { return new_id_; } - [[nodiscard]] document_slot new_slot() const noexcept { return new_slot_; } - - void commit() - { - store_->id_to_slot_.emplace_back(new_slot_); - store_->slot_infos_.emplace_back(new_id_); - } - - private: - id_store* store_; - document_id new_id_; - document_slot new_slot_; - }; - -public: - add_document_transaction add_document() + [[nodiscard]] document_slot slot_for_new_document() const { - return { - this, - static_cast(id_to_slot_.size()), - static_cast(slot_infos_.size()) - }; - } - -private: - struct [[nodiscard]] update_document_transaction; - friend update_document_transaction; - struct update_document_transaction - { - update_document_transaction(id_store* store, slot_info_t& old_slot_info, document_slot new_slot) noexcept - : store_(store) - , old_slot_info_(old_slot_info) - , new_slot_(new_slot) - {} - - [[nodiscard]] document_slot new_slot() const noexcept { return new_slot_; } - - void commit() - { - auto const doc_id = old_slot_info_.doc_id; - 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_; + if (id_to_slot_.size() >= to_index(document_id::tombstone)) { + throw std::length_error{"no more document id can be issued"}; } + return this->next_slot(); + } - private: - id_store* store_; - slot_info_t& old_slot_info_; - document_slot new_slot_; - }; - -public: - update_document_transaction update_document(document_id const doc_id) + [[nodiscard]] document_slot slot_for_update(document_id const doc_id) const { if (to_index(doc_id) >= id_to_slot_.size()) { throwf("document id #{} is beyond the range of ids issued by this database", doc_id); } - - document_slot const& doc_slot = id_to_slot_[to_index(doc_id)]; - if (doc_slot == document_slot::tombstone) { + if (id_to_slot_[to_index(doc_id)] == document_slot::tombstone) { throwf("cannot update a removed document #{}", doc_id); } + return this->next_slot(); + } - assert(to_index(doc_slot) < slot_infos_.size()); - auto& old_slot_info = slot_infos_[to_index(doc_slot)]; - assert(old_slot_info.doc_id == doc_id); + // Requires the slot to be checked by `slot_for_new_document()`; `weight` is the length plus one + document_id add_document(std::size_t const weight) + { + auto const doc_id = static_cast(id_to_slot_.size()); + auto const doc_slot = static_cast(slot_infos_.size()); + id_to_slot_.emplace_back(doc_slot); + slot_infos_.emplace_back(doc_id); + this->add_slot_weight(weight); + return doc_id; + } - return { - this, old_slot_info, static_cast(slot_infos_.size()) - }; + void update_document(document_id const doc_id, std::size_t const weight) + { + document_slot& doc_slot = id_to_slot_[to_index(doc_id)]; + document_slot const old_slot = doc_slot; + slot_info_t const info = this->get_info(old_slot); + slot_infos_.emplace_back(info); + this->add_slot_weight(weight); + + this->get_info(old_slot).doc_id = document_id::tombstone; + this->kill_slot_weight(old_slot); + doc_slot = static_cast(slot_infos_.size() - 1); } void remove_document(document_id const doc_id) noexcept @@ -126,7 +85,6 @@ class id_store document_slot& doc_slot = id_to_slot_[to_index(doc_id)]; if (doc_slot == document_slot::tombstone) { - // Removing an already-removed document is no-op; same semantics as STL containers return; } @@ -135,18 +93,14 @@ class id_store assert(!slot_info.is_stale()); slot_info.doc_id = document_id::tombstone; + this->kill_slot_weight(doc_slot); doc_slot = document_slot::tombstone; } [[nodiscard]] bool has_document(document_id const doc_id) const noexcept { - 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)]; @@ -154,8 +108,6 @@ class id_store return doc_slot != document_slot::tombstone; } - // This method is intentionally `noexcept` and invokes only assertion because - // it is heavily accessed via actual lookup [[nodiscard]] slot_info_t const& get_info(document_slot const doc_slot) const noexcept { assert(to_index(doc_slot) < slot_infos_.size()); @@ -193,10 +145,50 @@ class id_store this->get_info(doc_slot).is_visible = flag; } + // Compact when the stale weight dominates, which amortizes the linear cost. + // The minimum avoids frequent tiny rebuilds. + [[nodiscard]] bool needs_compaction() const noexcept + { + return stale_weight_ >= min_compaction_weight && stale_weight_ > live_weight_; + } + + // The new slot of each slot in the same order, or `document_slot::tombstone` for a stale one + [[nodiscard]] std::vector compaction_map() const + { + std::vector slot_map; + slot_map.reserve(slot_infos_.size()); + std::uint32_t new_slot = 0; + for (auto const& info : slot_infos_) { + slot_map.emplace_back(info.is_stale() ? document_slot::tombstone : static_cast(new_slot++)); + } + return slot_map; + } + + void compact(std::span const slot_map) noexcept + { + assert(slot_map.size() == slot_infos_.size()); + + std::size_t out = 0; + for (std::size_t in = 0; in < slot_infos_.size(); ++in) { + if (slot_map[in] == document_slot::tombstone) continue; + assert(to_index(slot_map[in]) == out); + slot_infos_[out] = slot_infos_[in]; + slot_weights_[out] = slot_weights_[in]; + id_to_slot_[to_index(slot_infos_[out].doc_id)] = slot_map[in]; + ++out; + } + slot_infos_.resize(out); + slot_weights_.resize(out); + stale_weight_ = 0; + } + void clear() noexcept { id_to_slot_.clear(); slot_infos_.clear(); + slot_weights_.clear(); + live_weight_ = 0; + stale_weight_ = 0; } void swap(id_store& other) noexcept @@ -204,11 +196,45 @@ class id_store using std::swap; swap(id_to_slot_, other.id_to_slot_); swap(slot_infos_, other.slot_infos_); + swap(slot_weights_, other.slot_weights_); + swap(live_weight_, other.live_weight_); + swap(stale_weight_, other.stale_weight_); } private: + // Stale documents below this weight hold at most about 128 KiB of postings and positions. + // Below it, the fixed cost of the compaction was measured to be significant. + static constexpr std::size_t min_compaction_weight = 4096; + + [[nodiscard]] document_slot next_slot() const + { + if (slot_infos_.size() >= to_index(document_slot::tombstone)) { + throw std::length_error{"no more document slot is available"}; + } + return static_cast(slot_infos_.size()); + } + + void add_slot_weight(std::size_t const weight) + { + assert(weight <= std::numeric_limits::max()); + slot_weights_.emplace_back(static_cast(weight)); + live_weight_ += weight; + } + + void kill_slot_weight(document_slot const doc_slot) noexcept + { + std::size_t const weight = slot_weights_[to_index(doc_slot)]; + live_weight_ -= weight; + stale_weight_ += weight; + } + std::vector id_to_slot_; std::vector slot_infos_; + + // Apart from `slot_infos_`, which searches access per posting + std::vector slot_weights_; + std::size_t live_weight_ = 0; + std::size_t stale_weight_ = 0; }; inline void swap(id_store& a, id_store& b) noexcept diff --git a/include/iris/ngram/detail/index.hpp b/include/iris/ngram/detail/index.hpp index b131a5e..c68168e 100644 --- a/include/iris/ngram/detail/index.hpp +++ b/include/iris/ngram/detail/index.hpp @@ -5,209 +5,429 @@ #include // IWYU pragma: keep -// Make sure we don't include `ngram/id.hpp` so that we can assure the -// internal logic never uses the external id type +// Not including `ngram/id.hpp` keeps the external id type out of the index +#include #include #include +#include -#include #include -#include #include -#include -#include #include -#include +#include +#include +#include +#include #include #include #include -#include #include +#include #include // IWYU pragma: keep namespace iris::ngram::detail { -enum struct [[nodiscard]] search_continuation : bool +struct posting { - abort = false, - proceed = true, + document_slot doc_slot; + std::uint32_t pos_count; + int pos_or_offset; // the position if `pos_count == 1`, otherwise the offset into the pool }; -class posting_list +// Sorted by `document_slot`; invalidated by any modification of the index +struct posting_list { + std::span postings; + std::span pool; + + [[nodiscard]] std::span positions(posting const& p IRIS_LIFETIMEBOUND) const noexcept + { + if (p.pos_count == 1) return {&p.pos_or_offset, 1}; + return pool.subspan(static_cast(p.pos_or_offset), p.pos_count); + } +}; + +// Each n-gram owns a slot-sorted segment of a shared pool with `bit_ceil(size)` capacity. +// Relocation leaves old segments unused until compaction. +// Multiple positions are pooled; single positions are stored inline. +template +class gram_index +{ + static_assert(N == 1 || N == 2, "gram_index requires a gram stored as a single integer"); + + using key_type = std::make_unsigned_t::data)>; + + static constexpr std::uint32_t npos = std::numeric_limits::max(); + + // An empty slot has `begin == npos` + struct chain_slot + { + key_type key; + std::uint32_t begin = npos; + std::uint32_t size = 0; + + [[nodiscard]] bool is_full() const noexcept + { + return std::has_single_bit(size) || size == 0; + } + }; + + // In the current document iff `serial - serial_base < keys.size()`; the difference is the run id. + // Slots are not cleared between documents. + struct run_slot + { + key_type key; + std::uint32_t serial = npos; + }; + + // Scratch for grouping a document by n-gram. Copies omit it; copy assignment keeps the buffers for reuse. + struct run_buffers + { + run_buffers() = default; + run_buffers(run_buffers const&) noexcept {} + run_buffers(run_buffers&&) = default; + run_buffers& operator=(run_buffers const&) noexcept { return *this; } + run_buffers& operator=(run_buffers&&) = default; + + std::vector slots; // the size is zero or a power of two + int shift = 0; + std::uint32_t serial_base = 0; + + std::vector> run_of; // of each occurrence + std::vector keys; + std::vector begins; // into `positions`, with the end of the last run + std::vector> positions; + }; + public: - void append(document_slot const doc_slot, int pos) + [[nodiscard]] posting_list find(gram const ng) const noexcept { - assert(postings_.empty() || postings_.back().doc_slot == document_slot::sentinel); - assert(postings_.empty() || postings_.back().pos_offset == positions_.size()); + auto const* const chain = this->find_chain(key_of(ng)); + if (!chain) return {}; + return {this->segment_of(*chain), positions_}; + } + + // Groups `input` by n-gram for `append()` in O(occurrences + distinct n-grams) expected time. + // Throws `std::length_error` if appending would exceed the limits; the index is unchanged. + void group(std::basic_string_view const input) + { + auto& runs = runs_; + runs.serial_base += static_cast(runs.keys.size()); + runs.keys.clear(); + runs.begins.clear(); + runs.positions.clear(); + if (input.size() < N) return; + + std::size_t const gram_count = input.size() - N + 1; + if (gram_count > npos - runs.serial_base) { + // Serial numbers are exhausted + std::ranges::fill(runs.slots, run_slot{}); + runs.serial_base = 0; + } + if (runs.slots.empty()) { + this->grow_run_slots(); + } + + container::reserve_geometric(runs.run_of, gram_count); + runs.run_of.resize(gram_count); - if (postings_.empty()) { - postings_.emplace_back(doc_slot, 0u); - postings_.emplace_back(document_slot::sentinel, 0u); + for (std::size_t i = 0; i < gram_count; ++i) { + key_type const key = key_of(gram::from_copy_n(input.begin() + i)); + auto* slot = &this->run_slot_of(key); + std::uint32_t run = slot->serial - runs.serial_base; - } else if (auto last_posting = postings_.end() - 2; last_posting->doc_slot != doc_slot) { - if (last_posting->doc_slot > doc_slot) { - throw std::logic_error{"documents must be indexed in non-decreasing order of document_slot"}; + if (run >= runs.keys.size()) { + if ((runs.keys.size() + 1) * 2 > runs.slots.size()) { + this->grow_run_slots(); + slot = &this->run_slot_of(key); + } + run = static_cast(runs.keys.size()); + runs.keys.emplace_back(key); + runs.begins.emplace_back(0); + *slot = {key, runs.serial_base + run}; } - assert(postings_.back().doc_slot == document_slot::sentinel); + runs.run_of[i] = run; + ++runs.begins[run]; + } + + runs.begins.emplace_back(0); + std::inclusive_scan(runs.begins.begin(), runs.begins.end(), runs.begins.begin()); - // Promote the sentinel into a real posting - postings_.back().doc_slot = doc_slot; - postings_.emplace_back(document_slot::sentinel, static_cast(positions_.size())); + // Fill backwards to keep the positions ascending within each run + container::reserve_geometric(runs.positions, gram_count); + runs.positions.resize(gram_count); + for (std::size_t i = gram_count; i-- > 0;) { + runs.positions[--runs.begins[runs.run_of[i]]] = static_cast(i); + } + + std::size_t position_growth = 0; + for (std::size_t run = 0; run < runs.keys.size(); ++run) { + if (std::uint32_t const pos_count = runs.begins[run + 1] - runs.begins[run]; pos_count > 1) { + position_growth += pos_count; + } + } + if (position_growth > static_cast(std::numeric_limits::max()) - positions_.size()) { + throw std::length_error{"too many n-gram positions in the index"}; } - positions_.emplace_back(pos); - ++postings_.back().pos_offset; + // Disjoint chains hold at most `postings_.size()` postings, so the growth is at most + // `postings_.size() * 2 + keys.size()`. Look up the chains for per-chain bounds only + // if this may exceed the limit; `npos` is reserved for empty slots. + std::uint64_t const posting_count = postings_.size(); + if (posting_count * 3 + runs.keys.size() <= npos) return; + + std::uint64_t posting_growth = 0; + for (auto const key : runs.keys) { + auto const* const chain = this->find_chain(key); + if (!chain) { + posting_growth += 1; + + } else if (chain->is_full()) { + posting_growth += std::max(std::uint64_t{chain->size} * 2, std::uint64_t{1}); + } + } + if (posting_count + posting_growth > npos) { + throw std::length_error{"too many postings in the index"}; + } } - template - void for_each_documents(F&& f) const + // Requires `group()` and a `doc_slot` greater than all indexed slots + void append(document_slot const doc_slot) { - static_assert(std::invocable>); - - constexpr bool f_returns_continuation = std::same_as< - std::invoke_result_t>, - search_continuation - >; - - for (auto const& [posting, next] : postings_ | std::views::pairwise) { - std::span const posting_span{ - std::next(positions_.begin(), posting.pos_offset), - static_cast(next.pos_offset - posting.pos_offset) - }; - - if constexpr (f_returns_continuation) { - search_continuation const cont = f(posting.doc_slot, posting_span); - if (cont == search_continuation::abort) break; + auto const& runs = runs_; + for (std::size_t run = 0; run < runs.keys.size(); ++run) { + auto& chain = this->insert_chain(runs.keys[run]); + if (chain.is_full()) { + this->grow_segment(chain); + } + assert(chain.size == 0 || postings_[chain.begin + chain.size - 1].doc_slot < doc_slot); + + std::uint32_t const pos_count = runs.begins[run + 1] - runs.begins[run]; + int const* const run_pos = runs.positions.data() + runs.begins[run]; + if (pos_count == 1) { + postings_[chain.begin + chain.size] = {doc_slot, pos_count, *run_pos}; + } else { - f(posting.doc_slot, posting_span); + postings_[chain.begin + chain.size] = {doc_slot, pos_count, static_cast(positions_.size())}; + positions_.insert(positions_.end(), run_pos, run_pos + pos_count); + } + ++chain.size; + } + } + + // Drops the postings of the slots mapped to tombstone and renumbers the others by `slot_map` + void compact(std::span const slot_map) + { + auto const is_live = [&](posting const& p) { return slot_map[to_index(p.doc_slot)] != document_slot::tombstone; }; + + std::size_t chain_count = 0; + std::size_t posting_count = 0; + std::size_t position_count = 0; + for (auto const& chain : chain_slots_) { + if (chain.begin == npos) continue; + + std::size_t live_count = 0; + for (auto const& p : this->segment_of(chain)) { + if (!is_live(p)) continue; + ++live_count; + if (p.pos_count > 1) { + position_count += p.pos_count; + } + } + if (live_count == 0) continue; + ++chain_count; + posting_count += std::bit_ceil(live_count); + } + + gram_index result; + result.reserve_chains(chain_count); + result.postings_.reserve(posting_count); + result.positions_.reserve(position_count); + + for (auto const& chain : chain_slots_) { + if (chain.begin == npos) continue; + + chain_slot* new_chain = nullptr; + for (auto const& p : this->segment_of(chain)) { + if (!is_live(p)) continue; + if (!new_chain) { + new_chain = &result.chain_slot_of(chain.key); + *new_chain = {chain.key, static_cast(result.postings_.size()), 0}; + ++result.chain_count_; + } + + int pos_or_offset = p.pos_or_offset; + if (p.pos_count > 1) { + pos_or_offset = static_cast(result.positions_.size()); + auto const positions = std::span{positions_}.subspan(static_cast(p.pos_or_offset), p.pos_count); + result.positions_.insert(result.positions_.end(), positions.begin(), positions.end()); + } + result.postings_.emplace_back(slot_map[to_index(p.doc_slot)], p.pos_count, pos_or_offset); + ++new_chain->size; + } + if (new_chain) { + result.postings_.resize(new_chain->begin + std::bit_ceil(std::size_t{new_chain->size})); } } + + // Keep `runs_` for reuse + chain_slots_.swap(result.chain_slots_); + chain_count_ = result.chain_count_; + chain_shift_ = result.chain_shift_; + postings_.swap(result.postings_); + positions_.swap(result.positions_); } - void swap(posting_list& other) noexcept + void clear() noexcept + { + chain_slots_.clear(); + chain_count_ = 0; + chain_shift_ = 0; + postings_.clear(); + positions_.clear(); + } + + void swap(gram_index& other) noexcept { using std::swap; + swap(chain_slots_, other.chain_slots_); + swap(chain_count_, other.chain_count_); + swap(chain_shift_, other.chain_shift_); swap(postings_, other.postings_); swap(positions_, other.positions_); + swap(runs_, other.runs_); } private: - struct posting_t + [[nodiscard]] static constexpr key_type key_of(gram const ng) noexcept { - document_slot doc_slot; - unsigned pos_offset = 0; - }; - std::vector postings_; - std::vector positions_; -}; + return static_cast(ng.data); + } -inline void swap(posting_list& a, posting_list& b) noexcept -{ - a.swap(b); -} + // Fibonacci hashing + [[nodiscard]] static std::size_t home_of(key_type const key, int const shift) noexcept + { + return static_cast((static_cast(key) * 0x9E3779B97F4A7C15ull) >> shift); + } -template -class gram_index -{ -public: + // Returns the slot holding `key`, or the empty slot for it template - [[nodiscard]] auto find_list(this Self&& self, gram const ng) - noexcept - -> std::conditional_t>, posting_list const*, posting_list*> + [[nodiscard]] auto& chain_slot_of(this Self& self, key_type const key) noexcept { - if (auto const it = self.gram_entries_.find(ng); it != self.gram_entries_.end()) { - return it->second.get(); + assert(!self.chain_slots_.empty()); + std::size_t const mask = self.chain_slots_.size() - 1; + auto i = home_of(key, self.chain_shift_); + while (self.chain_slots_[i].begin != npos && self.chain_slots_[i].key != key) { + i = (i + 1) & mask; } - return nullptr; + return self.chain_slots_[i]; } - template - void search(gram const ng, F&& f) const + [[nodiscard]] std::span segment_of(chain_slot const& chain) const noexcept { - auto const* list = this->find_list(ng); - if (!list) return; - list->for_each_documents(f); + return std::span{postings_}.subspan(chain.begin, chain.size); } - [[nodiscard]] bool empty() const noexcept + [[nodiscard]] chain_slot const* find_chain(key_type const key) const noexcept { - return gram_entries_.empty(); + if (chain_slots_.empty()) return nullptr; + auto const& slot = this->chain_slot_of(key); + return slot.begin == npos ? nullptr : &slot; } - void clear() noexcept + chain_slot& insert_chain(key_type const key) { - gram_entries_.clear(); + if (chain_slots_.empty()) { + this->reserve_chains(1); + } + auto* chain = &this->chain_slot_of(key); + if (chain->begin == npos) { + if ((chain_count_ + 1) * 2 > chain_slots_.size()) { + this->reserve_chains(chain_count_ + 1); + chain = &this->chain_slot_of(key); + } + *chain = {key, 0, 0}; + ++chain_count_; + } + return *chain; } - void swap(gram_index& other) noexcept + // Keeps the load factor at or below 1/2 + void reserve_chains(std::size_t const count) { - using std::swap; - swap(gram_entries_, other.gram_entries_); + if (count * 2 <= chain_slots_.size()) return; + + std::size_t const new_size = std::bit_ceil(std::max(count * 2, std::size_t{16})); + std::vector old_slots(new_size); + chain_slots_.swap(old_slots); + chain_shift_ = 64 - std::countr_zero(new_size); + + for (auto const& old_slot : old_slots) { + if (old_slot.begin == npos) continue; + this->chain_slot_of(old_slot.key) = old_slot; + } } - void merge_new_entries(std::vector, std::unique_ptr>>& pending) + // Returns the slot of the current document holding `key`, or a free slot for it + [[nodiscard]] run_slot& run_slot_of(key_type const key) noexcept { - for (auto& [key, pl] : pending) { - [[maybe_unused]] auto const [it, inserted] = gram_entries_.try_emplace(key, std::move(pl)); - assert(inserted); - assert(it->second != nullptr && pl == nullptr); + auto& runs = runs_; + std::size_t const mask = runs.slots.size() - 1; + std::size_t const run_count = runs.keys.size(); + auto i = home_of(key, runs.shift); + while (runs.slots[i].serial - runs.serial_base < run_count && runs.slots[i].key != key) { + i = (i + 1) & mask; } + return runs.slots[i]; } -private: - std::unordered_map, std::unique_ptr> - gram_entries_; -}; - -template -void swap(gram_index& a, gram_index& b) noexcept -{ - a.swap(b); -} + // Only the runs of the current document need to be moved + void grow_run_slots() + { + auto& runs = runs_; + std::size_t const new_size = std::max(runs.slots.size() * 2, std::size_t{16}); + std::vector new_slots(new_size); + runs.slots.swap(new_slots); + runs.shift = 64 - std::countr_zero(new_size); + + for (std::size_t run = 0; run < runs.keys.size(); ++run) { + this->run_slot_of(runs.keys[run]) = {runs.keys[run], runs.serial_base + static_cast(run)}; + } + } -template -struct gram_pos_t -{ - gram ng; - int pos; + void grow_segment(chain_slot& chain) + { + assert(chain.is_full()); - [[nodiscard]] constexpr bool operator==(gram_pos_t const&) const noexcept = default; - [[nodiscard]] constexpr std::strong_ordering operator<=>(gram_pos_t const&) const noexcept = default; -}; + if (chain.size == 0) { + chain.begin = static_cast(postings_.size()); + postings_.resize(postings_.size() + 1); -template -struct gram_index_storage -{ - gram_index idx; + } else if (chain.begin + chain.size == postings_.size()) { + postings_.resize(postings_.size() + chain.size); - // Caches - std::vector, default_init_allocator>> - batch_grams; + } else { + auto const new_begin = static_cast(postings_.size()); + postings_.resize(postings_.size() + std::size_t{chain.size} * 2); + std::copy_n(postings_.begin() + chain.begin, chain.size, postings_.begin() + new_begin); + chain.begin = new_begin; + } + } - std::vector, std::unique_ptr>> - batch_pending; + std::vector chain_slots_; // the size is zero or a power of two + std::size_t chain_count_ = 0; + int chain_shift_ = 0; - void clear() noexcept - { - idx.clear(); - batch_grams.clear(); - batch_pending.clear(); - } + std::vector> postings_; + std::vector> positions_; - 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); - } + run_buffers runs_; }; template -void swap(gram_index_storage& a, gram_index_storage& b) noexcept +void swap(gram_index& a, gram_index& b) noexcept { a.swap(b); } @@ -215,124 +435,47 @@ void swap(gram_index_storage& a, gram_index_storage& b) noex template struct index_storage { + // Checks the limits of both indexes before changing either void append_index(document_slot const doc_slot, std::basic_string_view const input) { - this->template append_index<1>(doc_slot, input); - this->template append_index<2>(doc_slot, input); - } - - void clear() noexcept - { - uni_data_.clear(); - bi_data_.clear(); + if (input.size() > static_cast(std::numeric_limits::max())) { + throw std::length_error{"document is too long to be indexed"}; + } + uni_index_.group(input); + bi_index_.group(input); + uni_index_.append(doc_slot); + bi_index_.append(doc_slot); } - void swap(index_storage& other) noexcept + void compact(std::span const slot_map) { - using std::swap; - swap(uni_data_, other.uni_data_); - swap(bi_data_, other.bi_data_); + uni_index_.compact(slot_map); + bi_index_.compact(slot_map); } - [[nodiscard]] bool empty() const noexcept + void clear() noexcept { - return - this->template get_data<1>().idx.empty() && - this->template get_data<2>().idx.empty(); + uni_index_.clear(); + bi_index_.clear(); } - template - void search(gram const ng, F&& f) const + void swap(index_storage& other) noexcept { - this->template get_data().idx.search(ng, f); + using std::swap; + swap(uni_index_, other.uni_index_); + swap(bi_index_, other.bi_index_); } - template - [[nodiscard]] auto& get_index(this auto& self IRIS_LIFETIMEBOUND) noexcept + [[nodiscard]] posting_list find(std::basic_string_view const chars) const noexcept { - return self.template get_data().idx; + assert(chars.size() == 1 || chars.size() == 2); + if (chars.size() == 1) return uni_index_.find(gram<1, CharT>::from_copy_n(chars.begin())); + return bi_index_.find(gram<2, CharT>::from_copy_n(chars.begin())); } private: - 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 - { - if constexpr (N == 1) { - return self.uni_data_; - } else if constexpr (N == 2) { - return self.bi_data_; - } else { - static_assert(false); - } - } - - template - void append_index( - document_slot const doc_slot, - std::basic_string_view const input - ) - { - if (input.size() < N) return; - gram_index_storage& data = this->template get_data(); - - // 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 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. - // - // 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. - // - // 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); - - if constexpr (N == 1) { - for (std::size_t i = 0; i < input.size(); ++i) { - data.batch_grams[i].ng.data = input[i]; - data.batch_grams[i].pos = static_cast(i); - } - - } else { - for (std::size_t i = 0; i + N <= input.size(); ++i) { - data.batch_grams[i].ng.copy_n(input.begin() + i); - data.batch_grams[i].pos = static_cast(i); - } - } - std::ranges::sort(data.batch_grams); - - data.batch_pending.clear(); - - for (auto const& chunk : data.batch_grams | std::views::chunk_by( - [](auto const& a, auto const& b) { return a.ng == b.ng; } - )) { - auto const& key = chunk.front().ng; - if (posting_list* const pl = data.idx.find_list(key)) { - for (auto const& gp : chunk) { - pl->append(doc_slot, gp.pos); - } - - } else { - auto& new_pl = data.batch_pending.emplace_back(key, std::make_unique()).second; - for (auto const& gp : chunk) { - new_pl->append(doc_slot, gp.pos); - } - } - } - - data.idx.merge_new_entries(data.batch_pending); - } + gram_index<1, CharT> uni_index_; + gram_index<2, CharT> bi_index_; }; template diff --git a/include/iris/ngram/detail/search_result_cache.hpp b/include/iris/ngram/detail/search_result_cache.hpp index 00c421d..1a15f59 100644 --- a/include/iris/ngram/detail/search_result_cache.hpp +++ b/include/iris/ngram/detail/search_result_cache.hpp @@ -5,148 +5,185 @@ #include // IWYU pragma: keep +#include #include -#include #include #include -#include #include -#include +#include #include -#include -#include +#include +#include #include +#include +#include namespace iris::ngram::detail { -class [[nodiscard]] search_result_cache +// Galloping lower bound from the front; skipping k elements costs O(1 + log(1 + k)). +template +[[nodiscard]] std::size_t lower_bound_from_front(std::span const range, U const& value, Proj proj = {}) + noexcept(noexcept(std::invoke(proj, range[0]) < value)) { - using doc_matches_map = std::flat_map>; + std::size_t lo = 0; + std::size_t hi = 1; + while (hi < range.size() && std::invoke(proj, range[hi]) < value) { + lo = hi; + hi *= 2; + } + auto const sub = range.subspan(lo, std::min(hi, range.size()) - lo); + return lo + static_cast(std::ranges::lower_bound(sub, value, {}, proj) - sub.begin()); +} - struct word_matches_handle +// Candidates stay slot-sorted; each word's spans stay position-sorted. +class search_result_cache +{ +public: + struct span_range { - doc_matches_map::iterator doc_it; - search_word_match* word_match = nullptr; - - [[nodiscard]] - search_word_match* operator->() const noexcept - { - return word_match; - } - - [[nodiscard]] explicit operator bool() const noexcept - { - return word_match; - } + std::uint32_t begin = 0; + std::uint32_t end = 0; }; -public: - [[nodiscard]] auto& doc_matches() noexcept { return doc_matches_; } + void reset(std::size_t const word_count) noexcept + { + word_count_ = word_count; + slots_.clear(); + ranges_.clear(); + spans_.clear(); + } - // Returns whether search must continue - template - [[nodiscard]] bool init_word_matches(document_slot const doc_slot, int const word_id, std::span const positions) + [[nodiscard]] bool empty() const noexcept { - assert(!positions.empty()); - - doc_matches_map::iterator doc_matches_it; - if constexpr (IsFirstWord) { - assert(word_id == 0); - assert(doc_matches_.empty() || doc_matches_.rbegin()->first < doc_slot); - doc_matches_it = doc_matches_.try_emplace(doc_matches_.end(), doc_slot); // hint: append - ++live_doc_count_; - - } else { - doc_matches_it = doc_matches_.find(doc_slot); - if (doc_matches_it == doc_matches_.end()) return false; // no new docs after word 0 - if (doc_matches_it->second.empty()) return false; // tombstoned (soft-erased) document; skip - } + return slots_.empty(); + } - assert(!std::ranges::contains(doc_matches_it->second, word_id, &search_word_match::word_id_)); - auto& word_match = doc_matches_it->second.emplace_back(word_id); - word_match.spans_.assign_range(positions | std::views::transform([](int const pos) -> interval { - return {pos, pos + static_cast(N)}; - })); - return true; + [[nodiscard]] std::size_t size() const noexcept + { + return slots_.size(); } - [[nodiscard]] word_matches_handle get_word_matches(document_slot const doc_slot, int const word_id) + [[nodiscard]] document_slot slot(std::size_t const candidate) const noexcept { - auto const doc_matches_it = doc_matches_.find(doc_slot); - if (doc_matches_it == doc_matches_.end()) return {}; - - // We don't need to do *full* `std::find` here; the word match is - // always inserted sequentially so if it exists, it is always placed - // at the *back* of the vector. - if ( - doc_matches_it->second.empty() || - doc_matches_it->second.back().word_id_ != word_id - ) { - assert( - doc_matches_it->second.empty() || - // Make sure the matching element does not exist at the position except for *back* - !std::ranges::contains(doc_matches_it->second, word_id, &search_word_match::word_id_) - ); - return {}; - } - assert(!doc_matches_it->second.back().spans_.empty()); - return {doc_matches_it, &doc_matches_it->second.back()}; + return slots_[candidate]; } - void erase_document(word_matches_handle const& handle) + [[nodiscard]] std::span const> spans(std::size_t const candidate, std::size_t const word) const noexcept { - assert(!handle.doc_it->second.empty()); // never double-tombstone - handle.doc_it->second.clear(); // make this tombstone - assert(live_doc_count_ >= 1); - --live_doc_count_; + auto const& range = ranges_[candidate * word_count_ + word]; + return std::span{spans_}.subspan(range.begin, range.end - range.begin); } - void remove_stale_document_matches(int const word_id, unsigned const expected_ngrams) + // A gram at `offset` in the word occurring at `pos` implies a span starting at `pos - offset`. + + // Must be called in ascending order of `doc_slot` + void add_candidate( + document_slot const doc_slot, std::size_t const word, std::span const positions, + int const offset, int const length + ) { - auto [keys, values] = std::move(doc_matches_).extract(); + assert(slots_.empty() || slots_.back() < doc_slot); + auto const range = this->append_spans(positions, offset, length); + if (range.begin == range.end) return; + + slots_.emplace_back(doc_slot); + ranges_.resize(ranges_.size() + word_count_); + ranges_[ranges_.size() - word_count_ + word] = range; + } + // Keeps the candidates having a posting in `list` for which `f(candidate, positions)` returns true + template + void narrow(posting_list const& list, F&& f) + { + std::span postings = list.postings; std::size_t out = 0; - for (std::size_t in = 0; in < keys.size(); ++in) { - auto& word_matches = values[in]; - bool is_word_survived = false; - std::erase_if(word_matches, [&](search_word_match const& wm) { - if (wm.word_id_ != word_id) return false; - if (wm.successful_ngrams_ != expected_ngrams) return true; - is_word_survived = true; - return false; - }); - if (!is_word_survived || word_matches.empty()) continue; + for (std::size_t in = 0; in < slots_.size(); ++in) { + postings = postings.subspan(lower_bound_from_front(postings, slots_[in], &posting::doc_slot)); + if (postings.empty()) break; + if (postings.front().doc_slot != slots_[in]) continue; + if (!f(in, list.positions(postings.front()))) continue; if (out != in) { - keys[out] = keys[in]; - values[out] = std::move(values[in]); + slots_[out] = slots_[in]; + std::copy_n(ranges_.begin() + in * word_count_, word_count_, ranges_.begin() + out * word_count_); } ++out; } - keys.resize(out); - values.resize(out); - doc_matches_.replace(std::move(keys), std::move(values)); - live_doc_count_ = out; + slots_.resize(out); + ranges_.resize(out * word_count_); } - // Clears the search result and tombstones - void reset() noexcept + [[nodiscard]] bool set_word_spans( + std::size_t const candidate, std::size_t const word, std::span const positions, + int const offset, int const length + ) { - doc_matches_.clear(); - live_doc_count_ = 0; + assert(word < word_count_); + auto const range = this->append_spans(positions, offset, length); + ranges_[candidate * word_count_ + word] = range; + return range.begin != range.end; } - [[nodiscard]] bool empty() const noexcept + [[nodiscard]] bool keep_word_spans( + std::size_t const candidate, std::size_t const word, std::span positions, int const offset + ) noexcept { - return live_doc_count_ == 0; + auto& range = ranges_[candidate * word_count_ + word]; + auto out = range.begin; + for (auto in = range.begin; in < range.end; ++in) { + int const pos = spans_[in].lower + offset; + positions = positions.subspan(lower_bound_from_front(positions, pos)); + if (positions.empty()) break; + if (positions.front() != pos) continue; + spans_[out++] = spans_[in]; + } + range.end = out; + return range.begin != range.end; + } + + // Runs after all grams are checked so that a span failing later cannot hide a match + void remove_overlapping_word_spans(std::size_t const word) noexcept + { + for (std::size_t candidate = 0; candidate < slots_.size(); ++candidate) { + auto& range = ranges_[candidate * word_count_ + word]; + auto out = range.begin; + for (auto in = range.begin; in < range.end; ++in) { + if (out != range.begin && spans_[in].lower < spans_[out - 1].upper) continue; + spans_[out++] = spans_[in]; + } + range.end = out; + } } private: - doc_matches_map doc_matches_; - std::size_t live_doc_count_ = 0; + [[nodiscard]] span_range append_spans(std::span const positions, int const offset, int const length) + { + assert(0 <= offset && offset < length); + + if (positions.size() > std::numeric_limits::max() - spans_.size()) { + throw std::length_error{"too many spans in the search"}; + } + auto const begin = static_cast(spans_.size()); + for (int const pos : positions) { + if (pos < offset) continue; + int const start = pos - offset; + if (start > std::numeric_limits::max() - length) break; + spans_.emplace_back(start, start + length); + } + return {begin, static_cast(spans_.size())}; + } + + std::size_t word_count_ = 0; + std::vector slots_; + + // `ranges_[candidate * word_count_ + word]` refers to `spans_` + std::vector ranges_; + + // Spans removed by `keep_word_spans()` leave holes + std::vector> spans_; }; } // iris::ngram::detail diff --git a/include/iris/ngram/detail/slot.hpp b/include/iris/ngram/detail/slot.hpp index 287acae..d9d3768 100644 --- a/include/iris/ngram/detail/slot.hpp +++ b/include/iris/ngram/detail/slot.hpp @@ -12,7 +12,7 @@ namespace iris::ngram::detail { // A monotonically increasing internal index used for bookkeeping. // Obsolete documents and their posting data may still refer to this index (harmlessly) -// until `compact()` is requested on the database. +// until the database compacts itself, which renumbers the slots. enum struct document_slot : std::uint32_t { sentinel = static_cast(-1), diff --git a/include/iris/ngram/gram.hpp b/include/iris/ngram/gram.hpp index ac17565..aaf9e17 100644 --- a/include/iris/ngram/gram.hpp +++ b/include/iris/ngram/gram.hpp @@ -57,18 +57,6 @@ struct gram return ng; } - template - constexpr void shift_copy(It it, int const remaining_chars) - noexcept( - noexcept(std::shift_left(data.begin(), data.end(), remaining_chars)) && - noexcept(std::ranges::copy_n(it, remaining_chars, data.begin() + (N - remaining_chars))) - ) - { - assert(remaining_chars < int(N)); - std::shift_left(data.begin(), data.end(), remaining_chars); - std::ranges::copy_n(it, remaining_chars, data.begin() + (N - remaining_chars)); - } - template [[nodiscard]] static constexpr gram from_c_array(CharT const (&chars)[Len]) noexcept { @@ -136,15 +124,6 @@ struct gram<2, CharT> return ng; } - template - constexpr void shift_copy(It it, int const remaining_chars) - noexcept(noexcept(*it)) - { - assert(remaining_chars == 1); - (void)remaining_chars; - data = (data << (sizeof(CharT) * 8)) | value_type(static_cast>(*it)); - } - template [[nodiscard]] static constexpr gram from_c_array(CharT const (&chars)[Len]) noexcept { diff --git a/include/iris/ngram/keyed_database.hpp b/include/iris/ngram/keyed_database.hpp index 95e4db1..3ba5b63 100644 --- a/include/iris/ngram/keyed_database.hpp +++ b/include/iris/ngram/keyed_database.hpp @@ -26,12 +26,35 @@ class keyed_database : private database public: using document_id_type = KeyT; + keyed_database() = default; + + // Rebuilds `doc_id_to_key_`, which points into `key_to_doc_id_` + keyed_database(keyed_database const& other) + : base_type(other) + , key_to_doc_id_(other.key_to_doc_id_) + , doc_id_to_key_(other.doc_id_to_key_.size(), nullptr) + { + for (auto const& [key, doc_id] : key_to_doc_id_) { + doc_id_to_key_[detail::to_index(doc_id)] = std::addressof(key); + } + } + + keyed_database(keyed_database&&) = default; + keyed_database& operator=(keyed_database&&) = default; + + keyed_database& operator=(keyed_database const& other) + { + keyed_database copy(other); + this->swap(copy); + return *this; + } + template requires std::is_constructible_v void add_document(KeyLikeT&& key_like, std::basic_string_view const doc_text) { - // 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 + // Reserve the key once; publish its document id after indexing succeeds. + // TODO: __cpp_lib_associative_heterogeneous_insertion auto const [it, inserted] = key_to_doc_id_.emplace(std::forward(key_like), document_id::tombstone); if (!inserted) { if constexpr (std::is_pointer_v) { @@ -114,9 +137,6 @@ 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_); @@ -135,7 +155,6 @@ class keyed_database : private database template [[nodiscard]] document_id get_document_id(KeyLikeT const& key_like) const { - // TODO: __cpp_lib_associative_heterogeneous_insertion auto const it = key_to_doc_id_.find(key_like); if (it == key_to_doc_id_.end()) throw std::out_of_range{"key not found"}; return it->second; diff --git a/include/iris/ngram/search_result.hpp b/include/iris/ngram/search_result.hpp index a7c982c..9f88afc 100644 --- a/include/iris/ngram/search_result.hpp +++ b/include/iris/ngram/search_result.hpp @@ -8,98 +8,376 @@ #include #include -#include +#include -#include -#include -#include +#include +#include #include -#include +#include +#include +#include #include -namespace iris::ngram { +#include +#include +#include // IWYU pragma: keep -namespace detail { -class search_result_cache; -} // detail +namespace iris::ngram { template class database; -struct [[nodiscard]] search_word_match +template +class search_result; + +namespace detail { + +template +class word_matches { - search_word_match() = default; +public: + class iterator + { + public: + using value_type = std::span const>; + using difference_type = std::ptrdiff_t; - explicit search_word_match(int word_id) - : word_id_(word_id) - {} + iterator() = default; - search_word_match(int word_id, std::initializer_list> spans) - : word_id_(word_id) - , spans_(spans) - {} + [[nodiscard]] value_type operator*() const noexcept + { + auto const& offsets = result_->span_offsets_; + auto const i = static_cast(offset_); + return std::span{result_->spans_}.subspan(offsets[i], offsets[i + 1] - offsets[i]); + } + + [[nodiscard]] value_type operator[](difference_type const n) const noexcept + { + return *(*this + n); + } + + iterator& operator++() noexcept + { + ++offset_; + return *this; + } + + iterator operator++(int) noexcept + { + auto const it = *this; + ++offset_; + return it; + } + + iterator& operator--() noexcept + { + --offset_; + return *this; + } + + iterator operator--(int) noexcept + { + auto const it = *this; + --offset_; + return it; + } + + iterator& operator+=(difference_type const n) noexcept + { + offset_ += n; + return *this; + } + + iterator& operator-=(difference_type const n) noexcept + { + offset_ -= n; + return *this; + } + + [[nodiscard]] friend iterator operator+(iterator it, difference_type const n) noexcept { return it += n; } + [[nodiscard]] friend iterator operator+(difference_type const n, iterator it) noexcept { return it += n; } + [[nodiscard]] friend iterator operator-(iterator it, difference_type const n) noexcept { return it -= n; } + + [[nodiscard]] friend difference_type operator-(iterator const& a, iterator const& b) noexcept + { + return a.offset_ - b.offset_; + } + + [[nodiscard]] friend bool operator==(iterator const& a, iterator const& b) noexcept + { + return a.offset_ == b.offset_; + } + + [[nodiscard]] friend std::strong_ordering operator<=>(iterator const& a, iterator const& b) noexcept + { + return a.offset_ <=> b.offset_; + } + + private: + friend word_matches; + + iterator(search_result const* const result, std::size_t const offset) noexcept + : result_(result) + , offset_(static_cast(offset)) + {} + + search_result const* result_ = nullptr; + difference_type offset_ = 0; + }; + + using const_iterator = iterator; + using reverse_iterator = std::reverse_iterator; + using const_reverse_iterator = reverse_iterator; + + word_matches() = default; + + [[nodiscard]] std::span const> operator[](std::size_t const word) const noexcept + { + assert(word < this->size()); + return this->begin()[static_cast(word)]; + } - [[nodiscard]] int word_id() const noexcept { return word_id_; } - [[nodiscard]] auto const& spans() const noexcept { return spans_; } + [[nodiscard]] std::size_t size() const noexcept + { + return result_ ? result_->word_count_ : 0; + } + + [[nodiscard]] iterator begin() const noexcept + { + return {result_, doc_ * this->size()}; + } + + [[nodiscard]] iterator end() const noexcept + { + return {result_, (doc_ + 1) * this->size()}; + } + + [[nodiscard]] reverse_iterator rbegin() const noexcept + { + return reverse_iterator{this->end()}; + } + + [[nodiscard]] reverse_iterator rend() const noexcept + { + return reverse_iterator{this->begin()}; + } + + [[nodiscard]] const_iterator cbegin() const noexcept + { + return this->begin(); + } + + [[nodiscard]] const_iterator cend() const noexcept + { + return this->end(); + } - [[nodiscard]] bool operator==(search_word_match const& other) const noexcept + [[nodiscard]] const_reverse_iterator crbegin() const noexcept { - return word_id_ == other.word_id_ && spans_ == other.spans_; + return this->rbegin(); + } + + [[nodiscard]] const_reverse_iterator crend() const noexcept + { + return this->rend(); } private: - friend class detail::search_result_cache; + friend search_result; - template - friend class database; + word_matches(search_result const* const result, std::size_t const doc) noexcept + : result_(result) + , doc_(doc) + {} - int word_id_ = 0; - unsigned successful_ngrams_ = 1; // due to the class layout, this must be placed here - std::vector> spans_; + search_result const* result_ = nullptr; + std::size_t doc_ = 0; }; -template, class EqualT = std::equal_to<>> -struct [[nodiscard]] search_result +} // detail + +template +class [[nodiscard]] search_result { +public: using document_id_type = DocumentID; - using hash_type = HashT; - using equal_type = EqualT; - using map_type = std::unordered_map, HashT, EqualT>; + using word_matches = detail::word_matches; + using value_type = std::pair; - search_result() = default; + class iterator + { + public: + using value_type = search_result::value_type; + using difference_type = std::ptrdiff_t; + + iterator() = default; - template - requires requires(map_type& doc_matches) { - doc_matches.insert_range(std::declval()); + [[nodiscard]] value_type operator*() const noexcept + { + return (*result_)[static_cast(doc_)]; } - void assign(DocumentMatchMap&& doc_match_map) - { - doc_matches_.clear(); - if constexpr (std::ranges::sized_range) { - doc_matches_.reserve(std::ranges::size(doc_match_map)); + + [[nodiscard]] value_type operator[](difference_type const n) const noexcept + { + return *(*this + n); + } + + iterator& operator++() noexcept + { + ++doc_; + return *this; + } + + iterator operator++(int) noexcept + { + auto const it = *this; + ++doc_; + return it; + } + + iterator& operator--() noexcept + { + --doc_; + return *this; } - doc_matches_.insert_range(std::forward(doc_match_map)); + + iterator operator--(int) noexcept + { + auto const it = *this; + --doc_; + return it; + } + + iterator& operator+=(difference_type const n) noexcept + { + doc_ += n; + return *this; + } + + iterator& operator-=(difference_type const n) noexcept + { + doc_ -= n; + return *this; + } + + [[nodiscard]] friend iterator operator+(iterator it, difference_type const n) noexcept { return it += n; } + [[nodiscard]] friend iterator operator+(difference_type const n, iterator it) noexcept { return it += n; } + [[nodiscard]] friend iterator operator-(iterator it, difference_type const n) noexcept { return it -= n; } + + [[nodiscard]] friend difference_type operator-(iterator const& a, iterator const& b) noexcept + { + return a.doc_ - b.doc_; + } + + [[nodiscard]] friend bool operator==(iterator const& a, iterator const& b) noexcept + { + return a.doc_ == b.doc_; + } + + [[nodiscard]] friend std::strong_ordering operator<=>(iterator const& a, iterator const& b) noexcept + { + return a.doc_ <=> b.doc_; + } + + private: + friend search_result; + + iterator(search_result const* const result, std::size_t const doc) noexcept + : result_(result) + , doc_(static_cast(doc)) + {} + + search_result const* result_ = nullptr; + difference_type doc_ = 0; + }; + + using const_iterator = iterator; + using reverse_iterator = std::reverse_iterator; + using const_reverse_iterator = reverse_iterator; + + search_result() = default; + search_result(search_result const&) = default; + search_result(search_result&&) = default; + search_result& operator=(search_result&&) = default; + + search_result& operator=(search_result const& other) + { + search_result copy(other); + this->swap(copy); + return *this; + } + + [[nodiscard]] value_type operator[](std::size_t const doc) const noexcept IRIS_LIFETIMEBOUND + { + assert(doc < this->size()); + return {doc_ids_[doc], word_matches{this, doc}}; + } + + [[nodiscard]] iterator begin() const noexcept IRIS_LIFETIMEBOUND + { + return {this, 0}; + } + + [[nodiscard]] iterator end() const noexcept IRIS_LIFETIMEBOUND + { + return {this, this->size()}; + } + + [[nodiscard]] reverse_iterator rbegin() const noexcept IRIS_LIFETIMEBOUND + { + return reverse_iterator{this->end()}; + } + + [[nodiscard]] reverse_iterator rend() const noexcept IRIS_LIFETIMEBOUND + { + return reverse_iterator{this->begin()}; + } + + [[nodiscard]] const_iterator cbegin() const noexcept IRIS_LIFETIMEBOUND + { + return this->begin(); + } + + [[nodiscard]] const_iterator cend() const noexcept IRIS_LIFETIMEBOUND + { + return this->end(); + } + + [[nodiscard]] const_reverse_iterator crbegin() const noexcept IRIS_LIFETIMEBOUND + { + return this->rbegin(); } - [[nodiscard]] map_type const& doc_matches() const noexcept + [[nodiscard]] const_reverse_iterator crend() const noexcept IRIS_LIFETIMEBOUND { - return doc_matches_; + return this->rend(); + } + + [[nodiscard]] std::size_t size() const noexcept + { + return doc_ids_.size(); } void clear() noexcept { - doc_matches_.clear(); + word_count_ = 0; + doc_ids_.clear(); + span_offsets_.clear(); + spans_.clear(); } - void swap(search_result& other) noexcept(std::is_nothrow_swappable_v) + void swap(search_result& other) noexcept { using std::swap; - swap(doc_matches_, other.doc_matches_); + swap(word_count_, other.word_count_); + swap(doc_ids_, other.doc_ids_); + swap(span_offsets_, other.span_offsets_); + swap(spans_, other.spans_); } [[nodiscard]] bool empty() const noexcept { - return doc_matches_.empty(); + return doc_ids_.empty(); } [[nodiscard]] explicit operator bool() const noexcept @@ -108,27 +386,72 @@ struct [[nodiscard]] search_result } private: - map_type doc_matches_; + template + friend class database; + + friend word_matches; + + void reset(std::size_t const word_count) noexcept + { + this->clear(); + word_count_ = word_count; + } + + void reserve_documents(std::size_t const doc_count) + { + container::reserve_geometric(doc_ids_, doc_count); + container::reserve_geometric(span_offsets_, doc_count * word_count_ + 1); + } + + // Must be followed by `add_word_spans()` or `add_word_positions()` for every word + template + void add_document(DocumentIDLike&& doc_id) + { + if (span_offsets_.empty()) { + span_offsets_.emplace_back(0); + } + doc_ids_.emplace_back(std::forward(doc_id)); + } + + void add_word_spans(std::span const> const spans) + { + if (spans.size() > std::numeric_limits::max() - spans_.size()) { + throw std::length_error{"too many spans in the search result"}; + } + container::reserve_geometric(spans_, spans_.size() + spans.size()); + spans_.insert(spans_.end(), spans.begin(), spans.end()); + span_offsets_.emplace_back(static_cast(spans_.size())); + } + + void add_word_positions(std::span const positions, int const length) + { + if (positions.size() > std::numeric_limits::max() - spans_.size()) { + throw std::length_error{"too many spans in the search result"}; + } + int end = std::numeric_limits::min(); + for (int const pos : positions) { + if (pos < end) continue; + end = pos + length; + spans_.emplace_back(pos, end); + } + span_offsets_.emplace_back(static_cast(spans_.size())); + } + + std::size_t word_count_ = 0; + std::vector doc_ids_; + + // `[doc * word_count_ + word]` begins the spans of the word in the document; one extra element at the end + std::vector span_offsets_; + + std::vector> spans_; }; -template -void swap(search_result& a, search_result& b) - noexcept(noexcept(a.swap(b))) +template +void swap(search_result& a, search_result& b) noexcept { a.swap(b); } } // iris::ngram -template -struct std::formatter - : iris::no_spec_formatter -{ - template - Ctx::iterator format(iris::ngram::search_word_match const& word_match, Ctx& ctx) const - { - return std::format_to(ctx.out(), "{{word: #{}, spans: {}}}", word_match.word_id(), word_match.spans()); - } -}; - #endif diff --git a/test/ngram/ngram.cpp b/test/ngram/ngram.cpp index 3d20788..84ff9c4 100644 --- a/test/ngram/ngram.cpp +++ b/test/ngram/ngram.cpp @@ -8,6 +8,10 @@ #include #include +#include +#include +#include +#include TEST_CASE("ngram: type traits") { @@ -24,6 +28,11 @@ TEST_CASE("ngram: type traits") STATIC_CHECK(std::is_trivially_copyable_v>); STATIC_CHECK(std::is_trivially_copyable_v>); STATIC_CHECK(std::is_trivially_copyable_v>); + + STATIC_CHECK(std::ranges::random_access_range>); + STATIC_CHECK(std::ranges::sized_range>); + STATIC_CHECK(std::ranges::random_access_range::word_matches>); + STATIC_CHECK(std::ranges::sized_range::word_matches>); } TEST_CASE("ngram: update document") @@ -201,3 +210,376 @@ TEST_CASE("ngram: keyed_database") IRIS_CHECK_SEARCH("bc"); } } + +TEST_CASE("ngram: overlapping occurrences") +{ +#ifdef _MSC_VER + SetConsoleOutputCP(CP_UTF8); +#endif + + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"そんな機会はなかなかない"); + IRIS_CHECK_SEARCH( + "なかな", + {0_doc_id, { + {0, {interval{6, 9}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"すももも買った"); + IRIS_CHECK_SEARCH( + "もも", + {0_doc_id, { + {0, {interval{1, 3}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"こここそ探していた場所だ"); + IRIS_CHECK_SEARCH( + "ここ", + {0_doc_id, { + {0, {interval{0, 2}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"ボボボーボ・ボーボボ"); + IRIS_CHECK_SEARCH( + "ボボ", + {0_doc_id, { + {0, {interval{0, 2}, interval{8, 10}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"banana"); + IRIS_CHECK_SEARCH( + "ana", + {0_doc_id, { + {0, {interval{1, 4}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"Mississippi"); + IRIS_CHECK_SEARCH( + "issi", + {0_doc_id, { + {0, {interval{1, 5}}}, + }}, + ); + } + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"assesses"); + IRIS_CHECK_SEARCH( + "sses", + {0_doc_id, { + {0, {interval{1, 5}}}, + }}, + ); + } + + // The occurrence after a skipped one may start right where the previous match ends + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"ははははと笑った"); + IRIS_CHECK_SEARCH( + "はは", + {0_doc_id, { + {0, {interval{0, 2}, interval{2, 4}}}, + }}, + ); + } + + // Matches of different words may overlap each other + { + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"すももも買った"); + IRIS_CHECK_SEARCH( + "すもも もも", + {0_doc_id, { + {0, {interval{0, 3}}}, + {1, {interval{1, 3}}}, + }}, + ); + } +} + +TEST_CASE("ngram: search word order") +{ + // "xyz" is examined first, but the result keeps the query order + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"abcd xyz abcd"); + (void)ngram_db.add_document(U"abcd"); + IRIS_CHECK_SEARCH( + "abcd xyz", + {0_doc_id, { + {0, {interval{0, 4}, interval{9, 13}}}, + {1, {interval{5, 8}}}, + }}, + ); + +} + +TEST_CASE("ngram: search n-gram order") +{ + // "cd" is examined first, and its first occurrence is too early for the word + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"cd abcd"); + (void)ngram_db.add_document(U"ab"); + IRIS_CHECK_SEARCH( + "abcd", + {0_doc_id, { + {0, {interval{3, 7}}}, + }}, + ); +} + +TEST_CASE("ngram: search words of temporary strings") +{ + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"今日は良い天気です。明日も良い天気でしょう。"); + + std::vector const words{U"良い天気", U"明日も"}; + auto const temporaries = words | std::views::transform([](std::u32string const& word) { return word; }); + + iris::ngram::search_result<> search_res; + CHECK(ngram_db.search(temporaries, search_res)); + auto const doc_matches = search_res | std::views::transform([](auto const& kv) { + return DocumentMatch<>{kv.first, kv.second}; + }) | std::ranges::to(); + CHECK(doc_matches == std::vector>{ + {0_doc_id, { + {0, {interval{3, 7}, interval{13, 17}}}, + {1, {interval{10, 13}}}, + }}, + }); +} + +TEST_CASE("ngram: keyed_database copy") +{ + iris::ngram::keyed_database source; + source.add_document("doc0", U"abc"); + source.add_document("doc1", U"bcd"); + source.remove_document("doc0"); + source.add_document("doc2", U"abcd"); + + auto ngram_db = source; + source.clear(); + IRIS_CHECK_SEARCH( + "bc", + {"doc1", { + {0, {interval{0, 2}}}, + }}, + {"doc2", { + {0, {interval{1, 3}}}, + }}, + ); + + source.add_document("doc3", U"bc"); + ngram_db = source; + source.clear(); + IRIS_CHECK_SEARCH( + "bc", + {"doc3", { + {0, {interval{0, 2}}}, + }}, + ); +} + +TEST_CASE("ngram: compaction") +{ + // Enough updates to compact several times + iris::ngram::database<> ngram_db; + auto const updated = ngram_db.add_document(U"initial"); + auto const removed = ngram_db.add_document(U"removed text"); + auto const hidden = ngram_db.add_document(U"hidden text"); + auto const kept = ngram_db.add_document(U"kept text"); + ngram_db.remove_document(removed); + ngram_db.set_visible(hidden, false); + + for (int i = 0; i < 10000; ++i) { + ngram_db.update_document(updated, i % 2 == 0 ? U"even version" : U"odd version"); + } + auto const added = ngram_db.add_document(U"added text"); + + CHECK(ngram_db.has_document(updated)); + CHECK_FALSE(ngram_db.has_document(removed)); + CHECK(ngram_db.has_document(hidden)); + CHECK_FALSE(ngram_db.is_visible(hidden)); + CHECK(ngram_db.has_document(kept)); + CHECK(added == 4_doc_id); + + IRIS_CHECK_SEARCH( + "version", + {updated, { + {0, {interval{4, 11}}}, + }}, + ); + IRIS_CHECK_SEARCH("even"); + IRIS_CHECK_SEARCH("initial"); + IRIS_CHECK_SEARCH( + "text", + {kept, { + {0, {interval{5, 9}}}, + }}, + {added, { + {0, {interval{6, 10}}}, + }}, + ); + + ngram_db.set_visible(hidden, true); + IRIS_CHECK_SEARCH( + "text", + {hidden, { + {0, {interval{7, 11}}}, + }}, + {kept, { + {0, {interval{5, 9}}}, + }}, + {added, { + {0, {interval{6, 10}}}, + }}, + ); +} + +namespace { + +struct throwing_key +{ + int value = 0; + + static inline int copies_until_throw = -1; // never throws if negative + + throwing_key(int value) : value(value) {} + + throwing_key(throwing_key const& other) : value(other.value) + { + count_copy(); + } + + throwing_key& operator=(throwing_key const& other) + { + count_copy(); + value = other.value; + return *this; + } + + static void count_copy() + { + if (copies_until_throw < 0) return; + if (copies_until_throw-- == 0) throw std::runtime_error{"key copy"}; + } + + [[nodiscard]] bool operator==(throwing_key const&) const noexcept = default; +}; + +struct throwing_key_hash +{ + [[nodiscard]] std::size_t operator()(throwing_key const& key) const noexcept + { + return std::hash{}(key.value); + } +}; + +} // anonymous + +TEST_CASE("ngram: search_result copy") +{ + iris::ngram::keyed_database ngram_db; + for (int i = 0; i < 100; ++i) { + ngram_db.add_document(i, std::u32string(static_cast(i % 7), U'.') + U"abc xyz"); + } + + iris::ngram::search_result source; + ngram_db.search(iris::ngram::search_query{U"abc xyz"}, source); + iris::ngram::search_result target; + ngram_db.search(iris::ngram::search_query{U"b"}, target); + + auto const matches_of = [](iris::ngram::search_result const& search_res) { + std::vector>>>> matches; + for (auto const& [key, word_matches] : search_res) { + auto& spans = matches.emplace_back(key.value, std::vector>>{}).second; + for (auto const& word : word_matches) { + spans.emplace_back(word.begin(), word.end()); + } + } + return matches; + }; + auto const target_matches = matches_of(target); + REQUIRE(target_matches.size() == 100); + + throwing_key::copies_until_throw = 3; + CHECK_THROWS_AS(target = source, std::runtime_error); + throwing_key::copies_until_throw = -1; + CHECK(matches_of(target) == target_matches); + + target = source; + CHECK(matches_of(target) == matches_of(source)); + + // A search failing in the middle leaves no partial result, with a single gram or multiple words + throwing_key::copies_until_throw = 3; + CHECK_THROWS_AS(ngram_db.search(iris::ngram::search_query{U"b"}, target), std::runtime_error); + CHECK(matches_of(target).empty()); + throwing_key::copies_until_throw = 3; + CHECK_THROWS_AS(ngram_db.search(iris::ngram::search_query{U"abc xyz"}, target), std::runtime_error); + CHECK(matches_of(target).empty()); + throwing_key::copies_until_throw = -1; + + ngram_db.search(iris::ngram::search_query{U"abc xyz"}, target); + CHECK(matches_of(target) == matches_of(source)); +} + +TEST_CASE("ngram: search missing n-gram") +{ + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"abcdef"); + IRIS_CHECK_SEARCH("axcdef"); + IRIS_CHECK_SEARCH("abxdef"); + IRIS_CHECK_SEARCH("abcdxf"); + IRIS_CHECK_SEARCH("abcdefx"); + IRIS_CHECK_SEARCH("x abc"); + IRIS_CHECK_SEARCH("abc x"); +} + +TEST_CASE("ngram: search_result access") +{ + iris::ngram::database<> ngram_db; + (void)ngram_db.add_document(U"abc xyz"); + (void)ngram_db.add_document(U"none"); + (void)ngram_db.add_document(U"xyz abc abc"); + + iris::ngram::search_result<> search_res; + REQUIRE(ngram_db.search(iris::ngram::search_query{U"abc xyz"}, search_res)); + REQUIRE(search_res.size() == 2); + CHECK(search_res.end() - search_res.begin() == 2); + + auto const [doc_id, word_matches] = search_res[1]; + CHECK(doc_id == 2_doc_id); + REQUIRE(word_matches.size() == 2); + CHECK(std::ranges::equal(word_matches[0], std::vector{interval{4, 7}, interval{8, 11}})); + CHECK(std::ranges::equal(word_matches[1], std::vector{interval{0, 3}})); + + CHECK(std::get<0>(search_res.begin()[0]) == 0_doc_id); + CHECK(std::ranges::equal(std::get<1>(*(search_res.end() - 1)).end()[-1], std::vector{interval{0, 3}})); + + CHECK(search_res.rend() - search_res.rbegin() == 2); + CHECK(std::get<0>(*search_res.rbegin()) == 2_doc_id); + CHECK(std::ranges::equal(*word_matches.rbegin(), std::vector{interval{0, 3}})); + CHECK(std::ranges::equal(word_matches.rend()[-1], std::vector{interval{4, 7}, interval{8, 11}})); + + CHECK(search_res.cbegin() == search_res.begin()); + CHECK(search_res.cend() == search_res.end()); + CHECK(search_res.crend() - search_res.crbegin() == 2); + CHECK(word_matches.cend() - word_matches.cbegin() == 2); + CHECK(word_matches.crbegin() == word_matches.rbegin()); +} diff --git a/test/ngram/ngram_test.hpp b/test/ngram/ngram_test.hpp index 18fe66c..2afe2eb 100644 --- a/test/ngram/ngram_test.hpp +++ b/test/ngram/ngram_test.hpp @@ -21,23 +21,47 @@ using namespace iris::ngram_literals; using iris::interval; +// The spans of a search word, identified by its index among the non-empty words +struct WordMatch +{ + int word_id; + std::vector> spans; + + [[nodiscard]] bool operator==(WordMatch const&) const noexcept = default; +}; + +template +struct std::formatter + : iris::no_spec_formatter +{ + template + Ctx::iterator format(WordMatch const& word_match, Ctx& ctx) const + { + return std::format_to(ctx.out(), "{{word: #{}, spans: {}}}", word_match.word_id, word_match.spans); + } +}; + template struct DocumentMatch { DocumentID doc_id; - std::vector word_matches; + std::vector word_matches; template - DocumentMatch(ID&& doc_id, std::initializer_list word_matches) + DocumentMatch(ID&& doc_id, std::initializer_list word_matches) : doc_id(std::forward(doc_id)) , word_matches(word_matches) {} - template - DocumentMatch(ID&& doc_id, std::vector word_matches) + template + DocumentMatch(ID&& doc_id, WordSpans&& word_spans) : doc_id(std::forward(doc_id)) - , word_matches(std::move(word_matches)) - {} + { + int word_id = 0; + for (auto const& spans : word_spans) { + word_matches.emplace_back(word_id++, spans | std::ranges::to()); + } + } [[nodiscard]] bool operator==(DocumentMatch const&) const noexcept = default; @@ -58,13 +82,11 @@ struct std::formatter, CharT> iris::ngram::search_query const query{U ## query_input}; \ iris::ngram::search_result::document_id_type> search_res; \ ngram_db.search(query, search_res); \ - auto const& doc_matches = search_res.doc_matches(); \ - \ std::vector::document_id_type>> const expected_doc_matches{ \ std::initializer_list::document_id_type>>{__VA_ARGS__} \ }; \ \ - auto const actual_doc_matches = doc_matches | std::views::transform([](auto const& kv) { \ + auto const actual_doc_matches = search_res | std::views::transform([](auto const& kv) { \ return DocumentMatch::document_id_type>{kv.first, kv.second}; \ }) | std::ranges::to(); \ CHECK(actual_doc_matches == expected_doc_matches); \ diff --git a/test/ngram/search_3.cpp b/test/ngram/search_3.cpp index c99e050..3adf98f 100644 --- a/test/ngram/search_3.cpp +++ b/test/ngram/search_3.cpp @@ -25,7 +25,7 @@ TEST_CASE("gram search (document chars = 3, aaa/baa)") IRIS_CHECK_SEARCH( "aa", {0_doc_id, { - {0, {interval{0, 2}, interval{1, 3}}}, + {0, {interval{0, 2}}}, }}, ); IRIS_CHECK_SEARCH("XX"); diff --git a/test/ngram/search_4_dep.cpp b/test/ngram/search_4_dep.cpp index 714bb22..338296e 100644 --- a/test/ngram/search_4_dep.cpp +++ b/test/ngram/search_4_dep.cpp @@ -25,7 +25,7 @@ TEST_CASE("gram search (document chars = 4, aaaa)") IRIS_CHECK_SEARCH( "aa", {0_doc_id, { - {0, {interval{0, 2}, interval{1, 3}, interval{2, 4}}}, + {0, {interval{0, 2}, interval{2, 4}}}, }}, ); IRIS_CHECK_SEARCH("XX"); @@ -33,7 +33,7 @@ TEST_CASE("gram search (document chars = 4, aaaa)") IRIS_CHECK_SEARCH( "aaa", {0_doc_id, { - {0, {interval{0, 3}, interval{1, 4}}}, + {0, {interval{0, 3}}}, }}, ); IRIS_CHECK_SEARCH("XXX");