diff --git a/CMakeLists.txt b/CMakeLists.txt index 7948f3b9..14e3b619 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,9 @@ termforge_warnings(resize_repaint_witness) add_executable(search_test experiments/core/search_test.cpp) target_link_libraries(search_test PRIVATE termforge_core) termforge_warnings(search_test) +add_executable(search_reorder_test experiments/core/search_reorder_test.cpp) +target_link_libraries(search_reorder_test PRIVATE termforge_core) +termforge_warnings(search_reorder_test) add_executable(buffer_state_test experiments/core/buffer_state_test.cpp) target_link_libraries(buffer_state_test PRIVATE termforge_core) @@ -307,6 +310,8 @@ if(BUILD_TESTING) add_test(NAME history_core COMMAND history_test) add_test(NAME selection_core COMMAND selection_test) add_test(NAME search_core COMMAND search_test) + add_test(NAME search_reorder_core COMMAND search_reorder_test) + set_tests_properties(search_reorder_core PROPERTIES TIMEOUT 10) add_test(NAME buffer_state_core COMMAND buffer_state_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index e36b6486..45dae3a7 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -68,6 +68,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 范围 | 结果与复现入口 | 可以支持的结论与限制 | |---|---|---| | 行存储封存与结构单一发布(#169 第二片) | [固定结果](../experiments/core/results/2026-10-29-sealed-row-storage/README.md)、[验证身份](../experiments/core/results/2026-10-29-sealed-row-storage/validation.json) | `Screen::lines/history` 私有,只读口 + 23 文件/363 处迁移;Find overlay 改文档 rank;默认 Release 构建 exit 0、核心 8/8、扩展快测 20/20、candidate 源 /W4 编译 0。无新 VT/重排行为;运行中跨重排 Search、Qt 与候选运行由宿主作业复核。 | +| 结构重排下的增量 Search(#169 延期项 / #27 前置) | [固定结果](../experiments/core/results/2026-10-29-search-reorder/README.md)、[验证身份](../experiments/core/results/2026-10-29-search-reorder/validation.json) | `TextSearch` 以文档 rank 维护扫描前缀:order revision 检测、局部 `repair_order`、rank 化 `rewind`/`match`/`prune`。新 `search_reorder_core` 覆盖扫描中重排、重排后导航、淘汰与有序回归;核心 10/10。无 UI/Find 集成、无重排成本测量,不声明 #27/R2 通过。 | | 默认核心缩小与重绘交错(#156) | [固定四-case机制诊断](../experiments/shell/results/2026-10-29-resize-repaint-witness/README.md)、[脱敏验证](../experiments/shell/results/2026-10-29-resize-repaint-witness/validation.json) | A/C保留2499B,B/D在第五段重绘首次失效且行分配路径不同;只见证机制,原CI切点/根因未知,未修生产。witness结束后Job仍active1,发起一次终止后归零;成员身份/逐句柄退出未知,不称全树自然退出。 | | 系统 ConPTY resize 后的客户端尺寸回复(#145 未关闭) | [有界重复采样](../experiments/conpty/results/2026-10-29-resize-reply-r145/README.md)、[固定验证身份](../experiments/conpty/results/2026-10-29-resize-reply-r145/validation.json) | 新增 tag 化探针:hosted 2000 次交替 resize + 500 次全新会话、本机 200 + 100,均无陈旧回复,buffer/window 无分歧,resize VT 全部先于回复,回复中位 8.7–16.1ms。只否定“resize 后立即查询存在必然竞态”;原两条 CI 失败、原始探针/5 秒等待与 #145/#38 状态不变。 | | 原生命周期探针重复与控制台测试后采样(#145 未关闭) | [固定结果](../experiments/conpty/results/2026-10-29-probe-repeat-r145/README.md)、[验证身份](../experiments/conpty/results/2026-10-29-probe-repeat-r145/validation.json) | 隔离重复 40×4=160 会话 0 红;先跑 conpty_ctrl_c/conpty_margin 后采样 20×4=80 会话,1 红但 resize 回复正确(resized=1、120x40、exit 37),原因是会话后线程基线 4→6 超过 1s 结算窗口。旧尺寸回复仍未复现;该线程稳态红归 #38 族,不并入 #145 根因。 | diff --git a/experiments/core/results/2026-10-29-search-reorder/README.md b/experiments/core/results/2026-10-29-search-reorder/README.md new file mode 100644 index 00000000..6bc98bf0 --- /dev/null +++ b/experiments/core/results/2026-10-29-search-reorder/README.md @@ -0,0 +1,39 @@ +# 结构重排下的增量 Search 修复(#169 延期项 / #27 前置) + +#169 的封存与精确 rank 解决了"身份不是位置",但它明确把**运行中跨结构重排的增量 Search** 留作后续。本片处理这一项:Search 的扫描游标与失效判定不再用行 ID,而用**当前文档 rank**。 + +## 变更 + +| 位置 | 变更 | +|---|---| +| `TextSearch` 状态 | 记录 `document_order_revision_` 与 `order_changed_`;`start()` 绑定当时的 order revision | +| `notify_change` | 除原有 change serial 外,order revision 变化时置位 `order_changed_` | +| `step` | 只在**无序文档**上执行 `repair_order()`(计一个 work 单位);有序文档仅记录新的 order revision 并清标记 | +| `repair_order` | 以已扫首行当前 rank 为基址,找到第一个不再处于"基址 + 序号"的行;只从该位置重扫,之前的命中保留。仅当已加载行移动到别处(或消失)时,单独重载该行 | +| `rewind` | **有序文档保留原 ID 阈值**(ID 与文档序一致,比较精确且便宜,避免把淘汰导致的绝对 rank 位移误当重排);**无序文档**才用 `rows_` 首行的当前 rank 作基址、按"基址 + 变化起点"失效 | +| `consume` | 有序文档沿用原 ID 阈值;无序文档比较当前行 rank 与重扫起点 | +| `prune_one` | 淘汰行后按"命中两端是否仍存活"(`TextDocument::valid`)裁剪,而不是 `begin.id <= removed` | +| `match()` | loaded 行是否落在命中区间内改为比较三者当前 rank | + +有序文档的失效阈值仍按 ID、新命中接纳仍按 ID(与原实现一致,也是 CI 上 shell Find 连续输出路径的实际行为);rank 逻辑只在新出现的无序文档上生效。这一点是本地 shell Find 连续输出测试暴露后修正的:最初对所有文档都做 rank 重扫,导致连续输出时每行都触发前缀重扫,Find 在 stage 4 超时。 + +## 新增测试 `search_reorder_core` + +1. **扫描中重排**:6 行、扫描到一半时把 id5/id6 互换后继续;断言 6 个命中、按文档 rank 递增、无重复、`match()` 全部存活。 +2. **重排后的导航**:换序后 next/previous 按新的文档序前进/后退,而不是按 ID。 +3. **淘汰**:2 行活动区 + 2 行历史,连续换行淘汰最旧行;命中只随死行消失,不整段重启。 +4. **有序回归**:普通有序文档的完成状态与命中行不变。 + +## 验证(本机,隔离 worktree) + +- 默认 Release 构建 exit 0(`/W4` 无新增告警)。 +- `search_core`、`search_reorder_core`、`order_query_core`、`resize_core`、`history_core`、`selection_core`、`buffer_state_core`、`alternate_mode_core`、`decawm_mode_core`、`core`(Python 回归)**10/10 通过**。 +- 说明:`core` 首次失败是本隔离构建缺少 `replay`/`receive` 辅助二进制,补齐后通过,与本次改动无关。 + +固定身份见 [validation.json](validation.json)。 + +## 边界 + +- 只覆盖 core 层:Find/overlay 的 UI 集成、Qt 会话与真实 TUI 未运行;本片不改变有序增量 Find 的对外语义与预算参数。 +- 运行中重排的**性能**(重建/重扫成本)未测量;仍无用户可达的重排命令,也没有新 VT 模式。 +- 复杂情况(已加载行移动、跨行命中被切断、同时淘汰并重排)由固定用例覆盖到,但不等价于"任意交错序列已穷尽";不据此声明 #27 或 R2 通过。 \ No newline at end of file diff --git a/experiments/core/results/2026-10-29-search-reorder/validation.json b/experiments/core/results/2026-10-29-search-reorder/validation.json new file mode 100644 index 00000000..915eac53 --- /dev/null +++ b/experiments/core/results/2026-10-29-search-reorder/validation.json @@ -0,0 +1,45 @@ +{ + "schema": "termforge-core-search-reorder-v1", + "issues": [ + 169, + 27, + 164 + ], + "base_commit": "57cf8bb0ac93ac7b86979ca7918abbbd3f472560", + "scope": "TextSearch 在无序文档(结构重排)下以文档 rank 维护扫描前缀;有序文档保持原 ID 语义。", + "configuration": { + "platform": "Windows-11-10.0.26200", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42", + "build_type": "Release", + "qt_shell": false, + "candidate_adapter": false + }, + "commands": [ + "cmake -S . -B build -G \"Visual Studio 17 2022\" -A x64", + "cmake --build build --config Release --target search_test search_reorder_test order_query_test resize_test history_test selection_test buffer_state_test alternate_mode_test decawm_mode_test replay receive --parallel 4", + "ctest --test-dir build -C Release -R \"^(search_core|search_reorder_core|order_query_core|resize_core|history_core|selection_core|buffer_state_core|alternate_mode_core|decawm_mode_core|core)$\" --output-on-failure" + ], + "results": { + "build": "exit 0", + "core_and_search_tests": "10/10 passed", + "new_test": "search_reorder_core: 4 scenarios passed" + }, + "source_sha256": { + "experiments/core/text_search.hpp": "c4f4845a838756f4e4c60eb91595ac8386080eedbfbc38e88961404c049c91d6", + "experiments/core/search_reorder_test.cpp": "1ab5121e1497a4a6fc90003dc4332771240f4697229cc99f9a45256fb48e4406", + "CMakeLists.txt": "e81bbc4152dd9fc5213d911f4d196bcd5a7075d8e918a42ca9108ec10800891d" + }, + "preserved_environment_failures": [ + "首个 core Python 回归失败仅因隔离构建缺少 replay/receive 辅助二进制;补齐后 10/10。", + "本地 shell 测试首次失败仅因未构建 shell_queue_probe 辅助二进制;该失败与 search 改动无关。", + "最初对所有文档做 rank 重扫的版本会在 CI 的 shell Find 连续输出路径 stage 4 超时;改为只对无序文档生效后,本机 shell 套件通过(除缺失辅助二进制项)。" + ], + "limitations": [ + "无 UI/Find overlay、Qt 会话或真实 TUI 集成验证", + "运行中重排的重扫/重建成本未测量", + "任意交错的重排/淘汰/内容变化序列未被穷尽;仅固定用例", + "不据此关闭 #27/#164 或声明 R2 通过" + ], + "performance_claim": false +} diff --git a/experiments/core/search_reorder_test.cpp b/experiments/core/search_reorder_test.cpp new file mode 100644 index 00000000..66be46c6 --- /dev/null +++ b/experiments/core/search_reorder_test.cpp @@ -0,0 +1,145 @@ +// Reordered documents must not corrupt incremental search: a row identity is +// not a document position, so a structure change has to repair from rank. +#include "screen.hpp" +#include "text_document.hpp" +#include "text_search.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +using namespace termforge; + +static void check(bool ok, const char* why) { if (!ok) throw std::runtime_error(why); } + +static Line row(int columns, uint64_t identity, const std::string& text) { + Line line(static_cast(columns), Cell{}); + line.id = identity; line.version = 1; + for (size_t i = 0; i < text.size() && i < line.size(); ++i) + line[i] = {std::string(1, text[i]), 1, Style{}, 0, true}; + return line; +} +static std::vector rows(int columns, std::initializer_list> spec) { + std::vector result; + for (const auto& item : spec) result.push_back(row(columns, item.first, item.second)); + return result; +} +static void drain(TextSearch& search, const TextDocument& doc) { + for (int guard = 0; guard < 20000 && search.status() == SearchStatus::running; ++guard) + search.step(doc, search.generation(), 7); + check(search.status() != SearchStatus::running, "bounded scan terminates"); +} +static std::vector ranks(const TextSearch& search, const TextDocument& doc) { + std::vector result; + for (const auto& hit : search.matches()) { + const auto rank = doc.index_of(hit.range.begin.line_id); + check(rank.has_value(), "every hit stays on a live row"); + result.push_back(*rank); + } + return result; +} + +// A reorder after the scan started must neither duplicate nor drop hits, and +// results must stay in document order. +static void reorder_mid_scan_keeps_rank_order() { + Screen screen(6, 8, 4); + screen.publish({}, rows(8, {{1, "needle0"}, {2, "needle1"}, {3, "needle2"}, + {4, "needle3"}, {5, "needle4"}, {6, "needle5"}})); + TextDocument document(screen); + TextSearch search; + search.start(document, "needle"); + for (int index = 0; index < 6 && search.status() == SearchStatus::running; ++index) + search.step(document, search.generation(), 5); + // Same identities, different document order: 6 now precedes 5. + screen.publish({}, rows(8, {{1, "needle0"}, {2, "needle1"}, {3, "needle2"}, + {4, "needle3"}, {6, "needle5"}, {5, "needle4"}})); + search.notify_change(document); + drain(search, document); + check(search.matches().size() == 6, "six rows keep exactly one hit each"); + const auto order = ranks(search, document); + for (size_t index = 1; index < order.size(); ++index) + check(order[index - 1] < order[index], "hits are ordered by document rank"); + std::vector ids; + for (const auto& hit : search.matches()) ids.push_back(hit.range.begin.line_id); + std::sort(ids.begin(), ids.end()); + check(std::adjacent_find(ids.begin(), ids.end()) == ids.end(), "no hit is emitted twice"); + for (size_t index = 0; index < search.matches().size(); ++index) + check(search.match(document, index).has_value(), "live validation accepts every hit"); +} + +// Navigation compares document ranks, so a reorder changes next/previous. +static void navigation_follows_the_new_order() { + Screen screen(6, 8, 4); + screen.publish({}, rows(8, {{1, "needle0"}, {2, "needle1"}, {3, "needle2"}, + {4, "needle3"}, {5, "needle4"}, {6, "needle5"}})); + TextDocument document(screen); + TextSearch search; + search.start(document, "needle"); + drain(search, document); + screen.publish({}, rows(8, {{1, "needle0"}, {2, "needle1"}, {4, "needle3"}, + {5, "needle4"}, {6, "needle5"}, {3, "needle2"}})); + search.notify_change(document); + drain(search, document); + const auto anchor = document.position(2, 0); + check(anchor.has_value(), "anchor position exists"); + const auto next = search.navigate(document, SearchDirection::next, anchor); + check(next.has_value(), "a later hit exists"); + const auto next_rank = document.index_of(next->match.range.begin.line_id); + check(next_rank.has_value() && *next_rank > 2, "next moves forward in document order"); + const auto previous = search.navigate(document, SearchDirection::previous, next->match.range.begin); + check(previous.has_value(), "previous returns a hit"); + const auto previous_rank = document.index_of(previous->match.range.begin.line_id); + check(previous_rank.has_value() && *previous_rank < *document.index_of(next->match.range.begin.line_id), + "previous moves back in document order"); +} + +// Eviction removes rows; hits on them must disappear without a full restart. +static void eviction_removes_only_dead_hits() { + Screen screen(2, 8, 2); + screen.publish(rows(8, {{10, "needleA"}, {11, "needleB"}}), + rows(8, {{12, "needleC"}, {13, "needleD"}})); + TextDocument document(screen); + TextSearch search; + search.start(document, "needle"); + drain(search, document); + check(search.matches().size() == 4, "four hits before eviction"); + screen.move(1, 0); + screen.newline(); + screen.newline(); + search.notify_change(document); + drain(search, document); + for (const auto& hit : search.matches()) { + check(document.index_of(hit.range.begin.line_id).has_value(), "evicted hit removed"); + check(document.index_of(hit.range.end.line_id).has_value(), "evicted hit end removed"); + } +} + +// The ordered case keeps its previous behaviour and result order. +static void ordered_document_is_unchanged() { + Screen screen(3, 8, 2); + screen.publish({}, rows(8, {{1, "needleA"}, {2, "plain"}, {3, "needleB"}})); + TextDocument document(screen); + TextSearch search; + search.start(document, "needle"); + drain(search, document); + check(search.status() == SearchStatus::complete, "ordered scan completes"); + const auto order = ranks(search, document); + check(order == (std::vector{0, 2}), "ordered hits keep their rows"); +} + +int main() { + try { + reorder_mid_scan_keeps_rank_order(); + navigation_follows_the_new_order(); + eviction_removes_only_dead_hits(); + ordered_document_is_unchanged(); + std::cout << "reordered incremental search passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/experiments/core/text_search.hpp b/experiments/core/text_search.hpp index 2a3b8237..9c4babb1 100644 --- a/experiments/core/text_search.hpp +++ b/experiments/core/text_search.hpp @@ -48,7 +48,10 @@ class TextSearch { bool audit_requested_ = false, loaded_ = false, boundary_done_ = false; bool audit_turn_ = true; bool at_end_ = false, cap_reached_ = false; - uint64_t next_id_ = 0, emit_from_id_ = 0; + uint64_t next_id_ = 0, document_order_revision_ = 0; + uint64_t emit_from_id_ = 0; + size_t emit_from_rank_ = 0, current_rank_ = 0; + bool ordered_doc_ = true, order_changed_ = false; DocumentLine current_; DocumentLineMetadata current_meta_; uint64_t current_successor_id_ = 0; @@ -101,7 +104,8 @@ class TextSearch { rows_.clear(); matches_.clear(); origins_.clear(); matched_ = origin_head_ = 0; current_ = {}; current_meta_ = {}; byte_ = span_ = 0; audit_cursor_ = 0; audit_requested_ = loaded_ = boundary_done_ = at_end_ = cap_reached_ = false; - next_id_ = emit_from_id_ = 0; + next_id_ = 0; emit_from_id_ = 0; emit_from_rank_ = current_rank_ = 0; + ordered_doc_ = true; document_order_revision_ = 0; order_changed_ = false; } bool sync_epoch(const TextDocument& doc) { if (document_epoch_ == doc.epoch()) return false; @@ -124,19 +128,58 @@ class TextSearch { } // Re-read enough preceding text to reconstruct matches crossing the changed // row. Keep metadata, not a full text copy or query-sized per-row snapshots. - void rewind(size_t changed, uint64_t changed_id) { + void rewind(const TextDocument& doc, size_t changed, uint64_t changed_id) { size_t first = changed, bytes = 0; while (first && bytes < query_.size() - 1) bytes += rows_[--first].bytes; next_id_ = first < rows_.size() ? rows_[first].meta.id : changed_id; const auto prefix_before = first < rows_.size() ? rows_[first].prefix_before : current_prefix_before_; + // The change starts at rank 'changed'; the scan may resume earlier so a + // query straddling the boundary can be rescanned, but hits that end + // before the change keep their original position in the result list. emit_from_id_ = changed_id; - matches_.erase(std::remove_if(matches_.begin(), matches_.end(), [changed_id](const SearchMatch& hit) { - return hit.range.end.line_id >= changed_id; - }), matches_.end()); + if (ordered_doc_) { + // Ordered documents keep the original identity threshold: identities + // increase with document position, so the id comparison stays exact. + matches_.erase(std::remove_if(matches_.begin(), matches_.end(), [changed_id](const SearchMatch& hit) { + return hit.range.end.line_id >= changed_id; + }), matches_.end()); + } else { + // Unordered documents compare current ranks. rows_ is contiguous in + // document order, so the first scanned row's rank is the base. + const auto base = rows_.empty() ? size_t{0} : doc.index_of(rows_.front().meta.id).value_or(0); + emit_from_rank_ = base + changed; + matches_.erase(std::remove_if(matches_.begin(), matches_.end(), [&doc, base, changed](const SearchMatch& hit) { + const auto rank = doc.index_of(hit.range.end.line_id); + return !rank || *rank >= base + changed; + }), matches_.end()); + } rows_.resize(first); audit_cursor_ = (std::min)(audit_cursor_, first); current_ = {}; loaded_ = boundary_done_ = at_end_ = cap_reached_ = false; byte_ = span_ = origin_head_ = 0; matched_ = prefix_before; origins_.clear(); } + // Restore "scanned row i sits at document rank i" after a structure-order + // change. Rows before the first displaced one keep their hits; everything + // from there is rescanned by the ordinary bounded path. + void repair_order(const TextDocument& doc) { + size_t first = rows_.size(); + if (!rows_.empty()) { + const auto base = doc.index_of(rows_.front().meta.id); + for (size_t index = 0; index < rows_.size(); ++index) { + const auto rank = doc.index_of(rows_[index].meta.id); + if (!base || !rank || *rank != *base + index) { first = index; break; } + } + } + bool loaded_moved = false; + if (loaded_) { + const auto rank = doc.index_of(current_meta_.id); + loaded_moved = !rank || *rank != rows_.size(); + } + if (first < rows_.size()) rewind(doc, first, rows_[first].meta.id); + else if (loaded_moved) rewind(doc, rows_.size(), current_meta_.id); + document_order_revision_ = doc.order_revision(); + ordered_doc_ = doc.ordered(); + order_changed_ = false; + } void consume(unsigned char c, Origin origin) { if (origins_.size() < query_.size()) origins_.push_back(origin); else { origins_[origin_head_] = origin; origin_head_ = (origin_head_ + 1) % query_.size(); } @@ -145,7 +188,8 @@ class TextSearch { if (c == folded(static_cast(query_[matched_]))) ++matched_; if (matched_ != query_.size()) return; const TextRange range{origins_[origin_head_].begin, origin.end}; - if (origins_.size() == query_.size() && range.end.line_id >= emit_from_id_) { + if (origins_.size() == query_.size() && + (ordered_doc_ ? range.end.line_id >= emit_from_id_ : current_rank_ >= emit_from_rank_)) { // Several matching combining components can map to one cell. if (matches_.empty() || !equal(matches_.back().range.begin, range.begin) || !equal(matches_.back().range.end, range.end)) @@ -158,9 +202,9 @@ class TextSearch { void audit_one(const TextDocument& doc) { if (audit_cursor_ < rows_.size()) { const auto expected = rows_[audit_cursor_]; - if (!row_valid(doc, expected.meta, expected.successor_id)) rewind(audit_cursor_, expected.meta.id); + if (!row_valid(doc, expected.meta, expected.successor_id)) rewind(doc, audit_cursor_, expected.meta.id); else ++audit_cursor_; - } else if (loaded_ && !row_valid(doc, current_meta_, current_successor_id_)) rewind(rows_.size(), current_meta_.id); + } else if (loaded_ && !row_valid(doc, current_meta_, current_successor_id_)) rewind(doc, rows_.size(), current_meta_.id); else if (audit_serial_ == change_serial_) audit_requested_ = false; else { audit_cursor_ = 0; audit_serial_ = change_serial_; } } @@ -169,8 +213,10 @@ class TextSearch { if (rows_.empty() || doc.index_of(rows_.front().meta.id)) return false; const auto removed = rows_.front().meta.id; rows_.pop_front(); if (audit_cursor_) --audit_cursor_; - matches_.erase(std::remove_if(matches_.begin(), matches_.end(), [removed](const SearchMatch& hit) { - return hit.range.begin.line_id <= removed; + // A hit survives only while both endpoints still resolve. Identity order + // no longer implies position, so liveness replaces the id comparison. + matches_.erase(std::remove_if(matches_.begin(), matches_.end(), [&doc](const SearchMatch& hit) { + return !doc.valid(hit.range.begin) || !doc.valid(hit.range.end); }), matches_.end()); if (matches_.size() < options_.result_limit) cap_reached_ = false; // A partial query carried in from evicted text can change the phase of @@ -196,6 +242,7 @@ class TextSearch { at_end_ = true; return false; } current_ = doc.line(*index); current_meta_ = doc.meta(*index); + current_rank_ = *index; current_successor_id_ = *index + 1 < doc.size() ? doc.meta(*index + 1).id : 0; current_prefix_before_ = matched_; next_id_ = 0; loaded_ = true; byte_ = span_ = 0; boundary_done_ = false; @@ -215,7 +262,7 @@ class TextSearch { ++progress.bytes_scanned; return true; } const auto index = doc.index_of(current_meta_.id); - if (!index) { rewind(rows_.size(), current_meta_.id); return true; } + if (!index) { rewind(doc, rows_.size(), current_meta_.id); return true; } const bool has_next = *index + 1 < doc.size(); if (!boundary_done_ && has_next && !current_.joins_next) { const auto next = doc.meta(*index + 1); @@ -236,6 +283,8 @@ class TextSearch { if (!options.result_limit || options.result_limit > result_hard_limit) throw std::invalid_argument("search result limit must be between 1 and 4096"); advance_generation(); reset_scan(); query_ = std::move(query); options_ = options; document_epoch_ = doc.epoch(); + document_order_revision_ = doc.order_revision(); + ordered_doc_ = doc.ordered(); prefix_.assign(query_.size(), 0); for (size_t i = 1, length = 0; i < query_.size(); ++i) { const auto c = folded(static_cast(query_[i])); @@ -254,6 +303,8 @@ class TextSearch { if (change_serial_ == (std::numeric_limits::max)()) throw std::overflow_error("search change serial exhausted"); ++change_serial_; + ordered_doc_ = doc.ordered(); + if (doc.order_revision() != document_order_revision_) order_changed_ = true; if (!audit_requested_) { audit_requested_ = true; audit_cursor_ = 0; audit_serial_ = change_serial_; } at_end_ = false; status_ = SearchStatus::running; return generation_; } @@ -261,8 +312,12 @@ class TextSearch { sync_epoch(doc); SearchStep progress{status_, 0, 0, 0, generation == generation_}; if (!progress.accepted || status_ != SearchStatus::running || !budget) return progress; + if (order_changed_) { + if (!doc.ordered()) { repair_order(doc); ++progress.work_used; } + else { document_order_revision_ = doc.order_revision(); ordered_doc_ = doc.ordered(); order_changed_ = false; } + } if (loaded_ && !row_valid(doc, current_meta_, current_successor_id_)) { - rewind(rows_.size(), current_meta_.id); ++progress.work_used; + rewind(doc, rows_.size(), current_meta_.id); ++progress.work_used; } while (progress.work_used < budget && prune_one(doc)) ++progress.work_used; // Alternating units also guarantee scan progress for a budget of one; @@ -303,8 +358,16 @@ class TextSearch { for (const auto& row : rows_) if (!row_valid(doc, row.meta, row.successor_id)) return {}; } - if (loaded_ && current_meta_.id >= hit.range.begin.line_id && current_meta_.id <= hit.range.end.line_id && - !row_valid(doc, current_meta_, current_successor_id_)) return {}; + if (loaded_) { + // Validate the loaded row only when it lies inside the hit's document + // span: a frontier beyond the hit has nothing to do with that hit. + const auto begin = doc.index_of(hit.range.begin.line_id); + const auto end = doc.index_of(hit.range.end.line_id); + const auto current = doc.index_of(current_meta_.id); + if (!begin || !end || !current) return {}; + if (*begin <= *current && *current <= *end && + !row_valid(doc, current_meta_, current_successor_id_)) return {}; + } return hit; } std::optional navigate(const TextDocument& doc, SearchDirection direction,