Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/EVIDENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 根因。 |
Expand Down
39 changes: 39 additions & 0 deletions experiments/core/results/2026-10-29-search-reorder/README.md
Original file line number Diff line number Diff line change
@@ -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 通过。
45 changes: 45 additions & 0 deletions experiments/core/results/2026-10-29-search-reorder/validation.json
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions experiments/core/search_reorder_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <initializer_list>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
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<size_t>(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<Line> rows(int columns, std::initializer_list<std::pair<uint64_t, std::string>> spec) {
std::vector<Line> 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<size_t> ranks(const TextSearch& search, const TextDocument& doc) {
std::vector<size_t> 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<uint64_t> 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<size_t>{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;
}
Loading
Loading