diff --git a/CMakeLists.txt b/CMakeLists.txt index 89b8803a..7948f3b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,10 @@ add_executable(decawm_mode_test experiments/core/decawm_mode_test.cpp) target_link_libraries(decawm_mode_test PRIVATE termforge_core) termforge_warnings(decawm_mode_test) +add_executable(order_query_test experiments/core/order_query_test.cpp) +target_link_libraries(order_query_test PRIVATE termforge_core) +termforge_warnings(order_query_test) + add_executable(paste_test experiments/shell/paste_test.cpp) termforge_warnings(paste_test) add_executable(clipboard_policy_test experiments/shell/clipboard_policy_test.cpp) @@ -307,6 +311,8 @@ if(BUILD_TESTING) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) set_tests_properties(decawm_mode_core PROPERTIES TIMEOUT 10) + add_test(NAME order_query_core COMMAND order_query_test) + set_tests_properties(order_query_core PROPERTIES TIMEOUT 10) add_test(NAME paste COMMAND paste_test) add_test(NAME clipboard_policy COMMAND clipboard_policy_test) add_test(NAME keyboard COMMAND keyboard_test) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 484daba3..e3e15839 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -9,6 +9,7 @@ | 配置 | 证据定位 | |---|---| | 自主 C++ 主屏 ASCII DECAWM | [有限合同与红绿](../experiments/core/results/2026-10-29-cpp-decawm/README.md)、[固定验证身份](../experiments/core/results/2026-10-29-cpp-decawm/validation.json):抽取原版14–18阶段79B,strict/recovery各235个字面前缀分块用例;初次定向8/9的缺构建依赖失败、补建后的core单项31测试通过分列。跨1049/宽字/resize、origin/margin、GUI/固定ConPTY运输和完整TUI均不由本项验收 | +| 自主 C++ 行结构发布与精确身份查询 | [首片合同与固定夹具](../experiments/core/results/2026-10-29-order-query/README.md)、[验证身份](../experiments/core/results/2026-10-29-order-query/validation.json):`[1,3,4,5,7,6]` 的精确 rank(7→4、6→5、2 缺失)、文档序比较与强制 Find 导航、结构 revision 边界、发布拒绝与光标夹取;筛选的 8 项核心 CTest 与本地候选 `candidate_core` 通过,首个推送版本的 CI 红(回放保留历史仍为上一帧宽度)与按物理行策略适配后的绿分列,完整默认 Release 构建 exit0。未封住公开 lines/history,运行中跨重排的增量 Find、区域 LF、Qt/固定 ConPTY 与性能均未验收 | | 自主 C++ 限定 1049 协议 | [固定结果](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[验证身份](../experiments/core/results/2026-10-29-cpp-1049/validation.json):文字/光标/颜色及 pending-wrap、重复进入与读者失效;55项中Qt1项分列,13条内部skip及旧负例判据失败保留。未验收非颜色属性/全网格背景、其他模式、真实TUI/IME/DPI、呈现或性能 | | 自主 C++ 双缓冲存储与文档 epoch 基础 | [固定结果](../experiments/core/results/2026-10-29-buffer-state-r2/README.md)、[验证身份](../experiments/core/results/2026-10-29-buffer-state-r2/validation.json):拥有状态、共享 ID、隐藏帧 resize 回滚与跨缓冲句柄失效;Qt/candidate 关闭配置的完整 50 项及 12 条内部 skip 分列。未接 private CSI,不是原版模式、Qt 产品路线或完整 #27 验收 | | Qt 基础 Unicode 文本字节 | [红绿与联合验证](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、[固定联合身份](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json):公开Qt事件到独立ReadConsoleW接收端,整/分事件代理项、焦点/隐藏/关闭、1024B整包与NUL、两目标隔离。2b6d源码一次61项分列Qt3/其余58,13内部skip、旧红例与27原件保留;不是实际IME、字素/字形、原生Unicode窗口、DPI或性能验收 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ef49b77d..1c42323b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -25,7 +25,7 @@ |---|---|---|---| | 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列;[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留 | 分屏、其他 shell、工作区恢复、完整 resize/reflow、历史/选择/Find、完整 Unicode 字素/绘制与 IME、真实 DPI、应用兼容与性能仍需实现或迁移;物理裁剪不恢复丢弃正文,完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | -自主 C++ 核心已有 [#130 双缓冲存储基础](../experiments/core/results/2026-10-29-buffer-state-r2/README.md),并接入 [#135 限定 1049 协议](../experiments/core/results/2026-10-29-cpp-1049/README.md):strict/recovery 识别 `?1049h/l`,重复进入归首格、清旧备用文本和复位写入颜色,返回恢复主屏文字/光标/颜色及 pending-wrap。共享行 ID、双帧物理 resize 与逻辑 epoch 防止旧位置、选择、搜索及 viewport 复活,包括清屏后重写相同文字。[主屏 ASCII DECAWM 切片 #161](../experiments/core/results/2026-10-29-cpp-decawm/README.md)另按[有限直接观察](research/windterm-origin-wrap-reference-2026-10-29.md)实现 `?7h/l` 与末列开关,验证五阶段79B的字面文字/位置及分块;原版 origin/margin、wrap跨屏/宽字/resize和完整网格/样式仍未知或未实现。#135本地55项中Qt1项与其余54项诊断分列,13条内部跳过保留;#161仅有独立core CLI定向回归,不表示完整 [#27](https://github.com/JavaZeroo/termforge/issues/27)、真实 TUI/Qt 交互或系统剪贴板已验收。 +自主 C++ 核心已有 [#130 双缓冲存储基础](../experiments/core/results/2026-10-29-buffer-state-r2/README.md),并接入 [#135 限定 1049 协议](../experiments/core/results/2026-10-29-cpp-1049/README.md):strict/recovery 识别 `?1049h/l`,重复进入归首格、清旧备用文本和复位写入颜色,返回恢复主屏文字/光标/颜色及 pending-wrap。共享行 ID、双帧物理 resize 与逻辑 epoch 防止旧位置、选择、搜索及 viewport 复活,包括清屏后重写相同文字。[主屏 ASCII DECAWM 切片 #161](../experiments/core/results/2026-10-29-cpp-decawm/README.md)另按[有限直接观察](research/windterm-origin-wrap-reference-2026-10-29.md)实现 `?7h/l` 与末列开关,验证五阶段79B的字面文字/位置及分块;原版 origin/margin、wrap跨屏/宽字/resize和完整网格/样式仍未知或未实现。#135本地55项中Qt1项与其余54项诊断分列,13条内部跳过保留;#161仅有独立core CLI定向回归,不表示完整 [#27](https://github.com/JavaZeroo/termforge/issues/27)、真实 TUI/Qt 交互或系统剪贴板已验收。另有[行结构发布与精确身份查询首片 #169](../experiments/core/results/2026-10-29-order-query/README.md):`Screen` 经 `publish` 提交拥有值的行镜像并推进结构 revision,`locate` 精确回答 ID→rank(缺失不落到邻行),`TextDocument` 的定位/比较与 `HistoryViewport` 的活 anchor 改用精确 rank,`TextSearch` 只把**导航比较**改成文档序;固定 `[1,3,4,5,7,6]` 夹具、筛选的 8 项核心 CTest 与候选 `candidate_core` 通过(首个推送版本在该项为红,混合宽度帧修复后复跑为绿,过程保留)。该片**未**封住公开 `lines/history`、未迁移有序增量 Find 的 `emit/prune/rewind` ID 假设,也没有区域 LF、新 VT 或 Qt/固定 ConPTY 证据;#169 与 #27/#29 保持开放。 Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/msvc_runtime/README.md),不安装到 System32。隔离 Windows 的[限定加载与 cmd 验证](../experiments/shell/results/2026-10-29-qt-crt-deploy/README.md)保留原包失败、原 52 文件不变且仅追加 CRT/原许可后的成功,以及新源码包的独立验证;它不解除完整产品打包、Qt/第三方分发审计、实际 DPI/IME 或物理呈现的验收缺口。 diff --git a/experiments/core/candidate_terminal.cpp b/experiments/core/candidate_terminal.cpp index cc12246e..6633dbba 100644 --- a/experiments/core/candidate_terminal.cpp +++ b/experiments/core/candidate_terminal.cpp @@ -279,8 +279,7 @@ void CandidateTerminal::synchronize() { screen.wrap_pending = replay_origin_->wrap_pending; screen.discarded = replay_origin_->discarded; screen.style = replay_origin_->style; - screen.history = replay_origin_->history; - screen.lines = replay_origin_->lines; + screen.restore_frame(*replay_origin_); } return; } @@ -343,13 +342,24 @@ void CandidateTerminal::synchronize() { versions.emplace(line.id, version); } + // Preserved history arrives from an older frame, so it can still hold the + // previous width. Fit it with the same physical-row policy Screen::resize + // applies instead of publishing rows whose width contradicts the frame. + for (auto& line : retained) { + if (line.size() == static_cast(meta.columns)) continue; + if (line.size() > static_cast(meta.columns)) line.cropped = true; + line.resize(meta.columns); + if (!line.empty() && line.back().width == 2) line.back() = Cell{}; + } + screen.publish(std::vector(retained.begin(), retained.begin() + meta.history), + std::vector(retained.begin() + meta.history, retained.end())); + // Cursor and style follow the accepted publication: a rejected frame must + // not leave the screen half-updated. screen.row = meta.row; screen.column = static_cast(meta.column); screen.wrap_pending = meta.wrap_pending; screen.discarded = meta.discarded; screen.style = {meta.fg, meta.bg, meta.bold}; - screen.history.assign(retained.begin(), retained.begin() + meta.history); - screen.lines.assign(retained.begin() + meta.history, retained.end()); versions_ = std::move(versions); identity_aliases_.clear(); for (size_t index = 0; index < retained.size(); ++index) diff --git a/experiments/core/history_view.hpp b/experiments/core/history_view.hpp index b948f5dd..e3c91ac2 100644 --- a/experiments/core/history_view.hpp +++ b/experiments/core/history_view.hpp @@ -24,10 +24,16 @@ class HistoryViewport { } public: // A viewport is owned by exactly one session. Never share it across Screens. + // A live anchor uses its exact rank; an evicted anchor keeps the existing + // ordered-history fallback (first retained row at or after the id). + size_t anchor_top(const Screen& screen) const { + if (const auto index = screen.locate(anchor_)) return (std::min)(*index, screen.history.size()); + return (std::min)(screen.lower_bound_line(anchor_), screen.history.size()); + } ViewportState reconcile(const Screen& screen) { sync_epoch(screen); const size_t maximum = screen.history.size(); - const size_t top = following_ ? maximum : (std::min)(screen.lower_bound_line(anchor_), maximum); + const size_t top = following_ ? maximum : anchor_top(screen); anchor_ = screen.retained_line(top).id; return {top, screen.retained_size(), static_cast(screen.height()), maximum, anchor_, following_}; } @@ -53,8 +59,8 @@ class HistoryViewport { } ViewportState bottom(const Screen& screen) { return scroll_to(screen, screen.history.size()); } ViewportState reveal(const Screen& screen, uint64_t identity) { - const auto index = screen.lower_bound_line(identity); - return scroll_to(screen, index); + if (const auto index = screen.locate(identity)) return scroll_to(screen, *index); + return scroll_to(screen, screen.lower_bound_line(identity)); } bool following() const { return following_; } uint64_t anchor() const { return anchor_; } diff --git a/experiments/core/order_query_test.cpp b/experiments/core/order_query_test.cpp new file mode 100644 index 00000000..80826388 --- /dev/null +++ b/experiments/core/order_query_test.cpp @@ -0,0 +1,201 @@ +// Hand-authored rank/order expectations. A row identity is not a document +// position: a larger id may sit earlier in the retained sequence. +#include "history_view.hpp" +#include "screen.hpp" +#include "text_document.hpp" +#include "text_search.hpp" +#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, char text) { + Line line(static_cast(columns), Cell{}); + line.id = identity; + line.version = 1; + line[0] = {std::string(1, text), 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; +} + +// The [1,3,4,5,7,6] shape: two live rows whose ids are not in document order. +static Screen reordered_screen() { + Screen screen(2, 4, 4); + screen.publish(rows(4, {{1, 'a'}, {3, 'b'}, {4, 'c'}, {5, 'd'}}), + rows(4, {{7, 'x'}, {6, 'x'}})); + return screen; +} + +static void published_order_is_exact() { + auto screen = reordered_screen(); + check(screen.retained_size() == 6, "published frame keeps every retained row"); + check(screen.locate(7) == std::optional(4), "live identity 7 holds rank 4"); + check(screen.locate(6) == std::optional(5), "live identity 6 holds rank 5"); + check(screen.locate(5) == std::optional(3), "history identity 5 holds rank 3"); + check(!screen.locate(2), "a missing identity is never replaced by a neighbour"); + check(!screen.locate(0), "zero is not an identity"); + // The evicted-anchor fallback stays explicit and separate from exact lookup. + check(screen.lower_bound_line(2) == 1, "lower bound still resolves an insertion point"); + check(screen.retained_line(*screen.locate(6)).id == 6, "rank resolves back to the identity"); +} + +static void document_rank_and_compare() { + auto screen = reordered_screen(); + TextDocument doc(screen); + check(doc.index_of(7) == std::optional(4), "document rank follows the retained order"); + check(doc.index_of(6) == std::optional(5), "second reordered row keeps its own rank"); + check(!doc.index_of(2), "document reports a removed identity as missing"); + const auto earlier = doc.position(4, 0), later = doc.position(5, 0); + check(earlier && later, "positions exist for both live rows"); + check(doc.valid(*earlier) && doc.valid(*later), "published rows validate"); + check(doc.compare(*earlier, *later) == std::optional(-1), "7 precedes 6 in document order"); + check(doc.compare(*later, *earlier) == std::optional(1), "comparison is antisymmetric"); + check(doc.compare(*earlier, *earlier) == std::optional(0), "identical positions compare equal"); +} + +static void order_revision_tracks_structure_only() { + Screen screen(2, 4, 4); + const auto initial = screen.order_revision(); + screen.put('a', "a"); + check(screen.order_revision() == initial, "content writes are not structure changes"); + screen.newline(); + check(screen.order_revision() == initial, "a newline that only moves the cursor is not structural"); + screen.newline(); + check(screen.order_revision() > initial, "scrolling the bottom row changes structure"); + const auto scrolled = screen.order_revision(); + screen.resize(3, 4); + check(screen.order_revision() > scrolled, "physical resize changes structure"); + const auto resized = screen.order_revision(); + screen.resize(3, 4); + check(screen.order_revision() == resized, "an identical resize publishes nothing"); + screen.activate_buffer(ScreenBuffer::alternate); + check(screen.order_revision() > resized, "activating a frame changes the document owner"); + const auto published = screen.order_revision(); + screen.publish({}, {row(4, 900, 'z'), row(4, 901, 'w')}); + check(screen.order_revision() > published, "publishing different rows changes structure"); + const auto republished = screen.order_revision(); + screen.publish({}, {row(4, 900, 'z'), row(4, 901, 'w')}); + check(screen.order_revision() == republished, "republishing the same structure publishes nothing"); +} + +static void publication_keeps_the_cursor_inside_the_frame() { + Screen screen(3, 4, 4); + screen.move(2, 3); + screen.publish({}, {row(4, 40, 'a')}); + check(screen.row == 0 && screen.column == 3, "a shorter published frame clamps only the lost row"); + screen.put('b', "b"); + check(screen.lines[0][3].text == "b", "writes after a shrinking publication stay in range"); +} + +static void publication_respects_the_active_history_budget() { + Screen screen(2, 4, 1); + bool rejected = false; + try { screen.publish(rows(4, {{1, 'a'}, {2, 'b'}}), rows(4, {{3, 'c'}, {4, 'd'}})); } + catch (const std::invalid_argument&) { rejected = true; } + check(rejected, "a publication above the active history budget is rejected"); + screen.activate_buffer(ScreenBuffer::alternate); + bool alternate = false; + try { screen.publish(rows(4, {{5, 'e'}}), rows(4, {{6, 'f'}, {7, 'g'}})); } + catch (const std::invalid_argument&) { alternate = true; } + check(alternate, "the alternate frame accepts no imported history"); +} + +static void eviction_never_revives_rank() { + Screen screen(2, 4, 2); + screen.publish(rows(4, {{10, 'a'}, {11, 'b'}}), rows(4, {{12, 'c'}, {13, 'd'}})); + check(screen.locate(10) == std::optional(0), "evictable history is located before eviction"); + screen.move(1, 0); + screen.newline(); + screen.newline(); + check(!screen.locate(10), "an evicted identity is not located any more"); + check(screen.locate(13).has_value(), "a surviving identity keeps an exact rank"); + check(screen.lower_bound_line(10) == 0, "the evicted-anchor fallback stays an explicit insertion point"); +} + +static void copies_keep_their_own_order() { + auto original = reordered_screen(); + Screen copy = original; + check(copy.locate(7) == std::optional(4) && copy.locate(6) == std::optional(5), + "a copy keeps the published order"); + copy.publish({}, {row(4, 1, 'q'), row(4, 2, 'r')}); + check(original.locate(7) == std::optional(4), "changing a copy leaves the original order intact"); + check(copy.locate(7) == std::nullopt, "a republished copy reports its own identities only"); +} + +static void rejected_publication_commits_nothing() { + Screen screen(2, 4, 4); + const auto revision = screen.order_revision(); + const auto retained = screen.retained_size(); + const auto rejected = [&](auto&& call) { + try { call(); } catch (const std::invalid_argument&) { return true; } + return false; + }; + check(rejected([&] { screen.publish({}, rows(4, {{0, 'a'}, {1, 'b'}})); }), "zero identity rejected"); + check(rejected([&] { screen.publish(rows(4, {{3, 'a'}}), rows(4, {{3, 'b'}, {4, 'c'}})); }), + "duplicate identity rejected across history and live rows"); + check(rejected([&] { screen.publish({}, rows(3, {{9, 'a'}, {10, 'b'}})); }), "width mismatch rejected"); + check(rejected([&] { screen.publish({}, {}); }), "a frame without a live row is rejected"); + check(screen.order_revision() == revision && screen.retained_size() == retained && + screen.locate(screen.retained_line(0).id) == std::optional(0), + "a rejected publication leaves the published structure untouched"); +} + +static void search_navigation_follows_document_order() { + auto screen = reordered_screen(); + // Row 4 (id 7) holds "x"; row 5 (id 6) also holds "x". Document order says 7 + // comes first even though 6 < 7. + TextDocument doc(screen); + TextSearch search; + search.start(doc, "x"); + for (int guard = 0; guard < 256 && search.status() == SearchStatus::running; ++guard) + search.step(doc, search.generation(), 64); + check(search.matches().size() == 2, "both reordered rows produce a hit"); + const auto anchor = doc.position(5, 0); + check(anchor.has_value(), "anchor position exists"); + const auto previous = search.navigate(doc, SearchDirection::previous, anchor); + check(previous && previous->match.range.begin.line_id == 7, + "previous navigation uses document order, not identity order"); + const auto earlier = doc.position(4, 0); + const auto next = search.navigate(doc, SearchDirection::next, earlier); + check(next && next->match.range.begin.line_id == 6, "next navigation reaches the later live row"); + const auto no_wrap = search.navigate_no_wrap(doc, SearchDirection::previous, anchor); + check(no_wrap && no_wrap->match.range.begin.line_id == 7, + "no-wrap previous navigation also follows document order"); +} + +static void viewport_uses_exact_rank() { + auto screen = reordered_screen(); + HistoryViewport view; + view.scroll_to(screen, 1); + check(view.reconcile(screen).anchor == 3, "paused viewport anchors on the row it shows"); + const auto state = view.reveal(screen, 5); + check(state.top == 3 && state.anchor == 5, "reveal scrolls to the exact published rank"); + check(view.reconcile(screen).top == 3, "the anchor survives reconciliation"); +} + +int main() { + try { + published_order_is_exact(); + document_rank_and_compare(); + order_revision_tracks_structure_only(); + publication_keeps_the_cursor_inside_the_frame(); + publication_respects_the_active_history_budget(); + eviction_never_revives_rank(); + copies_keep_their_own_order(); + rejected_publication_commits_nothing(); + search_navigation_follows_document_order(); + viewport_uses_exact_rank(); + std::cout << "exact identity rank, publication and document order passed\n"; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/experiments/core/results/2026-10-29-order-query/README.md b/experiments/core/results/2026-10-29-order-query/README.md new file mode 100644 index 00000000..4dfb5749 --- /dev/null +++ b/experiments/core/results/2026-10-29-order-query/README.md @@ -0,0 +1,64 @@ +# 行结构发布、精确身份查询与文档序(#169 首片) + +本片把“稳定行 ID 被当作文档顺序”的隐含假设收敛到 `Screen` 的 owner 发布口与精确查询:行身份只表示身份,位置由当前保留序列决定。没有新增 VT 模式、区域 LF 或任何用户可触发行为;`Screen::lines/history` 仍是公开成员,封住直接写入属于下一片。 + +## 交付内容 + +| 入口 | 变更 | +|---|---| +| `Screen::publish(history, live)` | 导入的显式提交口:先校验行宽、非零且唯一 ID、活跃历史预算与尺寸,再把新存储准备好并交换,校验或分配失败都不留下半提交;成功才推进结构 revision,并把光标夹回新帧内 | +| `Screen::locate(id)` | 精确 ID→rank;缺失 ID 返回空,**不再**退化为相邻行。只在可证有序时二分,其余逐行精确扫描 | +| `Screen::order_revision()` | 结构 revision:保留成员、文档顺序或 history/live 分界改变时前进;内容写入、仅移动光标的新行、同尺寸 resize 与同结构重复发布都不前进。内部结构变更走同一推进点,缓冲切换与回放恢复也计入 | +| `Screen::mark_order_unknown()` / `restore_frame(origin)` | 回放恢复改为显式入口:保留原帧镜像、推进 revision、声明顺序不再可证;绕过发布口的直接写入仍须调用 `mark_order_unknown()` | +| `TextDocument` | `index_of` 改用 `locate`(因此依赖它的位置比较与校验拿到精确 rank);新增按当前文档序的 `compare` 与 `ordered()` | +| `HistoryViewport` | 活 anchor 用精确 rank;已淘汰 anchor 保留原有 `lower_bound` 回退,两条路径分开 | +| `TextSearch::navigate` | 前后导航改用文档序;`match()` 在顺序不可证时不再按 ID 二分校验扫描记录,改为整体校验 | +| `CandidateTerminal` | 导入改经 `publish`;回放恢复改经 `restore_frame`;保留历史先按物理行策略适配到当前帧宽,不再把上一帧宽度的行混进新帧 | + +## 固定夹具与期望 + +``` +4 列、history_limit=4 +history ids [1,3,4,5] live ids [7,6] +``` + +| 断言 | 期望 | +|---|---| +| `locate(7)` / `locate(6)` / `locate(5)` | rank 4 / 5 / 3 | +| `locate(2)` / `locate(0)` | 缺失 / 缺失(不落到邻行) | +| `lower_bound_line(2)` | 1(已淘汰 anchor 的旧回退保持独立) | +| 文档序比较 | 7 在 6 之前,尽管 6 < 7 | +| 新增强制 Find 的 previous/next/no-wrap | previous(6)→7、next(7)→6、no-wrap previous(6)→7 | +| 结构 revision | 只在下滚、物理 resize、缓冲切换、结构发布、回放恢复时前进 | + +## 实际验证 + +环境:Windows 11 10.0.26200、Visual Studio 17 2022 / MSVC 19.42.34433.0、CMake 3.31.0、Release;源码状态见 `validation.json`。 + +- 完整默认 Release 构建(Qt 与 candidate 关闭)exit 0。 +- 核心 CTest 筛选 8 项 `8/8` 通过:新增 `order_query_core`,`resize_core`/`history_core`/`selection_core`/`search_core`/`buffer_state_core`/`alternate_mode_core`/`decawm_mode_core` 维持通过。 +- 候选适配器另在本机以 `TERMFORGE_BUILD_CANDIDATE_ADAPTER=ON` 构建:`candidate_core` `1/1` 通过。CI 上同一项在首个推送版本为红(见下),修复后本地复跑为绿。 +- **首个推送版本的真实失败(保留)**:CI 的 `alacritty-headless` 两个 Ubuntu 作业在 `candidate_core` 报 `published row width differs from the screen`。原因是回放保留历史来自上一帧,宽度仍是旧值(111 列)而被发布进 149 列的新帧;旧代码直接 `assign` 掩盖了这个混合宽度的帧。修复方式是按 `Screen::resize` 的物理行策略把保留行适配到当前帧宽(缩窄置 `cropped`、宽带尾格清空),不是放宽发布校验。该红→绿只针对候选适配器,不转记为其他配置。 +- 新测试自身的首轮失败(`y`/`x` 夹具、`order_known` 期望、光标夹取期望)只改测试,**不作为产品红→绿证据**。 + +身份与原始命令见 [validation.json](validation.json)。 + +## 未完成与本片不主张的结论 + +- `Screen::lines/history` 仍公开可写,外部仍可绕过 owner 发布;封口与全部 caller 迁移属下一片。 +- 有序增量 Find 内部的 `emit_from_id_`/`prune`/`rewind` 仍假设有序:运行中跨重排的搜索未验收;本片只把导航比较与 `match()` 的校验范围改成与顺序无关。 +- 未实现区域 LF、新的 VT 模式、reflow 或用户可达重排;默认物理裁剪策略不变。 +- 未运行 Qt、固定 ConPTY、真实 GUI、真实 TUI 或任何性能负载;没有性能数值或容差主张,#169 与 #27/#29 均未关闭。 + +## 复验 + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Release --target order_query_test history_test selection_test search_test resize_test buffer_state_test alternate_mode_test decawm_mode_test --parallel 2 +ctest --test-dir build -C Release -R "^(order_query_core|history_core|selection_core|search_core|resize_core|buffer_state_core|alternate_mode_core|decawm_mode_core)$" --output-on-failure + +# 候选适配器(需要 Rust 工具链) +cmake -S . -B build/cand -G "Visual Studio 17 2022" -A x64 -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=ON +cmake --build build/cand --config Release --target candidate_core_test --parallel 2 +ctest --test-dir build/cand -C Release -R "^candidate_core$" --output-on-failure +``` diff --git a/experiments/core/results/2026-10-29-order-query/validation.json b/experiments/core/results/2026-10-29-order-query/validation.json new file mode 100644 index 00000000..5cb3a993 --- /dev/null +++ b/experiments/core/results/2026-10-29-order-query/validation.json @@ -0,0 +1,123 @@ +{ + "schema": "termforge-core-order-query-local-validation-v1", + "issue": 169, + "base_commit": "05f04d0222c06bed958f6894a27d2a1b0708d270", + "validated_source_commit": "d5116e2cefc4755efc79a4bd8de26eb2629ca71a", + "first_pushed_source_commit": "33b58fa4452c4d4f23dcfc463f65c5c521e59065", + "docs_and_evidence_commit": "the commit that adds this file's current revision", + "branch": "feat/core-order-query-r169", + "scope": "Screen 发布行结构、精确 ID→rank 查询、结构 revision、回放恢复入口;TextDocument 定位/比较、HistoryViewport 活 anchor、TextSearch 导航比较改用文档序;CandidateTerminal 经发布口导入。未封住公开 lines/history。", + "configuration": { + "platform": "Windows-11-10.0.26200", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42.34433.0", + "build_type": "Release", + "default_build": { + "qt_shell": false, + "candidate_adapter": false + }, + "candidate_build": { + "path": "build/cand", + "candidate_adapter": true, + "rust": "cargo 1.92.0" + } + }, + "observed_contract": { + "fixture": { + "columns": 4, + "history_limit": 4, + "history_ids": [ + 1, + 3, + 4, + 5 + ], + "live_ids": [ + 7, + 6 + ] + }, + "expected": { + "locate_rank": { + "0": null, + "2": null, + "5": 3, + "6": 5, + "7": 4 + }, + "lower_bound_id_2": 1, + "document_order_compare_7_before_6": true, + "previous_navigation_from_id_6": 7, + "next_navigation_from_id_7": 6, + "no_wrap_previous_from_id_6": 7 + }, + "order_revision_advances_on": [ + "bottom-row scroll", + "physical resize", + "buffer activation", + "structural publication", + "replay frame restore" + ], + "order_revision_unchanged_on": [ + "cell write", + "cursor-only newline", + "identical resize", + "identical republication", + "rejected publication" + ], + "publication_rejections_covered": [ + "zero identity", + "duplicate identity", + "row width mismatch", + "no live row", + "history above active budget" + ], + "publication_cursor_policy": "clamp into the published frame, then invalidate the old attachment", + "candidate_history_fit": "preserved history rows from an older frame are fitted to the current width (shrink sets cropped, wide tail cell cleared) before publication" + }, + "commands": [ + "cmake --build build --config Release --parallel 2", + "ctest --test-dir build -C Release -R \"^(order_query_core|resize_core|history_core|selection_core|search_core|buffer_state_core|alternate_mode_core|decawm_mode_core)$\" --output-on-failure", + "cmake -S . -B build/cand -G \"Visual Studio 17 2022\" -A x64 -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=ON", + "cmake --build build/cand --config Release --target candidate_core_test --parallel 2", + "ctest --test-dir build/cand -C Release -R \"^candidate_core$\" --output-on-failure" + ], + "results": { + "full_default_release_build": "exit 0", + "core_ctest_filtered_8": "8/8 passed", + "candidate_core_local": "1/1 passed", + "first_pushed_ci": "alacritty-headless (ubuntu-24.04) failed: candidate_core -> published row width differs from the screen" + }, + "preserved_failures": [ + "首个推送版本 33b58fa 的 CI:两个 Ubuntu alacritty-headless 作业在 candidate_core 失败,报 published row width differs from the screen。已定位为回放保留历史仍是上一帧宽度(111 列)被发布进 149 列新帧;旧代码的直接 assign 掩盖了混合宽度。修复按物理行策略适配保留行后,本地同配置复跑 1/1。该红→绿只属于候选适配器配置。", + "新测试首轮失败两次均来自测试自身:夹具第二可见行写成 y 却期望两个 x 命中;把已发布乱序期望成 order_known()==true;后续光标断言误期望列也归零。仅改测试,不作为产品红→绿。", + "Screen::lines/history 仍公开,封口与 caller 迁移未完成;有序增量 Find 的 emit_from_id_/prune/rewind 仍按 ID 假设,运行中重排未验收。", + "Qt、固定 ConPTY、真实 GUI/TUI 与任何性能负载均未由本项运行。" + ], + "source_sha256": { + "experiments/core/screen.hpp": "18d62af8d190c798218f7bce6d390ff8af5478aa6a1a0f1f1256059790866843", + "experiments/core/text_document.hpp": "d4862e269120fb7d9eb5e14f16d86ceadc905b0998fa45dbe8ea21665df5bb32", + "experiments/core/text_search.hpp": "ddd3b2fad0f39795f74003951a885f57bc7f2a5eb0678ab15111ebd523dad010", + "experiments/core/history_view.hpp": "50dcb2f7e801082f7f21bc23f43e6944af711fde0af6e4c100152ca46ed9bd13", + "experiments/core/candidate_terminal.cpp": "88457784ad947b55a3aa956bedde35a810fa2b77fc68de0d54515fed1dbe7812", + "experiments/core/order_query_test.cpp": "c546abd517b5a56ad39280d02c020a0bbfb172ccebfdbd5dacc30bddb531e409", + "CMakeLists.txt": "cd5546218b758cff70471870d9b6b8b59479e330cfb432274253a4de8266a092" + }, + "binary_sha256": { + "order_query_test.exe": "632dcc9f4a3b8a06dde355e269f52db48a3ba4b63c41aa1a3be39b1b6c69e885", + "history_test.exe": "cfb5fd1f9a9c0729186ec0a404ee7f30d87472d08629122b45419b1a9954bd12", + "selection_test.exe": "dc06e54ab192270d0bfdd6897e3de7f934cbe60c8bd1d67edbf855b0b895ba6c", + "search_test.exe": "e28165dd170cbb5e8bf724c4d545c10c1efd724bf454b4c4aa49670614b15daf", + "resize_test.exe": "de693ad51c7212c84ddad34b1faf6bd615cca71137b26ef57e328be1e22e5f3c", + "buffer_state_test.exe": "fa23349623be72f42ff207be2fb2d640ea5e76060197b78c3f9a99b211700221", + "alternate_mode_test.exe": "b5aad988e1fcc63e9731f0140124fd2f7af9c8699219f84607712f124768cf6b", + "decawm_mode_test.exe": "3795a7fa5f4507b96076ee16d6c629e89f92f5062b4e59e56dad275bb54fb635", + "candidate_core_test.exe": "f34a08f9f13ecc5e7a9d3e64ed19bf06d5e2a010fdf6c874c007133c317ba881" + }, + "limitations": [ + "Screen::lines/history 仍为公开成员:本片只提供 owner 发布口与显式回放恢复,未封住直接写入。", + "有序增量 Find 内部的 emit_from_id_/prune/rewind 仍假设有序;运行中任意重排未验收。", + "无 region LF、无新 VT 模式、无用户可达重排命令,也未改默认物理裁剪策略。", + "候选适配器只在本机以 Rust 依赖构建并运行 candidate_core;不覆盖真实 ConPTY 运输、GUI 或性能。" + ] +} diff --git a/experiments/core/screen.hpp b/experiments/core/screen.hpp index 9057fbcb..cee00fe4 100644 --- a/experiments/core/screen.hpp +++ b/experiments/core/screen.hpp @@ -61,11 +61,15 @@ class Screen { Style style; std::vector lines; std::deque history; + // True only while the retained sequence is known to be ordered by id. + // False is the safe fallback: lookups scan instead of bisecting. + bool ordered_ = true; template void swap(State& other) noexcept { std::swap(row, other.row); std::swap(column, other.column); std::swap(last_row, other.last_row); std::swap(last_column, other.last_column); std::swap(wrap_pending, other.wrap_pending); std::swap(discarded, other.discarded); std::swap(style, other.style); + std::swap(ordered_, other.ordered_); lines.swap(other.lines); history.swap(other.history); } }; @@ -74,11 +78,13 @@ class Screen { int columns_; uint64_t next_id_ = 1; uint64_t buffer_epoch_ = 0; + uint64_t order_revision_ = 0; + bool ordered_ = true; int last_row = -1, last_column = -1; ScreenBuffer active_buffer_ = ScreenBuffer::primary; std::optional inactive_; FrameState active_state_copy() const { - return {row, column, last_row, last_column, wrap_pending, discarded, style, lines, history}; + return {row, column, last_row, last_column, wrap_pending, discarded, style, lines, history, ordered_}; } void swap_active(FrameState& state) noexcept { state.swap(*this); @@ -88,6 +94,15 @@ class Screen { throw std::overflow_error("line version exhausted"); ++line.version; } + // Internal structure mutations advance the document-order revision here. + // Overflow is reported rather than wrapped; reaching it requires 2^64 + // structural changes in one session, so it is not a caller-visible path. + void advance_order(bool ordered) { + if (order_revision_ == (std::numeric_limits::max)()) + throw std::overflow_error("screen order revision exhausted"); + ++order_revision_; + ordered_ = ordered; + } static Line make_line(int columns, const Cell& cell, uint64_t& identity) { if (identity == (std::numeric_limits::max)()) throw std::overflow_error("line identities exhausted"); @@ -202,6 +217,7 @@ class Screen { active_buffer_ = buffer; next_id_ = identity; ++buffer_epoch_; + advance_order(ordered_); } size_t retained_size() const { return history.size() + lines.size(); } const Line& retained_line(size_t index) const { @@ -220,8 +236,110 @@ class Screen { return first; } const Line* find_line(uint64_t identity) const { - const auto index = lower_bound_line(identity); - return index < retained_size() && retained_line(index).id == identity ? &retained_line(index) : nullptr; + const auto index = locate(identity); + return index ? &retained_line(*index) : nullptr; + } + // Owner-published structure revision: advances whenever retained membership, + // document order or the history/live split changes. It never stands in for a + // row content version or for the buffer epoch. + uint64_t order_revision() const { return order_revision_; } + // Exact retained rank for a live identity. A missing id is never replaced by + // a neighbour: callers that need the old evicted-anchor fallback must ask for + // it explicitly (lower_bound_line). + std::optional locate(uint64_t identity) const { + if (!identity) return std::nullopt; + if (ordered_) { + const auto index = lower_bound_line(identity); + if (index < retained_size() && retained_line(index).id == identity) return index; + return std::nullopt; + } + for (size_t index = 0; index < retained_size(); ++index) + if (retained_line(index).id == identity) return index; + return std::nullopt; + } + // A direct structural write that bypasses publish() must call this so later + // lookups scan instead of trusting a bisect that no longer holds. + void mark_order_unknown() { ordered_ = false; } + // Restore a complete frame image captured from this same screen family + // (replay). The restored order is not provably the active sequence any more, + // so lookups fall back to scanning and readers lose their rank-derived state. + // Columns and the identity allocator stay with this screen. + void restore_frame(const Screen& origin) { + if (order_revision_ == (std::numeric_limits::max)()) + throw std::overflow_error("screen order revision exhausted"); + std::vector next_lines = origin.lines; + std::deque next_history = origin.history; + row = origin.row; column = origin.column; + wrap_pending = origin.wrap_pending; discarded = origin.discarded; style = origin.style; + lines.swap(next_lines); history.swap(next_history); + ordered_ = origin.ordered_; + ++order_revision_; + invalidate_attachment(); + } + bool order_known() const { return ordered_; } + // Explicit publication of an owned row image (import/replay). Identities are + // validated, never renumbered: a duplicate or zero identity is rejected + // before anything is committed. Order is recorded so lookups stay exact. + // Cursor, style, discarded counters and the inactive frame are untouched. + void publish(std::vector history_rows, std::vector live_rows) { + const auto row_width = static_cast(columns_); + const auto validate = [row_width](const std::vector& rows, const char* what) { + for (const auto& line : rows) { + if (line.size() != row_width) throw std::invalid_argument(what); + if (!line.id) throw std::invalid_argument("published row identity is zero"); + } + }; + validate(history_rows, "published row width differs from the screen"); + validate(live_rows, "published row width differs from the screen"); + if (live_rows.empty() || live_rows.size() > 200) + throw std::invalid_argument("published live rows must be between 1 and 200"); + if (history_rows.size() > active_history_limit()) + throw std::invalid_argument("published history exceeds the active retained limit"); + std::vector identities; + identities.reserve(history_rows.size() + live_rows.size()); + for (const auto& line : history_rows) identities.push_back(line.id); + for (const auto& line : live_rows) identities.push_back(line.id); + std::sort(identities.begin(), identities.end()); + if (std::adjacent_find(identities.begin(), identities.end()) != identities.end()) + throw std::invalid_argument("published row identity is duplicated"); + bool ordered = true; + uint64_t maximum = next_id_ ? next_id_ - 1 : 0, previous = 0; + bool first = true; + const auto track = [&](const std::vector& rows) { + for (const auto& line : rows) { + if (!first && line.id < previous) ordered = false; + if (line.id > maximum) maximum = line.id; + previous = line.id; first = false; + } + }; + track(history_rows); track(live_rows); + if (maximum == (std::numeric_limits::max)()) + throw std::overflow_error("screen line identities exhausted"); + const auto differs = [](const auto& current, const std::vector& next) { + if (current.size() != next.size()) return true; + for (size_t index = 0; index < next.size(); ++index) + if (current[index].id != next[index].id) return true; + return false; + }; + const bool changed = differs(history, history_rows) || differs(lines, live_rows); + if (changed && order_revision_ == (std::numeric_limits::max)()) + throw std::overflow_error("screen order revision exhausted"); + // Prepare the replacement storage before touching the published frame so + // an allocation failure cannot leave history and live rows disagreeing. + std::deque next_history(std::make_move_iterator(history_rows.begin()), + std::make_move_iterator(history_rows.end())); + std::vector next_live(std::make_move_iterator(live_rows.begin()), + std::make_move_iterator(live_rows.end())); + history.swap(next_history); + lines.swap(next_live); + if (changed) ++order_revision_; + ordered_ = ordered; + next_id_ = maximum + 1; + // The replaced frame may be shorter or narrower, and the old attachment + // points at a row that no longer exists. + row = std::clamp(row, 0, height() - 1); + column = std::clamp(column, 0, width() - 1); + invalidate_attachment(); } // Imported rows retain ordered unique identities; conflicts get a fresh identity and version. void normalize_identities(std::vector& retained) { @@ -255,6 +373,7 @@ class Screen { swap_active(next); if (hidden) inactive_->swap(*hidden); columns_ = columns; next_id_ = identity; + advance_order(ordered_); } void invalidate_attachment() { last_row = last_column = -1; } void move(int r, int c) { @@ -276,6 +395,7 @@ class Screen { if (history.size() > limit) { history.pop_front(); ++discarded; } } else ++discarded; lines.erase(lines.begin()); lines.push_back(make_line(width(), Cell{}, next_id_)); + advance_order(ordered_); } void put(uint32_t cp, const std::string& utf8) { const int cells = scalar_width(cp); diff --git a/experiments/core/text_document.hpp b/experiments/core/text_document.hpp index ac104aed..af6d74e1 100644 --- a/experiments/core/text_document.hpp +++ b/experiments/core/text_document.hpp @@ -43,10 +43,22 @@ class TextDocument { const auto value = meta(index); return {project_line(screen_.retained_line(index)), value.columns, value.joins_next, value.ending}; } - std::optional index_of(uint64_t identity) const { - const auto index = screen_.lower_bound_line(identity); - if (index == size() || screen_.retained_line(index).id != identity) return std::nullopt; - return index; + // Exact current rank: identity is not document order, so a missing row is + // reported as missing instead of snapping to a neighbour. + std::optional index_of(uint64_t identity) const { return screen_.locate(identity); } + // Owner structure revision, for callers that cache rank-derived state. + uint64_t order_revision() const { return screen_.order_revision(); } + // True while identities happen to be increasing in document order. Callers + // must not read this as "ids order the document"; it only permits bisects. + bool ordered() const { return screen_.order_known(); } + // Document-order comparison of two validated positions. Ids order nothing; + // the retained sequence does, so this stays correct for reordered rows. + std::optional compare(TextPosition a, TextPosition b) const { + if (!valid(a) || !valid(b)) return std::nullopt; + const auto first = *index_of(a.line_id), second = *index_of(b.line_id); + if (first != second) return first < second ? -1 : 1; + if (a.column != b.column) return a.column < b.column ? -1 : 1; + return 0; } bool valid(TextPosition value) const { if (value.epoch != epoch()) return false; diff --git a/experiments/core/text_search.hpp b/experiments/core/text_search.hpp index 5f285d77..2a3b8237 100644 --- a/experiments/core/text_search.hpp +++ b/experiments/core/text_search.hpp @@ -58,8 +58,14 @@ class TextSearch { return a.id == b.id && a.version == b.version && a.columns == b.columns && a.joins_next == b.joins_next && a.ending == b.ending; } - static bool before(const TextPosition& a, const TextPosition& b) { - return a.line_id < b.line_id || (a.line_id == b.line_id && a.column < b.column); + // Document order, never identity order: a retained row may hold a larger id + // but still sit earlier in the document. Callers pass positions they already + // validated, so a missing row means "cannot place it", not "not before". + static bool before(const TextDocument& doc, const TextPosition& a, const TextPosition& b) { + const auto first = doc.index_of(a.line_id), second = doc.index_of(b.line_id); + if (!first || !second) return false; + if (*first != *second) return *first < *second; + return a.column < b.column; } static bool equal(const TextPosition& a, const TextPosition& b) { return a == b; @@ -285,10 +291,18 @@ class TextSearch { if (document_epoch_ != doc.epoch() || index >= matches_.size()) return {}; const auto hit = matches_[index]; if (!doc.valid(hit.range.begin) || !doc.valid(hit.range.end)) return {}; - const auto first = std::lower_bound(rows_.begin(), rows_.end(), hit.range.begin.line_id, - [](const ScannedRow& row, uint64_t id) { return row.meta.id < id; }); - for (auto row = first; row != rows_.end() && row->meta.id <= hit.range.end.line_id; ++row) - if (!row_valid(doc, row->meta, row->successor_id)) return {}; + if (doc.ordered()) { + const auto first = std::lower_bound(rows_.begin(), rows_.end(), hit.range.begin.line_id, + [](const ScannedRow& row, uint64_t id) { return row.meta.id < id; }); + for (auto row = first; row != rows_.end() && row->meta.id <= hit.range.end.line_id; ++row) + if (!row_valid(doc, row->meta, row->successor_id)) return {}; + } else { + // Scanned rows follow document order while identities need not; a + // bisect on id would silently skip rows instead of failing. Validate + // the whole bounded scan record, which is at most the retained rows. + 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 {}; return hit; @@ -300,7 +314,7 @@ class TextSearch { for (int pass = 0; pass < (allow_wrap ? 2 : 1); ++pass) for (size_t offset = 0; offset < matches_.size(); ++offset) { const auto index = next ? offset : matches_.size() - 1 - offset; const auto hit = match(doc, index); if (!hit) continue; - const bool beyond = !anchor || (next ? before(*anchor, hit->range.begin) : before(hit->range.begin, *anchor)); + const bool beyond = !anchor || (next ? before(doc, *anchor, hit->range.begin) : before(doc, hit->range.begin, *anchor)); if ((pass == 0 && beyond) || (pass == 1 && !beyond)) return SearchNavigation{*hit, index, pass == 1}; } return {};