diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ab61248..4bdce628 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -187,6 +187,10 @@ add_executable(retained_columns_test experiments/core/retained_columns_test.cpp) target_link_libraries(retained_columns_test PRIVATE termforge_core) termforge_warnings(retained_columns_test) +add_executable(resize_reflow_addressing_test experiments/core/resize_reflow_addressing_test.cpp) +target_link_libraries(resize_reflow_addressing_test PRIVATE termforge_core) +termforge_warnings(resize_reflow_addressing_test) + add_executable(resize_edit_test experiments/core/resize_edit_test.cpp) target_link_libraries(resize_edit_test PRIVATE termforge_core) termforge_warnings(resize_edit_test) @@ -445,6 +449,8 @@ add_test(NAME retained_resize_caret_core COMMAND resize_caret_test) set_tests_properties(retained_resize_combining_core retained_resize_caret_core PROPERTIES TIMEOUT 10) add_test(NAME retained_columns_core COMMAND retained_columns_test) add_test(NAME retained_resize_address_core COMMAND resize_address_test --retained-columns) +add_test(NAME reflow_addressing_core COMMAND resize_reflow_addressing_test) +set_tests_properties(reflow_addressing_core PROPERTIES TIMEOUT 10) add_test(NAME retained_resize_edit_core COMMAND resize_edit_test) add_test(NAME retained_resize_pending_core COMMAND resize_pending_test) add_test(NAME retained_resize_hidden_core COMMAND resize_hidden_test) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 361ad496..687cbb03 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -16,6 +16,7 @@ | Qt / 固定 ConPTY 模式与正文 resize 差异 | [独立字面预期及失败运输证据](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md) | 模式阶段经公开 Qt 按键和原生网格核对;缩窄后原生 reflow 与核心物理裁剪不同步,独立运输捕获未见重排字节。失败诊断与模式回归分列,#29 的正文重排和完整 #27 不由基础尺寸通过代替 | | 固定 ConPTY resize 与输出机制 | [1.25.260930003 包身份及 pinned 源码核查](research/conpty-resize-output-2026-11-02.md) | 当前 0x18 是字宽,旧 resize quirk 已删除;signal 成功非完成屏障,内部 reflow 不保证重发整屏,DSR 光标同步不证明正文一致。静态解释不替代同次 Qt 动态证据或原版 oracle;本次未新增运行或改 flags | | 原版 resize 后寻址与自主核心字面诊断 | [独立直接协议样本](../research/ui/results/2026-11-02-resize-address-reference/README.md)、[默认裁剪路径的失败诊断](../experiments/core/results/2026-11-02-resize-address/README.md) | 四例自然追加、CR、LF、CUP 保留动作前观测,文档、DSR、显示坐标分别核对;第五例 EL1 保留原审计失败。默认 `Screen::resize` 历史诊断仅 16/64 阶段通过、退出 1;当时 `EXCLUDE_FROM_ALL`,现参与默认构建但 CTest 只注册另行验证的显式保留入口。旧红不设 `WILL_FAIL`,常规 CI 或另一入口通过不能豁免默认路径差异 | +| 缩窄后跨前方显示重排的 CUP/DSR 寻址(#27/#29 显式入口) | [单例原版四阶段严格记录](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)、[核心字面回放与身份](../experiments/core/results/2026-11-03-reflow-addressing/README.md):`CSI 8;2HQ` 写入文档行 6(保留的缩放前源片段行)而非重排行 5,DSR `8;3`、caret 同口径;窄屏矩形 (155,144) 恢复 (155,126) 证实显示页独立 +1 行重排 | 单例固定 ASCII、`91×20→61×20→91×20`、一个动作、最大一次运行;只补既有样本无法区分的行映射,不含备用屏、高度、Unicode、历史、样式、Qt/固定 ConPTY 或性能;默认 `Screen::resize`/Qt 未切换,#27/#29 不关闭 | | 自主 C++ 同 owner 保留列宽事务与有限编辑(#27/#29 独立入口) | [源码、定向红绿与限制](../experiments/core/results/2026-11-02-retained-columns/README.md):`a35be70` 的四个寻址 case 为 64/64 阶段;`fdeaed6` 使打印/ICH/DCH 按协议宽更新目标行,内容长度与 `written` 分离,三例编辑为 64/64 阶段。该固定源码重新构建后核心 22/22、Qt/固定 ConPTY 4/4,各配置身份独立记录 | 默认 resize / Qt 未切换;Qt 回归不执行新保留入口。Q/R 与 ICH/DCH 红绿分别保留,owner 存储及空白长度工程检查没有独立先行 RED。后续光标与隐藏帧修正另列,不继承本轮结果;默认裁剪与 B/CD 失败不豁免 | | 原版活动/隐藏帧与缩放后下一字符 | [6次原版、29阶段](../research/ui/results/2026-11-02-resize-frame-reference/README.md):5次原审计通过、1次隐藏主屏原审计失败后独立离线补充通过;无补跑。文档、DSR与显示分别记录 | 活动备用屏保留正文;隐藏主屏返回的正文和边界不同;4个ON/OFF缩放末列样本支持下一字符覆盖当前位置。原版Document索引差异、私有模式位及完整网格不据此判定 | | 自主 C++ 缩放后的末列输入与隐藏主屏 | [末列输入红绿](../experiments/core/results/2026-11-02-resize-pending/README.md):`9b67886` 的4例44/72→72/72;[隐藏主屏红绿](../experiments/core/results/2026-11-02-resize-hidden/README.md):`afdcf59` 的12/32→32/32,精确比较正文空白长度、源位置、显示与DSR | 定向实现均只走显式保留入口;默认 resize / Qt 尚未接入。隐藏主屏样本不能推出活动帧 margin、单纯隐藏增宽或完整 Unicode 语义;各报告区分固定生产源码、执行后身份与缺失的历史二进制 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a21020de..a58287e3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -33,7 +33,7 @@ [有界 Unicode Cell 显示投影](../experiments/core/results/2026-11-02-display-projection-unicode/README.md)保留完整宽字、已有组合标/样式、源身份和选择/Find范围,显示补白不创造源文字。[增量 `ProjectionJob`](../experiments/core/results/2026-11-02-projection-job/README.md)每步最多读200个源片段,保存未完成半行和页面,完整页才发布;500个单列片段实际按200/200/100步完成,跨步骤选择、复制与Find映射通过。每片段仍限500 Cell,每lead最多68字节/16个mark;不完整原子、trail起点及一列放不下宽字等请求拒绝。任务绑定owner、epoch、order、独立content revision与请求代次;旧单次`project`的source_limit可能停滞,需使用分步入口。源锚点不是全局显示行号,任意显示位置定位与持续输出下的进展保证仍待实现。**默认 `Screen::resize` 仍物理裁剪,Qt仍使用物理 `snapshot()`**;[B/CD缩放失败](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md)未修复,#27/#29保持开放。 -[同 owner 保留列宽事务](../experiments/core/results/2026-11-02-retained-columns/README.md)使用独立 `Screen::resize_columns_preserving` 入口:活动正文保留源 Cell、ID/version 与有效软续接,历史不被补齐或重写;普通打印、ICH/DCH 按当前协议宽度更新目标行,独立内容长度保留有意义的尾空白。实际列宽变化会[限制活动光标并取消延迟末列写入](../experiments/core/results/2026-11-02-resize-pending/README.md),并[复位两帧的滚动边界](../experiments/core/results/2026-11-02-resize-mode/README.md);[隐藏主屏](../experiments/core/results/2026-11-02-resize-hidden/README.md)还会裁剪 live 正文,不与活动正文采用同一保留策略。原版字面回放分别覆盖寻址、写入/插删、缩放后下一字符和隐藏主屏返回,各组身份与验证范围在对应报告中独立记录。 +[同 owner 保留列宽事务](../experiments/core/results/2026-11-02-retained-columns/README.md)使用独立 `Screen::resize_columns_preserving` 入口:活动正文保留源 Cell、ID/version 与有效软续接,历史不被补齐或重写;普通打印、ICH/DCH 按当前协议宽度更新目标行,独立内容长度保留有意义的尾空白。实际列宽变化会[限制活动光标并取消延迟末列写入](../experiments/core/results/2026-11-02-resize-pending/README.md),并[复位两帧的滚动边界](../experiments/core/results/2026-11-02-resize-mode/README.md);[隐藏主屏](../experiments/core/results/2026-11-02-resize-hidden/README.md)还会裁剪 live 正文,不与活动正文采用同一保留策略。原版字面回放分别覆盖寻址、写入/插删、缩放后下一字符和隐藏主屏返回,各组身份与验证范围在对应报告中独立记录。[缩窄后跨前方重排的 CUP/DSR 寻址原版样本](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)与[对应核心回放](../experiments/core/results/2026-11-03-reflow-addressing/README.md)确认协议行仍是保留的缩放前行、DSR/caret 同口径,显示页独立重排;仍只走显式入口,默认 `Screen::resize` 与 Qt 未切换。 [行擦除的五例字面回放](../experiments/core/results/2026-11-02-resize-erase/README.md)已覆盖末列/中间列的 EL0、EL2 和独立 EL1:EL0/EL2 从光标所在列起截去保留正文,EL1 保留已用范围中的擦除空格;完整回放由 56/96 修至 96/96。该限定结果不代替一般编辑、样式网格或全部帧组合验收。 diff --git a/experiments/core/README.md b/experiments/core/README.md index 377c56d9..b6529a44 100644 --- a/experiments/core/README.md +++ b/experiments/core/README.md @@ -4,7 +4,7 @@ 解析器提供默认 `strict` 和显式 `ParseMode::recovery` 两种模式。replay/receive、trace 默认值和共同子集差分使用严格模式,本地 shell 选择恢复模式;测试 trace 可用第七个参数 `--recovery` 比较恢复行为,或 `--resize=RxC` 在输入结束点 resize 并追加检查点。[恢复策略](RECOVERY.md)定义未知控制序列、非法 UTF-8、超长输入、取消及 EOF 的有界处理;下表未支持输入的失败规则适用于严格模式。 -`Screen::resize` 采用[物理行裁剪契约](../../docs/adr/0005-physical-row-resize.md)。`resize_test` 通过根 CTest 在 Windows/Linux 检查内容、历史、宽字符与字节流边界;本地 shell 将它与窗口/ConPTY 尺寸同步,replay CLI 不执行隐式 resize。显式保留入口 `resize_columns_preserving` 与 `resize_preserving` 不替换该默认契约:前者由 `retained_columns_core` 检查,后者的高度保留及五阶段组合回放见[组合保留结果](results/2026-11-02-retained-combined/README.md)。 +`Screen::resize` 采用[物理行裁剪契约](../../docs/adr/0005-physical-row-resize.md)。`resize_test` 通过根 CTest 在 Windows/Linux 检查内容、历史、宽字符与字节流边界;本地 shell 将它与窗口/ConPTY 尺寸同步,replay CLI 不执行隐式 resize。显式保留入口 `resize_columns_preserving` 与 `resize_preserving` 不替换该默认契约:前者由 `retained_columns_core` 检查,后者的高度保留及五阶段组合回放见[组合保留结果](results/2026-11-02-retained-combined/README.md);缩窄使前方行重排后继续 CUP 写入/DSR 的字面回放见[重排寻址结果](results/2026-11-03-reflow-addressing/README.md)(`reflow_addressing_core`)。 ## 支持范围和约定 diff --git a/experiments/core/resize_reflow_addressing_test.cpp b/experiments/core/resize_reflow_addressing_test.cpp new file mode 100644 index 00000000..18ec73d8 --- /dev/null +++ b/experiments/core/resize_reflow_addressing_test.cpp @@ -0,0 +1,212 @@ +// One independent WindTerm 2.7.0 observation: narrowing a 123-character line +// before the target rows shifts later display rows by one, while CUP, DSR and +// caret keep addressing the retained (pre-resize) source rows. The literal +// expectations come from research/ui/results/2026-11-03-reflow-addressing-reference; +// this exercises the explicit retained-width transaction, not default Screen::resize. +#include "terminal.hpp" +#include "history_view.hpp" +#include +#include +#include +#include +#include +#include + +using namespace termforge; + +struct Checks { + std::string context; + size_t failures = 0, assertions = 0, phases = 0, passed_phases = 0; + + void expect(bool condition, const std::string& message) { + ++assertions; + if (condition) return; + ++failures; + std::cerr << context << ": " << message << '\n'; + } +}; + +static void feed(Parser& parser, std::string_view bytes, bool bytewise) { + if (bytewise) { + for (const char byte : bytes) parser.feed(std::string_view(&byte, 1)); + } else { + parser.feed(bytes); + } +} + +// 222-byte fixed body: a 123-character soft-wrapped line on source rows 4-5, +// then single-row markers on source rows 6-10, cursor placed on source row 11. +static std::string initial_body() { + std::string body = "\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J"; + body += "\x1b[2;1H0123456789"; + body += "\x1b[3;1HANCHOR"; + body += "\x1b[5;1H" + std::string(122, 'r') + "v"; + body += "\x1b[7;1H5\x1b[8;1H6\x1b[9;1H7\x1b[10;1H8\x1b[11;1H9\x1b[12;1H"; + return body; +} + +static std::string reframe(size_t count) { + return std::string(count, 'r'); +} + +// Public document fragments joined only across real soft continuations. +static std::vector source_lines(const Screen& screen) { + TextDocument document(screen); + if (document.size() > 200) throw std::runtime_error("source observation exceeds its 200-fragment bound"); + std::vector result; + std::string logical; + for (size_t index = 0; index < document.size(); ++index) { + const auto line = document.line(index); + logical += line.projection.text; + if (!line.joins_next) { + result.push_back(std::move(logical)); + logical.clear(); + } + } + if (!logical.empty()) result.push_back(std::move(logical)); + return result; +} + +static std::string trimmed(std::string value) { + while (!value.empty() && value.back() == ' ') value.pop_back(); + return value; +} + +static std::vector display_lines(const ViewSnapshot& snapshot) { + std::vector result; + for (const auto& line : snapshot.lines) { + std::string text; + for (const auto& cell : line) text += cell.text; + result.push_back(trimmed(std::move(text))); + } + return result; +} + +using Rows = std::vector>; + +// Every non-empty line must be one of the literal expected rows and every +// expected row must be observed once; trailing padding is not compared. +static void check_rows(Checks& checks, const std::vector& lines, const Rows& expected, const char* space) { + std::vector seen(expected.size(), false); + for (size_t index = 0; index < lines.size(); ++index) { + if (lines[index].empty()) continue; + bool matched = false; + for (size_t position = 0; position < expected.size(); ++position) + if (expected[position].first == index) matched = expected[position].second == lines[index]; + checks.expect(matched, std::string(space) + " line " + std::to_string(index) + " content differs"); + for (size_t position = 0; position < expected.size(); ++position) + if (expected[position].first == index) seen[position] = true; + } + for (size_t position = 0; position < expected.size(); ++position) + checks.expect(seen[position], std::string(space) + " expected line " + + std::to_string(expected[position].first) + " is missing"); +} + +// Q and v are unique marker labels; assert their exact line/column occurrence. +static void check_marker(Checks& checks, const std::vector& lines, const std::string& label, + size_t row, size_t column, const char* space) { + size_t found = 0; + bool placed = false; + for (size_t index = 0; index < lines.size(); ++index) { + for (size_t offset = lines[index].find(label); offset != std::string::npos; + offset = lines[index].find(label, offset + 1)) { + ++found; + placed = index == row && offset == column; + } + } + checks.expect(found == 1 && placed, std::string(space) + " marker " + label + " position differs"); +} + +static void observe_phase(Checks& checks, Parser& parser, HistoryViewport& viewport, size_t phase, bool bytewise) { + const auto previous_failures = checks.failures; + const size_t columns = phase == 1 || phase == 2 ? 61 : 91; + checks.expect(parser.screen.width() == columns && parser.screen.height() == 20, + "actual protocol grid must match the observed phase dimensions"); + // Each original phase sends only its declared action followed by this DSR. + const char* replies[] = {"\x1b[12;1R", "\x1b[12;1R", "\x1b[8;3R", "\x1b[8;3R"}; + feed(parser, "\x1b[6n", bytewise); + checks.expect(parser.take_replies() == std::vector{replies[phase]}, + "DSR reply differs from the literal observed phase response"); + Rows source; + if (phase < 2) { + source = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(122) + "v"}, + {5, "5"}, {6, "6"}, {7, "7"}, {8, "8"}, {9, "9"}}; + } else { + source = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(122) + "v"}, + {5, "5"}, {6, "6Q"}, {7, "7"}, {8, "8"}, {9, "9"}}; + } + try { + check_rows(checks, source_lines(parser.screen), source, "source"); + } catch (const std::exception& error) { + checks.expect(false, std::string("source observation failed: ") + error.what()); + } + Rows display; + if (phase == 0) { + display = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(91)}, {5, reframe(31) + "v"}, + {6, "5"}, {7, "6"}, {8, "7"}, {9, "8"}, {10, "9"}}; + } else if (phase == 1) { + display = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(61)}, {5, reframe(61)}, {6, "v"}, + {7, "5"}, {8, "6"}, {9, "7"}, {10, "8"}, {11, "9"}}; + } else if (phase == 2) { + display = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(61)}, {5, reframe(61)}, {6, "v"}, + {7, "5"}, {8, "6Q"}, {9, "7"}, {10, "8"}, {11, "9"}}; + } else { + display = {{1, "0123456789"}, {2, "ANCHOR"}, {4, reframe(91)}, {5, reframe(31) + "v"}, + {6, "5"}, {7, "6Q"}, {8, "7"}, {9, "8"}, {10, "9"}}; + } + try { + const auto page = viewport.project(parser.screen, columns, 20, {0, 0}, parser.screen.retained_size()); + check_rows(checks, display_lines(page.snapshot), display, "display"); + // Independent visible-rectangle evidence: v moves by one display row when + // the earlier line reflows; Q stays on its document line and re-projects. + if (phase == 0) { + check_marker(checks, display_lines(page.snapshot), "v", 5, 31, "display"); + } else if (phase == 1) { + check_marker(checks, display_lines(page.snapshot), "v", 6, 0, "display"); + } else if (phase == 2) { + check_marker(checks, display_lines(page.snapshot), "v", 6, 0, "display"); + check_marker(checks, display_lines(page.snapshot), "Q", 8, 1, "display"); + } else { + check_marker(checks, display_lines(page.snapshot), "v", 5, 31, "display"); + check_marker(checks, display_lines(page.snapshot), "Q", 7, 1, "display"); + } + } catch (const std::exception& error) { + checks.expect(false, std::string("display projection failed: ") + error.what()); + } + ++checks.phases; + if (checks.failures == previous_failures) ++checks.passed_phases; +} + +static void replay(Checks& checks, ParseMode mode, bool bytewise) { + Parser parser(20, 91, 0, mode); + HistoryViewport viewport; + const auto body = initial_body(); + checks.expect(body.size() == 222, "fixed initial body differs from the frozen 222 bytes"); + const auto prefix = std::string(mode == ParseMode::strict ? "strict/" : "recovery/") + (bytewise ? "bytewise/" : "whole/"); + for (size_t phase = 0; phase < 4; ++phase) { + checks.context = prefix + "phase-" + std::to_string(phase); + if (phase == 0) feed(parser, body, bytewise); + else if (phase == 1) parser.screen.resize_columns_preserving(61); + else if (phase == 2) feed(parser, "\x1b[8;2HQ", bytewise); + else parser.screen.resize_columns_preserving(91); + // Keep all four observations even if an earlier phase failed. + observe_phase(checks, parser, viewport, phase, bytewise); + } + parser.finish(); +} + +int main() { + Checks checks; + for (const auto mode : {ParseMode::strict, ParseMode::recovery}) + for (const bool bytewise : {false, true}) { + try { + replay(checks, mode, bytewise); + } catch (const std::exception& error) { + checks.expect(false, std::string("replay failed: ") + error.what()); + } + } + std::cout << "retained reflow addressing: " << checks.passed_phases << '/' << checks.phases + << " phases, " << checks.failures << " failed assertions of " << checks.assertions + << " (4 observed phases x strict/recovery x whole/bytewise; no full-grid/style claim)\n"; + return checks.failures ? 1 : 0; +} diff --git a/experiments/core/results/2026-11-03-reflow-addressing/README.md b/experiments/core/results/2026-11-03-reflow-addressing/README.md new file mode 100644 index 00000000..d90e858c --- /dev/null +++ b/experiments/core/results/2026-11-03-reflow-addressing/README.md @@ -0,0 +1,45 @@ +# 缩窄后跨前方重排的 CUP/DSR 字面回放 + +[原版单例四阶段记录](../../../../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)在 `91×20 → 61×20 → 91×20` 中先于目标行写入一条 123 字符软折行;缩窄使后续显示行整体 +1,而保留的源片段行号不变。原版把 `CSI 8;2H Q` 的 Q 写到文档行 6(保留行口径),DSR 为 `8;3`,窄屏可见矩形 (155,144)、恢复后 (155,126)——显示页独立重排。 + +本切片把该观察固化为可重复的核心回放门禁:使用既有 `Screen::resize_columns_preserving` 与独立显示投影,逐阶段比较**协议网格、DSR、完整源逻辑行(真实软续接合并)与显示行/marker 位置**。**未修改任何生产源码**:现有同 owner 保留 + 投影表示已与本原版样本一致;默认 `Screen::resize` 与 Qt 仍走物理裁剪,未在本切片切换。 + +## 结果 + +| 验证 | 实际结果 | 口径 | +|---|---|---| +| 字面回放 `resize_reflow_addressing_test.exe` | **16/16 阶段、0/620 断言失败,退出 0** | strict/recovery × 整块/逐字节 × 4 阶段;每阶段检查网格、DSR、源逻辑行与显示行/marker | +| 核心 CTest | **35/35 通过,4.11 秒** | 新增 `reflow_addressing_core`;上一基线为 34/34,未改其他测试 | +| 生产源码变化 | **无**:`screen.hpp` SHA-256 与 main `0420137` 相同(`9107356…`),仅新增测试与 CMake 注册 | 不把"实现已一致"写成修复或验收 | + +首次门禁运行的 12/16 来自测试自身第 3 阶段预期分支写错(把已写入 Q 的阶段 3 当成动作前),不是生产 RED;保留该记录、修正测试后 16/16,没有跳过失败阶段或删减断言。620 条断言数与最终二进制身份见 [validation.json](validation.json)。 + +回放逐阶段字面预期(来自原版公开记录): + +| 阶段 | 网格 | DSR | 源行 6 | 显示行(v / Q) | +|---|---|---|---|---| +| 0 初始正文 | 91×20 | `12;1` | `6` | v (5,31) | +| 1 缩窄、动作前 | 61×20 | `12;1` | `6` | v (6,0) | +| 2 缩窄、CUP+Q | 61×20 | `8;3` | `6Q` | v (6,0),Q (8,1) | +| 3 恢复宽度 | 91×20 | `8;3` | `6Q` | v (5,31),Q (7,1) | + +源行/显示行均以零基记录;显示坐标按 8×18 单元、x=147 原点折算为原版公开矩形,回放只比较单元行列。源逻辑行按真实软续接合并后核对,保留正文 123 字符行在三种宽度下不丢失、不重复。 + +## 复现命令 + +```powershell +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release --target resize_reflow_addressing_test --parallel 4 +& build/bin/Release/resize_reflow_addressing_test.exe +ctest --test-dir build -C Release -R "^reflow_addressing_core$" --output-on-failure +ctest --test-dir build -C Release -R "_core$" --output-on-failure +``` + +构建/运行环境:Windows 11 x64、Visual Studio 17 2022 17.12.6、MSVC 19.42.34433.0、Windows SDK 10.0.26100.0、Release、Qt OFF、候选适配器 OFF。定向构建后收集源码与 EXE SHA-256,见 [validation.json](validation.json)。 + +## 边界与未完成 + +- 只覆盖单个固定 222 B ASCII 主屏样本、三组固定宽度与一个 CUP+Q 动作;不覆盖高度变化、备用/隐藏帧、连续 resize、在途输出、历史淘汰、宽字/结合字/Unicode、样式、margin/origin 或保存光标。 +- 只走显式 `resize_columns_preserving` 入口。默认 `Screen::resize`、正式 Qt 呈现、选择和 Find 的源位置迁移,以及 Qt 六阶段 B/CD 失败均未被本切片修复或豁免。 +- 原版样本仍是单例、最大一次运行;本页不声称原版内部存储结构、完整 reflow、WindTerm 等价或性能结果。 +- #27/#29 保持 OPEN;核心字面回放通过不替代原版、Qt/固定 ConPTY 或 R2 联合验收。 diff --git a/experiments/core/results/2026-11-03-reflow-addressing/validation.json b/experiments/core/results/2026-11-03-reflow-addressing/validation.json new file mode 100644 index 00000000..31347c70 --- /dev/null +++ b/experiments/core/results/2026-11-03-reflow-addressing/validation.json @@ -0,0 +1,68 @@ +{ + "schema": "termforge-reflow-addressing-validation-v1", + "date": "2026-11-03", + "status": "literal-reflow-addressing-replay-passed-no-production-change", + "default_resize_changed": false, + "qt_projection_integrated": false, + "r27_complete": false, + "r29_complete": false, + "main_base_commit": "0420137e9774b0add300e96a78b742f9331d564c", + "probe_source_commit": "2d270004afe77b82388a5d40f971c5892af5f41b", + "original_observation": "research/ui/results/2026-11-03-reflow-addressing-reference/README.md", + "original_runs_this_slice": 1, + "hashes_collected_after_runs": true, + "build": { + "platform": "Windows 11 x64", + "generator": "Visual Studio 17 2022 17.12.6", + "compiler": "MSVC 19.42.34433.0", + "windows_sdk": "10.0.26100.0", + "configuration": "Release", + "qt": false, + "candidate_adapter": false + }, + "source_files_sha256": { + "CMakeLists.txt": "dbde0f6ae929c826f718fe545a1f3f56dfd38a8db796dc95494c81d1190f3942", + "experiments/core/resize_reflow_addressing_test.cpp": "070814dc2f8b26cf514e9a8c6ce6291cbb6a0cb5ad8bc169d240732aa51f019b", + "experiments/core/screen.hpp": "910735615a453246e499f5fea07296b7e4b6451bc727bcd28324e3590b956769", + "experiments/core/terminal.hpp": "c1b537efc172ff446f129753b9a673ecb290716ee75e4505b81c371513acc8c7", + "experiments/core/history_view.hpp": "93f8c65363421d9b25996e1296fc0db0865cd716943ecdfd8db7b38ed9e4f0e3", + "experiments/core/text_document.hpp": "24f331c34b47e305a1706a2898309d6d0ea8267b87e09dd2b1a56a7f651ecc5d", + "experiments/core/text_projection.hpp": "b2d6dce33229f5d1ee738612c5e2f07dbbffa53f28693a2f03f3414782505cc7", + "experiments/core/view_mapping.hpp": "32ae3d1b2919d50d1614916e4c5e6bfb00f3e491e795287b9e10ae7f8f27c5d4" + }, + "binary_sha256": { + "resize_reflow_addressing_test.exe": "7945bd5ac925f59909c22906931eec9798617c5c52537ab10c3dd42038982ef7" + }, + "replay": { + "configurations": 4, + "phases": 16, + "final": { + "phases_passed": 16, + "literal_failures": 0, + "literal_assertions": 620, + "exit_code": 0 + }, + "initial_gate_authoring_error": { + "phases_passed": 12, + "literal_failures": 4, + "failure": "test-side phase-3 expectation branch treated the post-action phase as pre-action", + "product_red": false, + "note": "The retained replay itself matched the oracle; only the new test's expected rows were wrong. Corrected without removing assertions or skipping phases." + } + }, + "ctest": { + "core": { + "passed": 35, + "failed": 0, + "seconds": 4.11, + "includes": ["reflow_addressing_core"] + }, + "previous_core_count": 34 + }, + "limitations": [ + "Single fixed 222 B ASCII primary-screen sample, three widths, one CUP+Q action; no height change, alternate/hidden frame, continuous resize, in-flight output, history eviction, Unicode, styles, margin/origin or saved cursor cross.", + "Explicit resize_columns_preserving entry only; default Screen::resize and the formal Qt path still physically clip, and the Qt B/CD resize failure is neither fixed nor waived.", + "The original observation is one run with fixed geometry; it does not prove internal storage, complete reflow, WindTerm equivalence or any performance result.", + "The replay is a literal regression gate, not acceptance of #27/#29, R2 or the Qt/fixed-ConPTY user path." + ] +} diff --git a/research/ui/export_resize_addressing_reflow_reference.py b/research/ui/export_resize_addressing_reflow_reference.py new file mode 100644 index 00000000..050db93a --- /dev/null +++ b/research/ui/export_resize_addressing_reflow_reference.py @@ -0,0 +1,159 @@ +"""Strict offline publication for one fixed post-reflow CUP action and its baseline. + +The protected resize exporter supplies the package/source/protocol audit; this +module only changes the fixed action contract, the finite source-derived text +allowlist and the published schema. No target is executed here. +""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path, PureWindowsPath +import re +import sys +from types import FunctionType + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from research.ui import export_mode_state_reference as common +from research.ui import export_resize_state_reference as resize_export +from research.ui import resize_addressing_reflow_reference as probe + +require, sha, digest, integer = common.require, common.sha, common.digest, common.integer +MARKERS = (*resize_export.MARKERS, "Q") +RAW_NAMES = ("evidence.json", "run.json", "probe.js", "peer.json", probe.RESULT_FILE, "files.json", + "mechanics-check.json", "stderr.txt", "profile-check.json", "cleanup.json", "fixture-ready", + *(f"ack-{n}" for n in range(4)), *(f"resize-{kind}-{n}" for n in (1, 3) for kind in ("request", "ready"))) +EXPORT_SOURCES = ("research/ui/export_resize_addressing_reflow_reference.py", + "research/ui/export_resize_state_reference.py", + "research/ui/export_mode_state_reference.py") + + +def read(path): + require(path.stat().st_size <= 4*1024*1024, "Recording exceeds byte bound") + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate recording field") + result[key] = value + return result + def nonfinite(_): + raise ValueError("Nonfinite JSON value") + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique, parse_constant=nonfinite) + + +def extract(path, start, end): + source = path.read_text(encoding="utf-8") + require(source.count(start) == source.count(end) == 1, "Protected offline audit function boundary changed") + return start+source.split(start, 1)[1].split(end, 1)[0] + + +def expected_probe(plan, run, source_root): + integer(run["pid"], 2**32-1, 1); integer(run["tid"], 2**32-1, 1) + require(type(run["window_station"]) is str and re.fullmatch(r"TermForgeR1-[0-9]+", run["window_station"]), + "Owned station identity differs") + template = (source_root / "research/ui/qt_r1_text.js").read_text(encoding="utf-8").split("// Scenario driver", 1)[0] + driver = (source_root / probe.CONTROLLER).read_text(encoding="utf-8") + require(driver.count(probe.CONTROLLER_ANCHOR) == 1, "Case template anchor differs") + template += driver.replace(probe.CONTROLLER_ANCHOR, json.dumps(probe.validate_case(plan["case_id"]))) + require(hashlib.sha256(template.encode()).hexdigest() == run["template_sha256"], "Probe template differs") + generated = template.replace("__UI_THREAD_ID__", str(run["tid"])).replace("__OWNED_STATION__", run["window_station"]) + generated = generated.replace("__RUN_DIRECTORY_JSON__", json.dumps(PureWindowsPath(plan["out"]).as_posix())) + generated = generated.replace("__HOSTED_CI__", "false").replace("__SCENARIO_TIMEOUT_MS__", "43000") + return generated.encode().replace(b"\n", b"\r\n") + + +# The immutable existing helpers are adapted in fresh namespaces. Each textual +# boundary is checked, and all three offline source files are identified below. +_source = extract(Path(common.__file__), "def provenance_facts(", "\n\ndef export(") +_source = _source.replace("One original launch attempt; retain after failure and never retry this plan.", + probe.ATTEMPT.rstrip("\n")) +require(_source.count("probe.source_identity(root=source_root, source_files=probe.SOURCE_FILES)") == 1, + "Protected offline audit adapter changed") +_source = _source.replace("probe.source_identity(root=source_root, source_files=probe.SOURCE_FILES)", + "probe.common.source_identity(root=source_root, source_files=probe.SOURCE_FILES)") +_PROVENANCE = {**vars(common), "ROOT": ROOT, "probe": probe, "RAW_NAMES": RAW_NAMES, "read": read} +exec(compile(_source, str(Path(common.__file__)), "exec"), _PROVENANCE) +provenance_facts = _PROVENANCE["provenance_facts"] +marker_projection = FunctionType(common.marker_projection.__code__, {**vars(common), "MARKERS": MARKERS}) +geometry = FunctionType(resize_export.geometry.__code__, {**vars(resize_export), "probe": probe}) +_source = extract(Path(resize_export.__file__), "def capture_facts(", "\n\ndef export(") +for _old, _new in { + "probe.audit_mechanics(directory)": 'probe.audit_mechanics(directory, plan["case_id"])', + '"resize-state-result.json"': "probe.RESULT_FILE", + "6 <= len(messages) <= 69": "7 <= len(messages) <= 70", + '"scenario": "resize-state-v1"': '"scenario": "resize-address-v1", "case_id": plan["case_id"]', + "count = len(payloads)-5": "count = len(payloads)-6", + '"resize_state_phase"': '"resize_address_phase"', + '"resize_state_result"': '"resize_address_result"', + "for phase in (1, 2):": "for phase in (1, 3):", + 'probe.phase_records(grids[0][1], grids[0][0])': 'probe.phase_records(plan["case_id"], grids[0][1], grids[0][0])', + "synthetic_lines(grids[0][0])": 'probe.synthetic_lines(plan["case_id"], source["phase"])', + '"dsr_request_bytes": 12': '"dsr_request_bytes": 16', + 'enumerate(peer["resize_boundaries"], 1)': 'zip(probe.RESIZE_PHASES, peer["resize_boundaries"])', + '"phase_messages": 3': '"phase_messages": 4', +}.items(): + require(_source.count(_old) == 1, "Protected offline audit adapter changed: "+_old) + _source = _source.replace(_old, _new) +_CAPTURE = {**vars(resize_export), "ROOT": ROOT, "probe": probe, "read": read, + "expected_probe": expected_probe, "geometry": geometry, "marker_projection": marker_projection} +exec(compile(_source, str(Path(resize_export.__file__)), "exec"), _CAPTURE) + + +def capture_facts(directory, plan, source_root=ROOT): + result = _CAPTURE["capture_facts"](directory, plan, source_root) + raw = read(directory / probe.RESULT_FILE) + for index, (phase, observed) in enumerate(zip(result["phases"], raw["observations"])): + phase["stage_role"] = ("initial-baseline", "shrunk-before-action", "shrunk-after-action", "restored-after-action")[index] + lines = observed["terminal"]["lines"] + phase["document_projection"]["synthetic_lines_sha256"] = hashlib.sha256(json.dumps(lines, separators=(",", ":")).encode()).hexdigest() + result["pre_action_observation_count"] = 2 + result["declared_action_hex"] = probe.CASES[plan["case_id"]].hex() + return result + + +def export(directory, plan_path, destination, expected_plan_sha256, source_root=ROOT, reference_source=None): + directory, plan_path, destination, source_root = [Path(path).resolve() for path in (directory, plan_path, destination, source_root)] + require(not destination.exists(), "Export destination must be new") + require(sha(plan_path) == digest(expected_plan_sha256), "Authorized plan bytes differ") + plan = read(plan_path) + require(all(type(plan.get(k)) is type(v) and plan[k] == v for k, v in probe.fixed_contract(plan.get("case_id")).items()), + "Fixed reflow-addressing contract differs") + require(plan.get("execution_authorized") is True, "Plan did not authorize execution") + facts = capture_facts(directory, plan, source_root) + reference_source = Path(plan["source"]) if reference_source is None else Path(reference_source) + provenance = provenance_facts(directory, plan_path, plan, source_root, reference_source.resolve()) + result = {"schema": "termforge-original-resize-reflow-addressing-reference-v1", "issue": 29, "related_issue": 27, + "case_id": plan["case_id"], "authorized_plan_sha256": expected_plan_sha256, + "offline_export_source_files_sha256": {name: sha(ROOT / name) for name in EXPORT_SOURCES}, + "status": "bounded-direct-protocol-and-addressing-observation", "actual_original_runs": 1, "maximum_original_runs": 1, + "semantic_acceptance": False, "addressing_policy_classification": None, "full_active_grid_verified": False, + "terminal_styles_verified": False, "local_shell_transport_verified": False, "presented_done": None, + "performance_gate": "not_evaluated", "r2_complete": False, "rea_capture_seconds": 45, + "cli_wait_timeout_seconds": 60, "whole_lifecycle_bound_seconds": None, + "deliberate_original_process_termination": True, "natural_gui_exit_verified": False, + "capture": facts, "provenance": provenance, "limitations": [ + "One fresh direct Telnet process executes one CUP+Q action after the shrunk baseline has been observed and acknowledged.", + "The initial body contains a 123-character soft-wrapped line before the target rows, so narrowing shifts later display rows while retained source-fragment rows keep their numbering.", + "Four stages preserve both pre-action observations; only phases 1 and 3 resize, while phase 2 sends the exact declared CUP byte sequence.", + "Document markers, protocol DSR, caret API and visible rectangles remain different observations; no internal storage or addressing policy is inferred.", + "The finite text allowlist admits source-derived one-character writes without predicting the actually affected row.", + "Only 91x20 -> 61x20 -> 91x20 with fixed ASCII is covered; no full grid, styles, local-shell transport, alternate screen, height change, reliability repetition or performance acceptance.", + "Raw document text, paths, process identifiers, profiles, resources and provider diagnostics remain private."]} + payload = (json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)+"\n").encode() + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as stream: + stream.write(payload) + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + for name in ("directory", "plan", "destination"): + parser.add_argument("--"+name, type=Path, required=True) + parser.add_argument("--expected-plan-sha256", required=True) + parser.add_argument("--source-root", type=Path, default=ROOT) + parser.add_argument("--reference-source", type=Path) + args = parser.parse_args() + result = export(args.directory, args.plan, args.destination, args.expected_plan_sha256, args.source_root, args.reference_source) + print(json.dumps({"exported": True, "case_id": result["case_id"], "phases": len(result["capture"]["phases"]), "sha256": sha(args.destination)})) diff --git a/research/ui/resize_addressing_reflow_reference.md b/research/ui/resize_addressing_reflow_reference.md new file mode 100644 index 00000000..f5161914 --- /dev/null +++ b/research/ui/resize_addressing_reflow_reference.md @@ -0,0 +1,78 @@ +# 缩窄后跨前方重排的 CUP 寻址独立原版观察协议 + +本文件为 **运行前冻结** 的观察合同:先固定"能区分竞争解释的动作与独立预期",再对固定 WindTerm 2.7.0 取证一次;失败保留、不重跑、不改断言。承接 [三阶段正文保留观察](results/2026-11-02-resize-state-reference/README.md) 与 [缩窄后单动作寻址观察](results/2026-11-02-resize-address-reference/README.md),服务 #27/#29 与 [ADR-0010](../../docs/adr/0010-bounded-resize-retention.md)。本文件不是原版运行结果;正文中的"预期"是假设,不是用当前实现充当 oracle。 + +## 为什么需要新样本 + +已有寻址样本的正文只有**目标行自身**换行(`w/x` 位于底部逻辑行),行号层面"保留的源片段行"与"重排后的显示行"恰好同为第 11 行,因此只能区分列映射,不能区分行映射。若缩窄使**前方行**多占一行显示行,后续行的显示行号整体 +1,而保留的源片段行号不变,两种解释才会给出不同目标: + +| 解释 | CUP 行/列作用域 | +|---|---| +| **H-source(保留源片段/缩放前 91 列行网格)** | 协议行=保留源片段行,协议列=该行内列 | +| **H-display(当前 61 列重排后的显示网格)** | 协议行=当前显示行,协议列=该显示行内列 | + +## 固定条件 + +- 固定官方 WindTerm 2.7.0 x64(EXE SHA256 `7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0`,完整 4,047 文件清单 SHA256 `e459adf2f12adb6a1b7f55e66a9da1c22794185e66e848057d21e0c60d669203`),直接 Telnet,自有合成 profile 与自有 Job;不指向日常安装目录。 +- 窗口 `1360×749 → 1000×749 → 1000×749 → 1360×749`,NAWS `91×20 → 61×20 → 61×20 → 91×20`;高度不变。初始与 resize 冻结沿用连续 600 ms 稳定网格,单阶段观察沿用连续 400 ms 快照稳定。 +- 四阶段,每阶段末尾恰一个 `ESC [ 6 n` DSR 请求;**只有阶段 2** 有额外动作,字节精确为 `1b 5b 38 3b 32 48 51`(`CSI 8;2H Q`)。阶段 0/1 是动作前基线,阶段 3 只恢复宽度。 +- 每 case 一个新计划、一个新原版进程、期计划最多一次执行;执行前独占写 `.attempted`,失败保留。 + +## 固定正文与已知几何 + +初始正文(91×20)在同一份字节中写入: + +| 源行(0基) | 内容 | 说明 | +|---|---|---| +| 1 | `0123456789` | 稳定性 marker | +| 2 | `ANCHOR` | 稳定性 marker | +| 4 | `r`×122 + `v` | **前方重排行**:91 列时占源行 4–5(逻辑行 123 字符,软续接一轮);61 列时占显示行 4–6,因此后续显示行全部 +1 | +| 6 | `5` | 目标候选行(H-source 行号与 H-display 行号错位) | +| 7 | `6` | 目标候选行 | +| 8 | `7` | 目标候选行 | +| 9 | `8` | 目标候选行 | +| 10 | `9` | 目标候选行 | +| 11 | 空 | 用 `CSI 12;1H` 把协议光标放这里 | + +`v` 是重排行的末字符,宽屏显示位置 (395,90)(行 5、列 31),窄屏 (147,108)(行 6、列 0);两者差一行,正是"前方重排"的可观测证据。正文不含样式、Unicode、备用屏或历史操作。 + +## 竞争解释的独立预期(运行前固定) + +动作 `CSI 8;2H Q` 把 Q 写到协议第 8 行、第 2 列。窄屏重排后: + +| 可观测项 | H-source 预期 | H-display 预期 | +|---|---|---| +| 动作后 Q 所在文档逻辑行/列 | 行 6(`6` 行)、列 1 → 该行变 `6Q` | 行 5(`5` 行)、列 1 → 该行变 `5Q` | +| 动作后 DSR(源片段口径 +1) | `8;3` | `7;3` | +| 窄屏 Q 可见矩形左上角 | (155,144)(显示行 8,列 1) | (155,126)(显示行 7,列 1) | +| 恢复 91 列后 Q 可见矩形 | (155,126)(显示行 7) | (155,108)(显示行 6) | +| 阶段 1(只缩窄、动作前)DSR | `12;1`(源片段行 11 +1) | `13;1`(显示行 12 +1) | + +说明:H-source/H-display 是行号作用域的两种极端;DSR 自身也可能与寻址层不同源(`8;3` 在"源寻址+源 DSR"与"显示寻址+显示 DSR"下巧合相同,`9;3`/`7;3` 才分别表示混合口径)。因此**唯一裁决项是 Q 落在 `6` 行还是 `5` 行**(文档行列、marker 与矩形三处独立记录),DSR 与 caret 作为交叉核对,不单独判定。 + +其余在两种解释下相同的预期: + +| marker | 文档位置 | 宽屏矩形 | 窄屏矩形 | +|---|---|---|---| +| `0123456789` | 行 1,列 0…9 | y=18 | y=18 | +| `ANCHOR` | 行 2,列 0 | y=36 | y=36 | +| `v` | 行 4,列 122 | (395,90) | (147,108) | +| `5`/`6`/`7`/`8`/`9` | 行 5/6/7/8/9,列 0 | 宽屏 y=108/126/144/162/180 | 窄屏 y=126/144/162/180/198 | + +阶段 3 恢复 91 列后,`v` 与数字行应回到宽屏矩形;Q 保留在动作后所在文档位置。以上矩形基于已知 8×18 单元网格与 x=147 原点,沿用同一像素口径,不是完整 grid。 + +## 判读边界 + +- 任一白名单外文本、错误尺寸/网格、未武装 NAWS、过期快照、动作前缺 ack、额外回复或超界均失败;失败原件保留,不改断言、不重跑、不静默放宽。 +- 显示行号不能直接当协议行号;本样本只裁决上表动作目标,不证明内部存储结构,也不证明行号在其它宽度/高度/备用屏/历史/Unicode 下相同。 +- 观察结果与核心实现不一致时,保留 RED,再修复或如实记录差异;一致时只交付补齐的证据与门禁,不制造生产改动。 +- 本切片不豁免 Qt/固定 ConPTY 的用户路径缺口,不关闭 #27/#29,不声明完整 reflow、WindTerm 等价或性能结果。 + +## 工具与离线入口 + +```powershell +python -B -X utf8 -m unittest research.ui.test_resize_addressing_reflow_reference research.ui.test_export_resize_addressing_reflow_reference -v +node research/ui/test_resize_address_stage4.js research/ui/resize_address_reference.js +``` + +新工具:[resize_addressing_reflow_reference.py](resize_addressing_reflow_reference.py)(计划/对端/运行/机械审计)、[export_resize_addressing_reflow_reference.py](export_resize_addressing_reflow_reference.py)(严格离线发布)。两者复用既有受保护 peer、QT 模板与 REA 调用路径;本文件冻结时尚未运行原版。 diff --git a/research/ui/resize_addressing_reflow_reference.py b/research/ui/resize_addressing_reflow_reference.py new file mode 100644 index 00000000..51ecf078 --- /dev/null +++ b/research/ui/resize_addressing_reflow_reference.py @@ -0,0 +1,305 @@ +"""Four-stage, one-action CUP addressing observation across an earlier reflowing line. + +The initial body contains a 123-character soft-wrapped line *before* the target +rows. Narrowing 91 -> 61 columns makes that line occupy one extra display row, +which shifts later display rows by one while retained source-fragment rows keep +their original numbering. The single action `CSI 8;2H Q` therefore lands on the +source-fragment row 6 ("6") or the reflowed display row 5 ("5"), and composition +never runs a target. Preparation never starts the original program. +""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from research.ui import mode_state_reference as common +from research.ui import resize_state_reference as baseline + +require, sha = common.require, common.sha +qrect_size = baseline.qrect_size +EXE_SHA256, MANIFEST_SHA256 = common.EXE_SHA256, common.MANIFEST_SHA256 + +# 91x20 initial body: a 123-character line on source rows 4-5 (soft continuation), +# then single-row markers on source rows 6..10, ending with the cursor on row 11. +RESET = b"\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J" +REFLOW_LINE = b"r" * 122 + b"v" +BODY = (RESET + b"\x1b[2;1H0123456789\x1b[3;1HANCHOR" + b"\x1b[5;1H" + REFLOW_LINE + + b"\x1b[7;1H5\x1b[8;1H6\x1b[9;1H7\x1b[10;1H8\x1b[11;1H9\x1b[12;1H") +CASES = {"shrink-reflow-cup": b"\x1b[8;2HQ"} +WINDOWS = ((1360, 749), (1000, 749), (1000, 749), (1360, 749)) +GRIDS = ((91, 20), (61, 20), (61, 20), (91, 20)) +RESIZE_PHASES = (1, 3) +# The shared controller writes this file name and message kinds; both probes use +# the same qt_r1_text/resize_address_reference.js controller. +RESULT_FILE = "resize-address-result.json" +CONTROLLER = "research/ui/resize_address_reference.js" +CONTROLLER_ANCHOR = "__RESIZE_ADDRESS_CASE_JSON__" +ATTEMPT = "One original post-reflow addressing launch attempt; preserve failures and never retry this plan.\n" +SOURCE_FILES = ("research/ui/resize_addressing_reflow_reference.py", CONTROLLER, + "research/ui/resize_state_reference.py", "research/ui/mode_state_reference.py", + "research/ui/alternate_reference.py", "research/ui/r1_launch.py", "research/ui/launch.py", + "research/ui/qt_r1_text.js", "research/ui/r1_vt_fixture.py", "experiments/conpty/run.py") +BASE_LINES = ("", "0123456789", "ANCHOR", REFLOW_LINE.decode("ascii"), "5", "6", "7", "8", "9") + + +def validate_case(case): + require(type(case) is str and case in CASES, "Unknown fixed reflow-addressing case") + return case + + +def phases(case, rows=20, columns=91): + validate_case(case) + require(type(rows) is int and type(columns) is int and [columns, rows] == list(GRIDS[0]), "Fixed initial grid differs") + return [("initial-reflow-body", BODY), ("after-shrink-before-action", b""), + ("after-" + case, CASES[case]), ("after-restore-after-action", b"")] + + +def phase_records(case, rows=20, columns=91): + return [{"phase": index, "label": label, "source_hex": value.hex(), "source_bytes": len(value), + "source_sha256": hashlib.sha256(value).hexdigest(), "dsr_suffix_hex": "1b5b366e"} + for index, (label, value) in enumerate(phases(case, rows, columns))] + + +def peer_class(case): + validate_case(case) + base = baseline.peer_class() + # baseline creates a fresh module namespace for each class, not a shared peer. + base._serve.__globals__["phases"] = lambda rows, columns: phases(case, rows, columns) + + class ReflowPeer(base): + def _freeze_initial(self): + super()._freeze_initial() + require(self.state["frozen_grid"] == list(GRIDS[0]), "Fixed initial grid differs") + self.state["case_id"] = case + + def _phase_ready(self, index): + if index == -1: + return True + ack = self.directory / f"ack-{index}" + if not ack.exists() or self.state["records"][index]["cursor_reply"] is None: + return False + require(ack.read_text(encoding="utf-8") == "observed", "UI acknowledgement differs") + if index == 3: + return True + target = index + 1 + if target == 2: + require(self.state["resize_armed"] is None and self.state["frozen_grid"] == list(GRIDS[2]), + "Action was not released from the fixed shrunk grid") + self.state["frozen_grids"].append(list(self.state["frozen_grid"])) + self.state["freeze_event_counts"].append(len(self.state["naws_history"])) + return True + request = self.directory / f"resize-request-{target}" + if not request.exists(): + return False + require(json.loads(request.read_text(encoding="utf-8")) == {"phase": target, "window": list(WINDOWS[target])}, + "Resize request differs from immutable window sequence") + if self.state["resize_armed"] is None: + self.state["resize_armed"] = target + self.state["resize_boundaries"].append({"phase": target, "before_event_count": len(self.state["naws_history"]), + "requested_window": list(WINDOWS[target])}) + self._record() + return False + require(self.state["resize_armed"] == target, "Resize phase differs") + ready = self.directory / f"resize-ready-{target}" + if not ready.exists(): + return False + grid, history = [self.state["columns"], self.state["rows"]], self.state["naws_history"] + require(json.loads(ready.read_text(encoding="utf-8")) == + {"phase": target, "grid": grid, "event_count": len(history)}, "Resize ready snapshot is stale") + boundary = self.state["resize_boundaries"][-1] + require(len(history) > boundary["before_event_count"] and history[-1] == grid == list(GRIDS[target]), + "Fixed resized grid or NAWS boundary differs") + boundary["after_event_count"] = len(history) + self.state.update(frozen_grid=grid, resize_armed=None) + self.state["frozen_grids"].append(grid) + self.state["freeze_event_counts"].append(len(history)) + return True + return ReflowPeer + + +def source_identity(): + return common.source_identity(root=ROOT, source_files=SOURCE_FILES) + + +def probe_fragment(case): + validate_case(case) + source = (ROOT / CONTROLLER).read_text(encoding="utf-8") + require(source.count(CONTROLLER_ANCHOR) == 1, "Protected case template changed") + return source.replace(CONTROLLER_ANCHOR, json.dumps(case)) + + +def launcher_source(case): + validate_case(case) + source = (ROOT / "research/ui/r1_launch.py").read_text(encoding="utf-8") + replacements = { + "from research.ui.r1_vt_fixture import Peer": + "from research.ui.resize_addressing_reflow_reference import peer_class, probe_fragment\n Peer = peer_class("+repr(case)+")", + 'Path(__file__).with_name("r1_menu_wrap.js").read_text(encoding="utf-8")': "probe_fragment("+repr(case)+")", + } + for old, new in replacements.items(): + require(source.count(old) == 1, "Protected launcher source changed") + source = source.replace(old, new) + return source + + +def fixed_contract(case): + validate_case(case) + return {"schema": "termforge-resize-reflow-addressing-plan-v1", "issue": 29, "related_issue": 27, "case_id": case, + "maximum_original_runs": 1, "actual_original_runs": 0, "phase_count": 4, + "windows": [list(v) for v in WINDOWS], "required_grids": [list(v) for v in GRIDS], + "resize_phases": list(RESIZE_PHASES), "window_rect_format": "qt-qrect-inclusive-ltrb", + "rea_capture_seconds": 45, "cli_wait_timeout_seconds": 60, "whole_lifecycle_bound_seconds": None, + "phases": phase_records(case), "reference_exe_sha256": EXE_SHA256, + "reference_files_manifest_sha256": MANIFEST_SHA256, "semantic_acceptance": False, + "action_phase": 2, "pre_action_phases": [0, 1], "post_initial_terminal_input": + "DSR at every phase; exactly one declared CUP+Q action after the shrunk pre-action observation is acknowledged", + "scope": "Fresh direct Telnet process per case; document, DSR and public marker rectangles remain separate", + "stop": "Wrong fixed geometry/grid, unarmed NAWS, stale snapshots, source/reply/phase mismatch, bounds or deadline failure; no retries"} + + +def synthetic_lines(case, phase): + """Finite source-derived text candidates, not predictions of the original policy. + + The initial body plus append/replace of the single action marker at a bounded + column of one fixed line; the reflowing 123-character line keeps its logical + text because only the display projection changes width. + """ + validate_case(case) + require(type(phase) is int and 0 <= phase < 4, "Phase bound") + originals = set(BASE_LINES) + if phase < 2: + return originals + result = set(originals) + for line in originals: + for column in range(len(line)+1): + result.add(line[:column].ljust(column)+"Q"+line[column+1:]) + return result + + +def validate_document(case, phase, terminal): + require(type(terminal.get("line_count")) is int and 0 <= terminal["line_count"] <= 200 and + type(terminal.get("lines")) is list and len(terminal["lines"]) == terminal["line_count"], "Document bound") + require(all(type(line) is str and line in synthetic_lines(case, phase) for line in terminal["lines"]), + "Only fixed action-derived synthetic document lines are allowed") + require(sum(line.count("Q") for line in terminal["lines"]) <= (0 if phase < 2 else 1), + "Repeated or premature action marker") + + +def audit_mechanics(directory, case): + validate_case(case) + read = lambda name: json.loads((directory / name).read_text(encoding="utf-8")) + require((directory / "stderr.txt").read_bytes() == b"" and not (directory / "launcher-failure.json").exists(), + "REA stderr/launcher failure retained") + peer, result, cleanup, profile = [read(name) for name in ("peer.json", RESULT_FILE, "cleanup.json", "profile-check.json")] + require(all(peer.get(k) is True for k in ("connected", "closed", "complete")) and not peer.get("error") and peer.get("case_id") == case, + "Reflow peer did not complete cleanly") + require(result.get("status") == "observed-four-resize-address-states" and result.get("case_id") == case and result.get("error") is None and + result.get("window_rect_format") == "qt-qrect-inclusive-ltrb" and result.get("presented_done") is None and + result.get("full_active_grid_verified") is False and result.get("local_shell_transport_verified") is False, + "Reflow UI observation incomplete") + require(type(cleanup.get("active")) is int and cleanup["active"] == 0 and + all(type(cleanup.get(k)) is int and cleanup[k] >= 0 for k in ("total", "terminated")) and + profile == {"only_synthetic_session": True, "session_count": 1}, "Owned cleanup/profile differs") + grids, freezes, history = peer["frozen_grids"], peer["freeze_event_counts"], peer["naws_history"] + require(grids == [list(v) for v in GRIDS] and len(freezes) == 4 and type(history) is list and 1 <= len(history) <= 64 and + all(type(grid) is list and len(grid) == 2 and all(type(n) is int and 0 <= n <= 65535 for n in grid) for grid in history) and + all(type(n) is int and 1 <= n <= len(history) for n in freezes), "Fixed grids/freeze/NAWS bounds differ") + require(len(peer["resize_boundaries"]) == 2 and freezes[1] <= freezes[2], "Resize boundary count differs") + for index, boundary in zip(RESIZE_PHASES, peer["resize_boundaries"]): + before, after = boundary["before_event_count"], boundary["after_event_count"] + require(type(before) is int and type(after) is int and boundary["phase"] == index and + boundary["requested_window"] == list(WINDOWS[index]) and freezes[index-1] <= before < after == freezes[index] <= len(history) and + all(grid == grids[index-1] for grid in history[freezes[index-1]-1:before]) and + all(grid[1] == 20 for grid in history[before:after]) and history[after-1] == grids[index], "NAWS resize boundary differs") + require(all(grid == grids[1] for grid in history[freezes[1]-1:freezes[2]]) and + all(grid == grids[3] for grid in history[freezes[3]-1:]), "Unarmed action/final NAWS change retained") + records, observations, checks = peer["records"], result["observations"], result["geometry_checks"] + require(len(records) == len(observations) == len(checks) == 4 and peer["phase"] == 3 and peer["phase_count"] == 4, + "Four phases incomplete") + replies = bytearray() + for index, (expected, actual, observed, check) in enumerate(zip(phase_records(case), records, observations, checks)): + grid, window = list(GRIDS[index]), list(WINDOWS[index]) + require(all(actual.get(k) == v for k, v in expected.items()) and actual["frozen_grid"] == grid, "Exact action source differs") + raw = bytes.fromhex(actual["response_hex"]); reply = common.reply_observation(raw) + require(reply is not None and all(actual.get(k) == v for k, v in reply.items()), "DSR differs") + require(all(observed.get(k) == actual[k] for k in ("phase", "label", "frozen_grid", *reply)), "Peer/UI phase differs") + require(check.get("phase") == index and check.get("expected_size") == window and check.get("window_size") == window and + baseline.qrect_size(check.get("window_rect")) == window and observed.get("window_size") == window and + baseline.qrect_size(observed.get("window_rect")) == window, "Exact geometry differs") + terminal = observed["terminal"] + require(terminal.get("connected") is True and terminal.get("protocol") == "Telnet" and + type(terminal.get("capacity")) is int and terminal["capacity"] == 20, "UI binding differs") + validate_document(case, index, terminal) + require(type(terminal.get("markers")) is list and len(terminal["markers"]) <= 256, "Marker bound") + require((directory / f"ack-{index}").read_text(encoding="utf-8") == "observed", "Phase acknowledgment missing") + replies.extend(raw) + require(peer["received_hex"] == replies.hex(), "Unassigned reply bytes retained") + return {"schema": "termforge-resize-reflow-addressing-mechanics-v1", "case_id": case, "stage_count": 4, "dsr_count": 4, + "pre_action_stage_count": 2, "frozen_grids": grids, "naws_events": len(history), "job_active": 0, + "semantic_acceptance": False, "presented_done": None} + + +def prepare_plan(source, out, rea, destination, case, execution_authorized=False): + validate_case(case) + source, out, rea, destination = [Path(path).resolve() for path in (source, out, rea, destination)] + require(type(execution_authorized) is bool, "Execution authorization must be boolean") + require(not out.exists() and not destination.exists(), "Plan/output must be new") + require(out != ROOT / "local" and out.is_relative_to(ROOT / "local") and destination.is_relative_to(ROOT / "local"), + "Plan/output must belong to this worktree local directory") + require(not out.is_relative_to(source) and not source.is_relative_to(out), "Source/output overlap") + require(sha(source / "WindTerm.exe") == EXE_SHA256, "Pinned original executable differs") + plan = {**fixed_contract(case), **source_identity(), **common.host_identity(), "execution_authorized": execution_authorized, + "source": str(source), "out": str(out), "rea": str(rea), "rea_prepared_identity": common.rea_identity(rea), + "launcher_source_sha256": hashlib.sha256(launcher_source(case).encode()).hexdigest()} + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(plan, indent=2)+"\n") + return plan + + +def run_plan(path): + path = Path(path).resolve(); plan = json.loads(path.read_text(encoding="utf-8")) + require(plan.get("execution_authorized") is True, "Offline proposal only; execution is not authorized") + case = validate_case(plan.get("case_id")) + require(all(type(plan.get(k)) is type(v) and plan[k] == v for k, v in fixed_contract(case).items()), "Fixed addressing contract differs") + require(all(plan.get(k) == v for k, v in source_identity().items()), "Address source identity changed") + require(all(plan.get(k) == v for k, v in common.host_identity().items()), "Execution host/runtime differs") + require(plan["launcher_source_sha256"] == hashlib.sha256(launcher_source(case).encode()).hexdigest(), "Launcher source differs") + source, out, rea = [Path(plan[k]).resolve() for k in ("source", "out", "rea")] + require(path.is_relative_to(ROOT / "local") and out != ROOT / "local" and out.is_relative_to(ROOT / "local") and + not out.exists(), "Address run plan/output is not new/local") + require(not out.is_relative_to(source) and not source.is_relative_to(out), "Source/output overlap") + require(plan["rea_prepared_identity"] == common.rea_identity(rea), "Prepared REA entry changed") + require(sha(source / "WindTerm.exe") == EXE_SHA256, "Pinned original executable differs") + with path.with_suffix(".attempted").open("x", encoding="utf-8") as marker: + marker.write(ATTEMPT) + namespace = {"__file__": str(ROOT / "research/ui/r1_launch.py"), "__name__": "termforge_resize_reflow_addressing_launcher"} + exec(compile(launcher_source(case), namespace["__file__"], "exec"), namespace) + namespace["cli_command"] = lambda _: [plan["rea_prepared_identity"]["node_executable"], str(rea.parent.parent / "rea-agents/scripts/rea.mjs")] + namespace["run"](source, out, rea, 45, False, "menu-wrap") + require(all(plan.get(k) == v for k, v in source_identity().items()), "Address source identity changed during run") + mechanics = audit_mechanics(out, case) + (out / "mechanics-check.json").write_text(json.dumps(mechanics, indent=2)+"\n", encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", type=Path, required=True) + parser.add_argument("--prepare", action="store_true") + parser.add_argument("--case", choices=list(CASES)) + parser.add_argument("--authorize-execution", action="store_true") + for name in ("source", "out", "rea"): + parser.add_argument("--"+name, type=Path) + args = parser.parse_args() + if args.prepare: + require(args.source and args.out and args.rea and args.case, "Source/output/REA/case required") + prepare_plan(args.source, args.out, args.rea, args.plan, args.case, args.authorize_execution) + print(json.dumps({"prepared": True, "case_id": args.case, "phase_count": 4, "execution_authorized": args.authorize_execution})) + else: + require(not args.authorize_execution and args.case is None and args.source is None and args.out is None and args.rea is None, + "Execution uses only the immutable plan") + run_plan(args.plan) diff --git a/research/ui/results/2026-11-03-reflow-addressing-reference/README.md b/research/ui/results/2026-11-03-reflow-addressing-reference/README.md new file mode 100644 index 00000000..3d9e7701 --- /dev/null +++ b/research/ui/results/2026-11-03-reflow-addressing-reference/README.md @@ -0,0 +1,79 @@ +# WindTerm 2.7.0:缩窄后跨前方重排的 CUP 寻址观察 + +本报告承接 [固定正文三阶段观察](../2026-11-02-resize-state-reference/README.md)、[缩窄后单动作寻址观察](../2026-11-02-resize-address-reference/README.md)及 [ADR-0010 同 owner 正文保留方案](../../../../docs/adr/0010-bounded-resize-retention.md),服务 #27/#29。既有寻址样本的换行只发生在目标行自身,无法区分"协议行号跟随保留的源片段行"与"跟随当前重排后的显示行";本切片补上这一缺口。 + +**结论(有界)**:一次独立原版运行、四个阶段、一个 CUP+Q 动作。原审计与严格导出通过。动作 `CSI 8;2H Q` 写入**保留的源片段行**:Q 文档位置 (逻辑行 6, 列 1),其所在行与 `6` 同一逻辑行;DSR 为 `8;3`、`caretLine` 为 6,与保留行口径一致。窄屏可见矩形 (155,144) 与恢复后 (155,126) 说明显示层独立重排(前方 123 字符行在窄屏多占一行,后续显示行整体 +1)。因此协议/光标/DSR 寻址层与显示投影层分离,显示行号不能当协议行号。 + +## 预注册与判读边界 + +动作与两种竞争解释的独立预期在运行前冻结于[观察协议](../../resize_addressing_reflow_reference.md)(源码基点 `2d27000`),包括:H-source(保留的缩放前 91 列行)预期 Q 在文档行 6、DSR `8;3`、窄屏矩形 (155,144)、恢复 (155,126)、缩窄后动作前 DSR `12;1`;H-display(当前 61 列重排显示)预期 Q 在文档行 5、DSR `7;3`、窄屏 (155,126)、恢复 (155,108)、动作前 DSR `13;1`。严格导出器的有限文本白名单对两种候选目标行都可发布,不预设结论;实际结果由独立 marker/DSR 记录裁决。 + +## 固定条件 + +- 固定官方 WindTerm 2.7.0 x64,EXE SHA256 `7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0`,完整 4,047 文件清单 SHA256 `e459adf2f12adb6a1b7f55e66a9da1c22794185e66e848057d21e0c60d669203`;直接 Telnet,自有合成 profile 与自有 Job;不指向日常安装目录。 +- Windows VM `10.0.26300`,REA `6.7.0`/Frida,Python `3.12.10`。精确窗口 `1360×749 → 1000×749 → 1000×749 → 1360×749`,NAWS `91×20 → 61×20 → 61×20 → 91×20`;高度不变。 +- 四阶段各一个 `ESC [ 6 n`;只有阶段 2 追加动作字节 `1b5b383b324851`(`CSI 8;2H Q`)。阶段 0/1 为动作前基线,阶段 3 只恢复宽度。 +- 初始正文 222 B:源行 4 起写入 123 字符 `r`×122+`v`(91 列下占源行 4–5,软续接一轮;61 列下占显示行 4–6,使后续显示行 +1),源行 6–10 各写 `5`…`9`,`CSI 12;1H` 把协议光标放到零基行 11。 +- 计划只执行一次:`maximum_original_runs=1`、`actual_original_runs=1`;`.attempted` 独占写入,失败保留。 + +## 直接观察 + +DSR 为一基协议坐标;文档行/列为零基逻辑行与字符偏移;矩形沿用观测像素 `x,y`(8×18 单元、x=147 原点)。三种坐标分别记录,不互相替代。 + +| 阶段 | 尺寸/网格 | DSR | Q 文档位置 | v 矩形 | 5…9 所在显示行 | +|---|---|---|---|---|---| +| 0 初始正文 | 1360×749,91×20 | `12;1` | — | (395,90) | 6,7,8,9,10 | +| 1 缩窄后、动作前 | 1000×749,61×20 | `12;1` | — | (147,108) | 7,8,9,10,11 | +| 2 缩窄后 CUP+Q | 1000×749,61×20 | `8;3` | (6,1),`6Q` | (147,108) | 8,9,10,11,12(Q 在行 8) | +| 3 恢复宽度后 | 1360×749,91×20 | `8;3` | (6,1),`6Q` | (395,90) | 6,7,8,9,10(Q 在行 7) | + +动作后 caret API 为 `caretLine=6, caretPosition=148`(Q position 147 之后)。阶段 1 文档 `line_count` 为 13、阶段 2 为 12、阶段 3 为 11;这里只记录原版 API 返回,不据此推断内部存储或行计数策略。 + +与预注册预期逐项比较: + +| 项 | H-source 预期 | H-display 预期 | 实测 | +|---|---|---|---| +| Q 文档行/列 | (6,1) | (5,1) | **(6,1)** | +| 动作后 DSR | `8;3` | `7;3` | **`8;3`** | +| 窄屏 Q 矩形 | (155,144) | (155,126) | **(155,144)** | +| 恢复后 Q 矩形 | (155,126) | (155,108) | **(155,126)** | +| 缩窄后动作前 DSR | `12;1` | `13;1` | **`12;1`** | + +H-display 的每一个可区分预期都被拒绝:若协议行号跟随重排显示,阶段 2 的 Q 应落在 `5` 行并把已有 `9` 的显示行号整体前移;实际 Q 与 `6` 同逻辑行,且显示矩形按独立投影重排。动作前 DSR 在缩窄后仍为 `12;1`,说明光标/DSR 也与保留行一致,而不是随可见行 +1。 + +## 与工程表示的关系 + +观测支持现有同 owner 路线:保留源片段的 Cell、身份与软续接不因宽度变化而重排;显示页由独立投影按当前宽度生成;CUP、DSR、caret 继续在保留的源片段/文档位置口径上工作。因此 `Screen::resize_columns_preserving` 之后继续 CUP 写入的映射应是"协议行=保留源片段行、协议列=该行内列",而不是当前显示行。该结论只覆盖本样本的动作与坐标层,**不**证明原版内部采用相同数据结构,也不证明行号在其它宽度、高度、备用屏、历史或 Unicode 下相同。 + +## 身份与文件 + +| 项 | 值 | +|---|---| +| 执行源码基点 | `2d270004afe77b82388a5d40f971c5892af5f41b`(本切片预注册提交) | +| 执行源码清单 | 10 个文件,哈希见公开 JSON `provenance.execution_source_files_sha256`;probe `da14d2df…`、控制器 `e431bf4b…` | +| 授权计划 SHA256 | `19006fea24acacdb8d1d87439f17b5b1c3459bf223b74d45c0280bf8fe6469d0` | +| 公开 JSON | [shrink-reflow-cup.json](shrink-reflow-cup.json),SHA256 `1bb625177dc7001f54659b0401504c1f32650877d2838845ae95734dd7723319`,36,249 B | +| REA / provider | `6.7.0` / frida,evidence `ev_2df126455c1f3e0f32b1153c641bd2463e110f92fae0906153156dbc0c773b1c`,9 条消息(3 readiness + 4 phase + 结果) | +| 运行身份 | `run.json` 与 `probe.js` SHA256 分别为 `e4317e80…`、`90f1d9a7…`;窗口 station `TermForgeR1-6684` | +| 回收 | `job_after.active=0`,主动终止原版进程;不证明自然 GUI 退出 | + +公开 JSON 由严格导出器在原始记录上核验计划、源码归档、完整 4,047 文件原包与模块、provider 消息、四阶段 DSR/几何、动作前 ack、marker 与有限文本白名单后生成;不发布原文全文、路径、PID、profile 或原始资源。 + +## 运行尝试记录 + +- 首个请求 `r29-reflow-shrink-reflow-cup-0` 在计划创建**之前**失败:新增的探针预检行在 PowerShell 引号处理上出错,源目录虽已解压但未写计划、未写 `.attempted`、未启动原版。该失败状态保留在 guest,不记为原版运行。 +- 修正为文件式预检后,`r29-reflow-shrink-reflow-cup-1` 执行唯一一次原版运行(计划 `19006f…`);捕获 96 s 内完成并通过严格导出。没有重跑同一计划。 + +## 边界 + +- 单例、固定 ASCII、单主屏、固定 `91×20 → 61×20 → 91×20`、一个 CUP+Q 动作;不含完整网格/样式、连续尺寸、在途输出、历史淘汰、备用/隐藏帧、宽字/结合字、Unicode、margin/origin、保存光标或 Qt/固定 ConPTY 用户路径。 +- 不据此修改默认 `Screen::resize`、正式 Qt 路径或 Qt 六阶段 B/CD 失败身份;不关闭 #27/#29,不声明完整 reflow、WindTerm 等价或任何性能结果。 +- 核心回放门禁与身份另见 [核心结果](../../../../experiments/core/results/2026-11-03-reflow-addressing/README.md);本报告只记录原版观察。 + +## 离线复验 + +```powershell +python -B -X utf8 -m unittest research.ui.test_resize_addressing_reflow_reference research.ui.test_export_resize_addressing_reflow_reference -v +``` + +该命令只运行内存/合成替身与严格导出测试,不启动原版、不建立 socket、不附加 REA;通过不证明原版已运行或行为一致。 diff --git a/research/ui/results/2026-11-03-reflow-addressing-reference/shrink-reflow-cup.json b/research/ui/results/2026-11-03-reflow-addressing-reference/shrink-reflow-cup.json new file mode 100644 index 00000000..768ca3a3 --- /dev/null +++ b/research/ui/results/2026-11-03-reflow-addressing-reference/shrink-reflow-cup.json @@ -0,0 +1,1328 @@ +{ + "schema": "termforge-original-resize-reflow-addressing-reference-v1", + "issue": 29, + "related_issue": 27, + "case_id": "shrink-reflow-cup", + "authorized_plan_sha256": "19006fea24acacdb8d1d87439f17b5b1c3459bf223b74d45c0280bf8fe6469d0", + "offline_export_source_files_sha256": { + "research/ui/export_resize_addressing_reflow_reference.py": "24cc8db5cdcfc9b9cca15fc92fcae56102ce45ed3aa9d0fc8b4fe36a57b6e8a8", + "research/ui/export_resize_state_reference.py": "473e901305354d4ba8b22c08d22fa506c4d40da0e73bed3bf3122e4ff0e3c9b7", + "research/ui/export_mode_state_reference.py": "7e0a1e0e49f149791464c0dec357d3b5260bc2c34ca9f9c1be4edab35626cdf3" + }, + "status": "bounded-direct-protocol-and-addressing-observation", + "actual_original_runs": 1, + "maximum_original_runs": 1, + "semantic_acceptance": false, + "addressing_policy_classification": null, + "full_active_grid_verified": false, + "terminal_styles_verified": false, + "local_shell_transport_verified": false, + "presented_done": null, + "performance_gate": "not_evaluated", + "r2_complete": false, + "rea_capture_seconds": 45, + "cli_wait_timeout_seconds": 60, + "whole_lifecycle_bound_seconds": null, + "deliberate_original_process_termination": true, + "natural_gui_exit_verified": false, + "capture": { + "phases": [ + { + "phase": 0, + "label": "initial-reflow-body", + "source_hex": "1b5b3f366c1b5b3f37681b5b721b5b306d1b5b324a1b5b323b3148303132333435363738391b5b333b3148414e43484f521b5b353b31487272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272761b5b373b3148351b5b383b3148361b5b393b3148371b5b31303b3148381b5b31313b3148391b5b31323b3148", + "source_bytes": 222, + "source_sha256": "094c07cb3cdcc5e8c6845f3755cb030521fae7b5844c6a1398545a39c532f14d", + "dsr_suffix_hex": "1b5b366e", + "cursor_reply": [ + 12, + 1 + ], + "response_hex": "1b5b31323b3152", + "private_dsr_prefix": false, + "frozen_grid": [ + 91, + 20 + ], + "window_rect": [ + 102, + 102, + 1461, + 850 + ], + "window_size": [ + 1360, + 749 + ], + "document_projection": { + "line_count": 12, + "capacity": 20, + "top": null, + "scroll": 0, + "caret_api": { + "caretLine": 10, + "caretColumn": 0, + "caretPosition": 154 + }, + "markers": [ + { + "label": "0", + "document_line": 1, + "column": 0, + "position": 1, + "rect": { + "x": 147, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "1", + "document_line": 1, + "column": 1, + "position": 2, + "rect": { + "x": 155, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "2", + "document_line": 1, + "column": 2, + "position": 3, + "rect": { + "x": 163, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "3", + "document_line": 1, + "column": 3, + "position": 4, + "rect": { + "x": 171, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "4", + "document_line": 1, + "column": 4, + "position": 5, + "rect": { + "x": 179, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 1, + "column": 5, + "position": 6, + "rect": { + "x": 187, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 1, + "column": 6, + "position": 7, + "rect": { + "x": 195, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 1, + "column": 7, + "position": 8, + "rect": { + "x": 203, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 1, + "column": 8, + "position": 9, + "rect": { + "x": 211, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 1, + "column": 9, + "position": 10, + "rect": { + "x": 219, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "ANCHOR", + "document_line": 2, + "column": 0, + "position": 12, + "rect": { + "x": 147, + "y": 36, + "width": 8, + "height": 18 + } + }, + { + "label": "v", + "document_line": 4, + "column": 122, + "position": 142, + "rect": { + "x": 395, + "y": 90, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 5, + "column": 0, + "position": 144, + "rect": { + "x": 147, + "y": 108, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 6, + "column": 0, + "position": 146, + "rect": { + "x": 147, + "y": 126, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 7, + "column": 0, + "position": 148, + "rect": { + "x": 147, + "y": 144, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 8, + "column": 0, + "position": 150, + "rect": { + "x": 147, + "y": 162, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 9, + "column": 0, + "position": 152, + "rect": { + "x": 147, + "y": 180, + "width": 8, + "height": 18 + } + } + ], + "synthetic_lines_sha256": "7cb5b1c3e8e0b5effbc845e5beda9f811d76c48e1dce10d38b9d60f78648da0c" + }, + "stage_role": "initial-baseline" + }, + { + "phase": 1, + "label": "after-shrink-before-action", + "source_hex": "", + "source_bytes": 0, + "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dsr_suffix_hex": "1b5b366e", + "cursor_reply": [ + 12, + 1 + ], + "response_hex": "1b5b31323b3152", + "private_dsr_prefix": false, + "frozen_grid": [ + 61, + 20 + ], + "window_rect": [ + 102, + 102, + 1101, + 850 + ], + "window_size": [ + 1000, + 749 + ], + "document_projection": { + "line_count": 13, + "capacity": 20, + "top": null, + "scroll": 0, + "caret_api": { + "caretLine": 10, + "caretColumn": 0, + "caretPosition": 154 + }, + "markers": [ + { + "label": "0", + "document_line": 1, + "column": 0, + "position": 1, + "rect": { + "x": 147, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "1", + "document_line": 1, + "column": 1, + "position": 2, + "rect": { + "x": 155, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "2", + "document_line": 1, + "column": 2, + "position": 3, + "rect": { + "x": 163, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "3", + "document_line": 1, + "column": 3, + "position": 4, + "rect": { + "x": 171, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "4", + "document_line": 1, + "column": 4, + "position": 5, + "rect": { + "x": 179, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 1, + "column": 5, + "position": 6, + "rect": { + "x": 187, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 1, + "column": 6, + "position": 7, + "rect": { + "x": 195, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 1, + "column": 7, + "position": 8, + "rect": { + "x": 203, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 1, + "column": 8, + "position": 9, + "rect": { + "x": 211, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 1, + "column": 9, + "position": 10, + "rect": { + "x": 219, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "ANCHOR", + "document_line": 2, + "column": 0, + "position": 12, + "rect": { + "x": 147, + "y": 36, + "width": 8, + "height": 18 + } + }, + { + "label": "v", + "document_line": 4, + "column": 122, + "position": 142, + "rect": { + "x": 147, + "y": 108, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 5, + "column": 0, + "position": 144, + "rect": { + "x": 147, + "y": 126, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 6, + "column": 0, + "position": 146, + "rect": { + "x": 147, + "y": 144, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 7, + "column": 0, + "position": 148, + "rect": { + "x": 147, + "y": 162, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 8, + "column": 0, + "position": 150, + "rect": { + "x": 147, + "y": 180, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 9, + "column": 0, + "position": 152, + "rect": { + "x": 147, + "y": 198, + "width": 8, + "height": 18 + } + } + ], + "synthetic_lines_sha256": "25d734e1e1883d45abce9520dc7bc866e52262ea1f7cd44249e4a22ce9a78260" + }, + "stage_role": "shrunk-before-action" + }, + { + "phase": 2, + "label": "after-shrink-reflow-cup", + "source_hex": "1b5b383b324851", + "source_bytes": 7, + "source_sha256": "01182e79dd892bbede9ff78813ac595925893b4480f7666879292e13d9e89bf7", + "dsr_suffix_hex": "1b5b366e", + "cursor_reply": [ + 8, + 3 + ], + "response_hex": "1b5b383b3352", + "private_dsr_prefix": false, + "frozen_grid": [ + 61, + 20 + ], + "window_rect": [ + 102, + 102, + 1101, + 850 + ], + "window_size": [ + 1000, + 749 + ], + "document_projection": { + "line_count": 12, + "capacity": 20, + "top": null, + "scroll": 0, + "caret_api": { + "caretLine": 6, + "caretColumn": 0, + "caretPosition": 148 + }, + "markers": [ + { + "label": "0", + "document_line": 1, + "column": 0, + "position": 1, + "rect": { + "x": 147, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "1", + "document_line": 1, + "column": 1, + "position": 2, + "rect": { + "x": 155, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "2", + "document_line": 1, + "column": 2, + "position": 3, + "rect": { + "x": 163, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "3", + "document_line": 1, + "column": 3, + "position": 4, + "rect": { + "x": 171, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "4", + "document_line": 1, + "column": 4, + "position": 5, + "rect": { + "x": 179, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 1, + "column": 5, + "position": 6, + "rect": { + "x": 187, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 1, + "column": 6, + "position": 7, + "rect": { + "x": 195, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 1, + "column": 7, + "position": 8, + "rect": { + "x": 203, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 1, + "column": 8, + "position": 9, + "rect": { + "x": 211, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 1, + "column": 9, + "position": 10, + "rect": { + "x": 219, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "ANCHOR", + "document_line": 2, + "column": 0, + "position": 12, + "rect": { + "x": 147, + "y": 36, + "width": 8, + "height": 18 + } + }, + { + "label": "v", + "document_line": 4, + "column": 122, + "position": 142, + "rect": { + "x": 147, + "y": 108, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 5, + "column": 0, + "position": 144, + "rect": { + "x": 147, + "y": 126, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 6, + "column": 0, + "position": 146, + "rect": { + "x": 147, + "y": 144, + "width": 8, + "height": 18 + } + }, + { + "label": "Q", + "document_line": 6, + "column": 1, + "position": 147, + "rect": { + "x": 155, + "y": 144, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 7, + "column": 0, + "position": 149, + "rect": { + "x": 147, + "y": 162, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 8, + "column": 0, + "position": 151, + "rect": { + "x": 147, + "y": 180, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 9, + "column": 0, + "position": 153, + "rect": { + "x": 147, + "y": 198, + "width": 8, + "height": 18 + } + } + ], + "synthetic_lines_sha256": "3b8107c8b49c0fe4b5a17423700bbf0e3236293e58e88b6ff4d682bcd20b8190" + }, + "stage_role": "shrunk-after-action" + }, + { + "phase": 3, + "label": "after-restore-after-action", + "source_hex": "", + "source_bytes": 0, + "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dsr_suffix_hex": "1b5b366e", + "cursor_reply": [ + 8, + 3 + ], + "response_hex": "1b5b383b3352", + "private_dsr_prefix": false, + "frozen_grid": [ + 91, + 20 + ], + "window_rect": [ + 102, + 102, + 1461, + 850 + ], + "window_size": [ + 1360, + 749 + ], + "document_projection": { + "line_count": 11, + "capacity": 20, + "top": null, + "scroll": 0, + "caret_api": { + "caretLine": 6, + "caretColumn": 0, + "caretPosition": 148 + }, + "markers": [ + { + "label": "0", + "document_line": 1, + "column": 0, + "position": 1, + "rect": { + "x": 147, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "1", + "document_line": 1, + "column": 1, + "position": 2, + "rect": { + "x": 155, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "2", + "document_line": 1, + "column": 2, + "position": 3, + "rect": { + "x": 163, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "3", + "document_line": 1, + "column": 3, + "position": 4, + "rect": { + "x": 171, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "4", + "document_line": 1, + "column": 4, + "position": 5, + "rect": { + "x": 179, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 1, + "column": 5, + "position": 6, + "rect": { + "x": 187, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 1, + "column": 6, + "position": 7, + "rect": { + "x": 195, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 1, + "column": 7, + "position": 8, + "rect": { + "x": 203, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 1, + "column": 8, + "position": 9, + "rect": { + "x": 211, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 1, + "column": 9, + "position": 10, + "rect": { + "x": 219, + "y": 18, + "width": 8, + "height": 18 + } + }, + { + "label": "ANCHOR", + "document_line": 2, + "column": 0, + "position": 12, + "rect": { + "x": 147, + "y": 36, + "width": 8, + "height": 18 + } + }, + { + "label": "v", + "document_line": 4, + "column": 122, + "position": 142, + "rect": { + "x": 395, + "y": 90, + "width": 8, + "height": 18 + } + }, + { + "label": "5", + "document_line": 5, + "column": 0, + "position": 144, + "rect": { + "x": 147, + "y": 108, + "width": 8, + "height": 18 + } + }, + { + "label": "6", + "document_line": 6, + "column": 0, + "position": 146, + "rect": { + "x": 147, + "y": 126, + "width": 8, + "height": 18 + } + }, + { + "label": "Q", + "document_line": 6, + "column": 1, + "position": 147, + "rect": { + "x": 155, + "y": 126, + "width": 8, + "height": 18 + } + }, + { + "label": "7", + "document_line": 7, + "column": 0, + "position": 149, + "rect": { + "x": 147, + "y": 144, + "width": 8, + "height": 18 + } + }, + { + "label": "8", + "document_line": 8, + "column": 0, + "position": 151, + "rect": { + "x": 147, + "y": 162, + "width": 8, + "height": 18 + } + }, + { + "label": "9", + "document_line": 9, + "column": 0, + "position": 153, + "rect": { + "x": 147, + "y": 180, + "width": 8, + "height": 18 + } + } + ], + "synthetic_lines_sha256": "b27f4052b15213406dd65431e61688b8f2e413356dd9c7af011b5c9ce13e2701" + }, + "stage_role": "restored-after-action" + } + ], + "source_bytes": 229, + "dsr_request_bytes": 16, + "application_reply_bytes": 26, + "window_rect_format": "qt-qrect-inclusive-ltrb", + "geometry_checks": [ + { + "phase": 0, + "expected_size": [ + 1360, + 749 + ], + "window_rect": [ + 102, + 102, + 1461, + 850 + ], + "window_size": [ + 1360, + 749 + ] + }, + { + "phase": 1, + "expected_size": [ + 1000, + 749 + ], + "window_rect": [ + 102, + 102, + 1101, + 850 + ], + "window_size": [ + 1000, + 749 + ] + }, + { + "phase": 2, + "expected_size": [ + 1000, + 749 + ], + "window_rect": [ + 102, + 102, + 1101, + 850 + ], + "window_size": [ + 1000, + 749 + ] + }, + { + "phase": 3, + "expected_size": [ + 1360, + 749 + ], + "window_rect": [ + 102, + 102, + 1461, + 850 + ], + "window_size": [ + 1360, + 749 + ] + } + ], + "frozen_grids": [ + [ + 91, + 20 + ], + [ + 61, + 20 + ], + [ + 61, + 20 + ], + [ + 91, + 20 + ] + ], + "naws_history": [ + [ + 91, + 20 + ], + [ + 61, + 20 + ], + [ + 91, + 20 + ] + ], + "freeze_event_counts": [ + 1, + 2, + 2, + 3 + ], + "resize_boundaries": [ + { + "phase": 1, + "before_event_count": 1, + "after_event_count": 2, + "requested_window": [ + 1000, + 749 + ] + }, + { + "phase": 3, + "before_event_count": 2, + "after_event_count": 3, + "requested_window": [ + 1360, + 749 + ] + } + ], + "job_after": { + "active": 0, + "total": 3, + "terminated": 0 + }, + "provider": { + "id": "frida", + "version": null, + "confidence": "observed", + "evidence_id": "ev_2df126455c1f3e0f32b1153c641bd2463e110f92fae0906153156dbc0c773b1c", + "messages": 9, + "readiness_messages": 3, + "phase_messages": 4, + "messages_truncated": false, + "cleanup_error": null + }, + "pre_action_observation_count": 2, + "declared_action_hex": "1b5b383b324851" + }, + "provenance": { + "source_identity": { + "kind": "source-archive", + "manifest_sha256": "79908f9cc095cd56596179e8969b404ad3cfb40182690d35737d2e26a190422c" + }, + "execution_source_base_commit": "2d270004afe77b82388a5d40f971c5892af5f41b", + "execution_source_files_sha256": { + "research/ui/resize_addressing_reflow_reference.py": "da14d2df7c4a98a5d485f6af0f58599c3f101323026babc08ce9998f3fd0afa5", + "research/ui/resize_address_reference.js": "e431bf4bebda6283a8ccf50b7d3418bfda0dde6e85cf29d674c27f6b8f68cd2f", + "research/ui/resize_state_reference.py": "628f9e3a5e3ecea6c34829810df2cdf0e93dc221bd3d19b31b3f5028759f07fd", + "research/ui/mode_state_reference.py": "bac745e74a2653412ea7a701e3a1f3defa1fb97fdd7841b73fad483120496ec4", + "research/ui/alternate_reference.py": "94b85d49347e0e9813f7acddf0244f58520a7d1fce14084677c91cf4ddafffea", + "research/ui/r1_launch.py": "54fb41872c6abcc48242a0938044184854b016785cc13659c66b1bf404da0d8a", + "research/ui/launch.py": "6970e0d4f2fe5e7ec4827df7aa1026cd0e74aeb828cdc9b7a0b42b3c887eb4e6", + "research/ui/qt_r1_text.js": "f5820d413fdd72c13dffcefbf4d0ade615abfe15754c6b0b52e1e098814eea6f", + "research/ui/r1_vt_fixture.py": "5853f684d5b8a4c17a30f22a536f04f0eb212813b8d6b3cc3c197df392eb732a", + "experiments/conpty/run.py": "3766879cafe38aabf53cfac8ab2f62985b1976b6fa24c8b6031258bdc914258f" + }, + "launcher_namespace_sha256": "e3b2c89d7ace312d3fdfcf2be5d303c5beb2a86c932e81c31af9c832e908a060", + "reference_identity": { + "exe_sha256": "7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0", + "manifest_sha256": "e459adf2f12adb6a1b7f55e66a9da1c22794185e66e848057d21e0c60d669203", + "files_verified": 4047, + "module_files_sha256": { + "WindTerm.exe": "7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0", + "Qt5Core.dll": "8d2ff4ce9096ddccc4f4cd62c2e41fc854cfd1b0d6e8d296645a7f5fd4ae565a", + "Qt5Gui.dll": "5e7d2d41b8b92a880e83b8cc0ca173f5da61218604186196787ee1600956be1e", + "Qt5Widgets.dll": "3788c669d4b645e5a576de9fc77fca776bf516d43c89143dc2ca28291ba14358", + "Qt5Qml.dll": "30035484c81590976627f8face9507caa8581a7dc7630cccf6a8d6de65cab707" + } + }, + "launch_host": { + "windows_version": "10.0.26300", + "python_version_number": "3.12.10", + "python_exe_sha256": "4d6f5f81a4bca11191c4c7c6b43632694d0a4ce74e068619d8fdc161d469859a", + "node_exe_sha256": "1c331340fa6cac5e4294725959070a143fa0507195a78ad77eed7943f719aac9", + "rea_package_version": "6.7.0", + "rea_shim_sha256": "d092352a4088e7df223e9fe39039d703411dc5cbd39990f69756ade448ef1de4", + "rea_package_json_sha256": "3f6c47fb12c8388f291a21e7f99a876495d03aee671b3b6a0708dd12d6ebb570", + "rea_entry_sha256": "ed06cee49505b95a3cc5fceb1b4b279167e26df86227c644bb366ffffdbaf738", + "runtime_identity_source": "recorded execution plan; exporter does not execute the captured runtimes" + }, + "raw_files": { + "evidence.json": { + "bytes": 28726, + "sha256": "1e8d69ebdbe0ed2e7259ec28e2da606284334e88a019bd14a6427913030bfac0" + }, + "run.json": { + "bytes": 617, + "sha256": "e4317e802190ef013faf52869f85277906621f1c56d59c5c69c449ea9231f5e5" + }, + "probe.js": { + "bytes": 22986, + "sha256": "90f1d9a7579f8b907b46c5c89f50ea2fac14e86dbf3d0c30fedccd3be0aa81de" + }, + "peer.json": { + "bytes": 2401, + "sha256": "08139b3a3a3cd93a4f3ca97c671781c614a5e390e462750cde94ad3348ca90f4" + }, + "resize-address-result.json": { + "bytes": 13348, + "sha256": "50de7bcf18c2a08b168cc598df1b058bcdd254d8f24b686f27912be5442ec279" + }, + "files.json": { + "bytes": 637666, + "sha256": "a25c87cba935294d5bfd3bbc76c650d31a8473809564d798ad5a889e4aea00e8" + }, + "mechanics-check.json": { + "bytes": 418, + "sha256": "b62ca4661672245491782dcbd864906db4c24be40ea82620dc78717a0a95352f" + }, + "stderr.txt": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "profile-check.json": { + "bytes": 52, + "sha256": "5e522a64e98197722ccd084639b1ce50258b238e03be2f016e81c8af023b5ee1" + }, + "cleanup.json": { + "bytes": 42, + "sha256": "8400f93b6a4a4a50cde35b5d474660590ed215732f55772a9d8afac138da4f1d" + }, + "fixture-ready": { + "bytes": 32, + "sha256": "1f0b8ece17746ce41c81df65e45a6f8480747d9b69858dc905a29ae8d4c449cb" + }, + "ack-0": { + "bytes": 8, + "sha256": "604cee807f644af47487bf2bbab442b94212ac5119f36f995f78e9e4694dae8c" + }, + "ack-1": { + "bytes": 8, + "sha256": "604cee807f644af47487bf2bbab442b94212ac5119f36f995f78e9e4694dae8c" + }, + "ack-2": { + "bytes": 8, + "sha256": "604cee807f644af47487bf2bbab442b94212ac5119f36f995f78e9e4694dae8c" + }, + "ack-3": { + "bytes": 8, + "sha256": "604cee807f644af47487bf2bbab442b94212ac5119f36f995f78e9e4694dae8c" + }, + "resize-request-1": { + "bytes": 31, + "sha256": "38caa2237388a5f1b45a7e96077c0f8424d279f6c34ca97d177e16cc47b99fe9" + }, + "resize-ready-1": { + "bytes": 42, + "sha256": "311f5ead4d5362d9c2e384404d7d2b293d6cbd037cefa590190e8b208c00a89c" + }, + "resize-request-3": { + "bytes": 31, + "sha256": "dbc6af1a7e8ada86a7f804a58be0ba3a199a3ceb88930d923361f06668cef1fa" + }, + "resize-ready-3": { + "bytes": 42, + "sha256": "32b2926cd244a55d4c4e9c84415c894fd7f37be2e1b2d0015163659692ddbd8e" + } + }, + "authorized_plan": { + "bytes": 5383, + "sha256": "19006fea24acacdb8d1d87439f17b5b1c3459bf223b74d45c0280bf8fe6469d0" + } + }, + "limitations": [ + "One fresh direct Telnet process executes one CUP+Q action after the shrunk baseline has been observed and acknowledged.", + "The initial body contains a 123-character soft-wrapped line before the target rows, so narrowing shifts later display rows while retained source-fragment rows keep their numbering.", + "Four stages preserve both pre-action observations; only phases 1 and 3 resize, while phase 2 sends the exact declared CUP byte sequence.", + "Document markers, protocol DSR, caret API and visible rectangles remain different observations; no internal storage or addressing policy is inferred.", + "The finite text allowlist admits source-derived one-character writes without predicting the actually affected row.", + "Only 91x20 -> 61x20 -> 91x20 with fixed ASCII is covered; no full grid, styles, local-shell transport, alternate screen, height change, reliability repetition or performance acceptance.", + "Raw document text, paths, process identifiers, profiles, resources and provider diagnostics remain private." + ] +} diff --git a/research/ui/test_export_resize_addressing_reflow_reference.py b/research/ui/test_export_resize_addressing_reflow_reference.py new file mode 100644 index 00000000..6f535884 --- /dev/null +++ b/research/ui/test_export_resize_addressing_reflow_reference.py @@ -0,0 +1,267 @@ +"""Synthetic four-stage publication guards; no target, sockets, REA or native UI.""" +from __future__ import annotations +import copy +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from research.ui import export_resize_addressing_reflow_reference as exporter +from research.ui import resize_addressing_reflow_reference as probe +from research.ui import mode_state_reference as original_probe +from research.ui import export_mode_state_reference as original_exporter +from research.ui.test_resize_addressing_reflow_reference import recording +from research.ui.test_export_mode_state_reference import fixture as mode_fixture, complete_fixture as mode_complete_fixture + +CASE = "shrink-reflow-cup" + + +def save(directory, data, synchronize=True): + if synchronize: + result = data[probe.RESULT_FILE] + payloads = [{"kind": "probe_ready", "station_isolated": True, "scenario": "resize-address-v1", "case_id": result["case_id"]}, + {"kind": "readiness", "phase": "ui", "ready": True, "has_tree": True, + "has_engine": True, "has_editor": False, "peer_ready": False}] + payloads.extend({"kind": "resize_address_phase", **copy.deepcopy(row)} for row in result["observations"]) + payloads.append({"kind": "resize_address_result", **copy.deepcopy(result)}) + data["evidence.json"]["data"]["evidence"]["normalized_result"]["messages"] = [ + {"type": "send", "script_id": "synthetic-script", "payload": value} for value in payloads] + for name, value in data.items(): + (directory / name).write_text(json.dumps(value), encoding="utf-8") + + +def marker_rows(lines): + result, offset = [], 0 + for row, line in enumerate(lines): + for label in exporter.MARKERS: + column = line.find(label) + while column >= 0: + result.append({"label": label, "document_line": row, "column": column, "position": offset+column, + "rect": {"x": column*8, "y": row*18, "width": 8, "height": 18}}) + column = line.find(label, column+1) + offset += len(line)+1 + return result + + +def fixture(root, case=CASE): + directory, data, _ = mode_fixture(root) + data.pop("mode-state-result.json") + # A complete fixture contains only this protocol's four acknowledgments. + for path in directory.glob("ack-*"): + path.unlink() + state, result = recording(case) + for observation in result["observations"]: + terminal = observation["terminal"] + terminal["markers"] = marker_rows(terminal["lines"]) + data["peer.json"], data[probe.RESULT_FILE] = state, result + plan = {**probe.fixed_contract(case), "execution_authorized": True, "out": str(directory), "source": str(root / "official")} + run = data["run.json"] + run.update(sha256=probe.EXE_SHA256, files_sha256=probe.MANIFEST_SHA256) + template = (probe.ROOT / "research/ui/qt_r1_text.js").read_text(encoding="utf-8").split("// Scenario driver", 1)[0] + template += probe.probe_fragment(case) + run["template_sha256"] = hashlib.sha256(template.encode()).hexdigest() + generated = exporter.expected_probe(plan, run, probe.ROOT) + (directory / "probe.js").write_bytes(generated) + run["script_sha256"] = hashlib.sha256(generated).hexdigest() + data["evidence.json"]["data"]["evidence"]["normalized_result"]["source_sha256"] = run["script_sha256"] + (directory / "fixture-ready").write_text(json.dumps({"grid": state["frozen_grids"][0], "event_count": state["freeze_event_counts"][0]})) + for index in range(4): + (directory / f"ack-{index}").write_text("observed") + for index in (1, 3): + (directory / f"resize-request-{index}").write_text(json.dumps({"phase": index, "window": list(probe.WINDOWS[index])})) + (directory / f"resize-ready-{index}").write_text(json.dumps({"phase": index, + "grid": state["frozen_grids"][index], "event_count": state["freeze_event_counts"][index]})) + save(directory, data) + (directory / "mechanics-check.json").write_text(json.dumps(probe.audit_mechanics(directory, case))) + return directory, data, plan + + +def replace_lines(data, phase, lines): + terminal = data[probe.RESULT_FILE]["observations"][phase]["terminal"] + terminal.update(lines=lines, line_count=len(lines), markers=marker_rows(lines)) + + +@contextmanager +def complete_fixture(root, case=CASE): + with mode_complete_fixture(root) as original: + old_directory, _, old_plan, _, source_root, official = original + with patch.object(probe, "EXE_SHA256", original_probe.EXE_SHA256), \ + patch.object(probe, "MANIFEST_SHA256", original_probe.MANIFEST_SHA256), \ + patch.dict(exporter._PROVENANCE, FILE_COUNT=original_exporter.FILE_COUNT): + new_root = root / "reflow" + new_root.mkdir() + directory, data, case_plan = fixture(new_root, case) + plan = {**old_plan, **case_plan, "source": str(official)} + (directory / "app").mkdir() + for name in original_exporter.MODULES: + (directory / "app" / name).write_bytes((official / name).read_bytes()) + (directory / "files.json").write_bytes((old_directory / "files.json").read_bytes()) + profile = "profile/.wind/profiles/default.v10/terminal/user.sessions" + (directory / profile).parent.mkdir(parents=True) + (directory / profile).write_bytes((old_directory / profile).read_bytes()) + for name in probe.SOURCE_FILES: + (source_root / name).parent.mkdir(parents=True, exist_ok=True) + (source_root / name).write_bytes((probe.ROOT / name).read_bytes()) + archive = {"schema": probe.common.ARCHIVE_SCHEMA, "source_base_commit": "b"*40, + "source_files_sha256": {name: probe.sha(source_root / name) for name in probe.SOURCE_FILES}} + (source_root / probe.common.ARCHIVE_MANIFEST).write_text(json.dumps(archive), encoding="utf-8") + plan.update(probe.common.source_identity(root=source_root, source_files=probe.SOURCE_FILES)) + plan["launcher_source_sha256"] = hashlib.sha256(probe.launcher_source(case).encode()).hexdigest() + plan_path = new_root / "plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + plan_path.with_suffix(".attempted").write_text(probe.ATTEMPT) + yield directory, data, plan, plan_path, source_root, official + + +class ReflowAddressExportTests(unittest.TestCase): + def test_two_pre_action_observations_and_one_declared_action_are_preserved(self): + with tempfile.TemporaryDirectory() as temporary: + directory, _, plan = fixture(Path(temporary).resolve()) + result = exporter.capture_facts(directory, plan) + self.assertEqual(len(result["phases"]), 4) + self.assertEqual(result["pre_action_observation_count"], 2) + self.assertEqual(result["declared_action_hex"], probe.CASES[CASE].hex()) + self.assertEqual([v["source_hex"] for v in result["phases"]][1:], ["", probe.CASES[CASE].hex(), ""]) + self.assertEqual([v["stage_role"] for v in result["phases"]], + ["initial-baseline", "shrunk-before-action", "shrunk-after-action", "restored-after-action"]) + self.assertEqual(result["freeze_event_counts"], [1, 4, 4, 5]) + self.assertEqual([v["phase"] for v in result["resize_boundaries"]], [1, 3]) + for phase in result["phases"]: + self.assertNotIn("lines", phase["document_projection"]) + self.assertRegex(phase["document_projection"]["synthetic_lines_sha256"], r"^[0-9a-f]{64}$") + self.assertNotIn(str(directory), json.dumps(result)) + + def test_both_preregistered_target_rows_are_publishable_without_choosing_a_policy(self): + for row in (5, 6): + with self.subTest(row=row), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + for phase in (2, 3): + lines = ["", "0123456789", "ANCHOR", "", probe.REFLOW_LINE.decode("ascii"), "5", "6", "7", "8", "9", "", ""] + lines[row] = lines[row]+"Q" + replace_lines(data, phase, lines) + save(directory, data) + result = exporter.capture_facts(directory, plan) + self.assertEqual(result["phases"][2]["document_projection"]["line_count"], 12) + markers = [m for m in result["phases"][2]["document_projection"]["markers"] if m["label"] == "Q"] + self.assertEqual([(m["document_line"], m["column"]) for m in markers], [(row, 1)]) + + def test_private_text_premature_or_repeated_action_and_unbounded_padding_are_rejected(self): + for phase, lines in ((0, ["Q"]), (1, ["Q"]), (2, ["Q", " Q"]), (2, ["QQ"]), + (2, [" "*125+"Q"]), (3, ["SECRET"]), (3, ["5Qx"]), (3, ["ANCHOR "])): + with self.subTest(phase=phase, lines=lines), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + replace_lines(data, phase, lines) + save(directory, data) + with self.assertRaises(ValueError): exporter.capture_facts(directory, plan) + + def test_action_source_case_missing_pre_action_and_stale_or_extra_protocol_events_are_rejected(self): + for changed in ("action", "case", "missing-baseline", "pre-action-ack", "stale-ready", "extra-reply", "unarmed-naws", "window"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + result, peer = data[probe.RESULT_FILE], data["peer.json"] + if changed == "action": peer["records"][2]["source_hex"] = "0d51" + elif changed == "case": result["case_id"] = "other" + elif changed == "missing-baseline": result["observations"].pop(1) + elif changed == "pre-action-ack": (directory / "ack-1").write_text("not observed") + elif changed == "stale-ready": (directory / "resize-ready-1").write_text('{}') + elif changed == "extra-reply": peer["received_hex"] += "1b5b313b3152" + elif changed == "unarmed-naws": peer["naws_history"].append([90, 20]) + elif changed == "window": result["observations"][2]["window_rect"][2] += 1 + save(directory, data) + with self.assertRaises(ValueError): exporter.capture_facts(directory, plan) + + def test_marker_provider_source_identity_and_partial_results_are_rejected(self): + for changed in ("marker-label", "marker-position-bound", "rect", "caret", "exe", "manifest", "template", "script", + "evidence", "truncated", "cleanup", "source-path", "provider-observations", "provider-ready"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + terminal = data[probe.RESULT_FILE]["observations"][2]["terminal"] + evidence = data["evidence.json"]["data"]["evidence"] + if changed == "marker-label": terminal["markers"][0]["label"] = "SECRET" + elif changed == "marker-position-bound": terminal["markers"][0]["position"] = 262145 + elif changed == "rect": terminal["markers"][0]["rect"]["x"] = "private" + elif changed == "caret": terminal["caret_api"]["caretPosition"] = "private" + elif changed == "exe": data["run.json"]["sha256"] = "0"*64 + elif changed == "manifest": data["run.json"]["files_sha256"] = "0"*64 + elif changed == "template": data["run.json"]["template_sha256"] = "0"*64 + elif changed == "script": (directory / "probe.js").write_bytes(b"changed") + elif changed == "evidence": evidence["evidence_id"] = "private" + elif changed == "truncated": evidence["normalized_result"]["messages_truncated"] = True + elif changed == "cleanup": data["cleanup.json"]["active"] = 1 + elif changed == "source-path": evidence["normalized_result"]["source_path"] = "C:/private/other.js" + elif changed == "provider-observations": evidence["normalized_result"]["messages"].pop(3) + elif changed == "provider-ready": evidence["normalized_result"]["messages"][0]["payload"]["case_id"] = "other" + save(directory, data, synchronize=not changed.startswith("provider-")) + with self.assertRaises(ValueError): exporter.capture_facts(directory, plan) + + def test_unlisted_private_metadata_is_not_forwarded(self): + with tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + before = exporter.capture_facts(directory, plan) + data[probe.RESULT_FILE]["observations"][2]["terminal"]["private_note"] = "SECRET" + data["peer.json"]["resize_boundaries"][0]["private_path"] = "C:/private" + save(directory, data) + self.assertEqual(exporter.capture_facts(directory, plan), before) + + def test_complete_ten_source_archive_and_package_identity(self): + with tempfile.TemporaryDirectory() as temporary, complete_fixture(Path(temporary).resolve()) as values: + directory, _, _, plan_path, source_root, official = values + result = exporter.export(directory, plan_path, Path(temporary) / "public/result.json", + exporter.sha(plan_path), source_root, official) + self.assertEqual(result["schema"], "termforge-original-resize-reflow-addressing-reference-v1") + self.assertEqual(result["case_id"], CASE) + self.assertEqual(set(result["provenance"]["execution_source_files_sha256"]), set(probe.SOURCE_FILES)) + self.assertEqual(set(result["offline_export_source_files_sha256"]), set(exporter.EXPORT_SOURCES)) + self.assertIsNone(result["addressing_policy_classification"]) + self.assertFalse(result["semantic_acceptance"]) + + def test_changed_package_source_profile_attempt_plan_and_destination_are_rejected(self): + for changed in ("module", "source", "manifest", "profile", "attempt", "plan", "destination"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary, complete_fixture(Path(temporary).resolve()) as values: + directory, _, _, plan_path, source_root, official = values + expected = exporter.sha(plan_path) + destination = Path(temporary) / "public/result.json" + if changed == "module": (directory / "app/Qt5Core.dll").write_bytes(b"changed") + elif changed == "source": (source_root / "research/ui/resize_addressing_reflow_reference.py").write_bytes(b"changed") + elif changed == "manifest": (directory / "files.json").write_text('{}') + elif changed == "profile": (directory / "profile/.wind/profiles/default.v10/terminal/user.sessions").write_text('[]') + elif changed == "attempt": plan_path.with_suffix('.attempted').write_text('not reflow') + elif changed == "plan": plan_path.write_bytes(plan_path.read_bytes()+b' ') + elif changed == "destination": destination.parent.mkdir(); destination.write_text('preserved') + with self.assertRaises(ValueError): exporter.export(directory, plan_path, destination, expected, source_root, official) + if changed == "destination": self.assertEqual(destination.read_text(), 'preserved') + else: self.assertFalse(destination.parent.exists()) + + def test_duplicate_fields_and_nonfinite_metadata_fail_closed(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "record.json" + for content in ('{"phase":1,"phase":2}', '{"x":NaN}', '{"x":Infinity}', '{"x":-Infinity}'): + path.write_text(content) + with self.subTest(content=content), self.assertRaises(ValueError): exporter.read(path) + + def test_cli_refuses_private_text_normally_and_under_optimization(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + directory, data, plan = fixture(root) + data[probe.RESULT_FILE]["observations"][1]["terminal"]["lines"][1] = "SECRET" + save(directory, data) + plan_path = root / "plan.json" + plan_path.write_text(json.dumps(plan)) + for optimize in (False, True): + destination = root / ("optimized/out.json" if optimize else "normal/out.json") + command = [sys.executable, "-B", *(["-O"] if optimize else []), "-X", "utf8", "-m", + "research.ui.export_resize_addressing_reflow_reference", "--directory", str(directory), "--plan", str(plan_path), + "--expected-plan-sha256", exporter.sha(plan_path), "--destination", str(destination)] + completed = subprocess.run(command, cwd=probe.ROOT, capture_output=True, text=True, encoding="utf-8", timeout=10) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("Only fixed action-derived synthetic document lines", completed.stderr) + self.assertFalse(destination.parent.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/research/ui/test_resize_addressing_reflow_reference.py b/research/ui/test_resize_addressing_reflow_reference.py new file mode 100644 index 00000000..8b664252 --- /dev/null +++ b/research/ui/test_resize_addressing_reflow_reference.py @@ -0,0 +1,233 @@ +"""Fixed post-reflow addressing sources, actual protected peer and plan guards; no sockets/target.""" +import hashlib +import json +from pathlib import Path +import shutil +import socket +import tempfile +import unittest +from unittest.mock import patch + +from research.ui import resize_addressing_reflow_reference as probe + +DSR = b"\x1b[12;1R" + + +def naws(columns, rows): + return bytes([255,250,31])+columns.to_bytes(2,"big")+rows.to_bytes(2,"big")+bytes([255,240]) + + +class Connection: + def __init__(self, peer, mode): + self.peer, self.mode = peer, mode + self.initial, self.fragment, self.sent, self.resize_sent, self.calls = True, None, [], set(), 0 + def settimeout(self, _): pass + def close(self): pass + def sendall(self, data): self.sent.append(data) + def recv(self, _): + self.calls += 1 + if self.calls > 100: + raise RuntimeError("Synthetic receive bound") + if self.initial: + self.initial = False + return naws(92 if self.mode == "stale-initial" else 91, 20) + state, directory = self.peer.state, self.peer.directory + armed = state.get("resize_armed") + if armed and armed not in self.resize_sent: + self.resize_sent.add(armed) + grid = [61,20] if armed == 1 else [91,20] + if self.mode == "wrong-shrink" and armed == 1: grid[0] = 62 + if self.mode == "height-change": grid[1] = 21 + if self.mode == "wrong-restore" and armed == 3: grid[0] = 90 + events = [[62,20],[60,20],grid] if self.mode == "batched-resize" and armed == 1 else [grid] + count = len(state["naws_history"])+(0 if self.mode == "stale-ready" else len(events)) + (directory/f"resize-ready-{armed}").write_text(json.dumps({"phase":armed,"grid":grid,"event_count":count})) + return b"".join(naws(*event) for event in events) + record = state["records"][-1] + if self.mode == "unarmed-action" and record["phase"] == 2: + return naws(62,20)+naws(61,20) + if record["cursor_reply"] is not None: + if self.mode == "no-pre-action-ack" and record["phase"] == 1: + raise RuntimeError("Synthetic withheld pre-action acknowledgment") + (directory/f'ack-{record["phase"]}').write_text("observed") + target = record["phase"]+1 + if target in (1,3): + window = list(probe.WINDOWS[target]) + if self.mode == "wrong-request": window[0] += 1 + (directory/f"resize-request-{target}").write_text(json.dumps({"phase":target,"window":window})) + raise socket.timeout() + if self.mode == "duplicate-reply": return DSR+DSR + if self.mode == "foreign-input": return b"SECRET" + if self.fragment is not None: + result, self.fragment = self.fragment, None + return result + self.fragment = DSR[2:] + return DSR[:2] + + +def base_lines(): + return ["", "0123456789", "ANCHOR", "", probe.REFLOW_LINE.decode("ascii"), "5", "6", "7", "8", "9", "", ""] + + +def receive(case="shrink-reflow-cup", mode="normal"): + with tempfile.TemporaryDirectory() as temporary: + peer_type = probe.peer_class(case); peer = peer_type.__new__(peer_type) + peer.directory = Path(temporary).resolve() + (peer.directory/"fixture-ready").write_text(json.dumps({"grid":[91,20],"event_count":1})) + peer.state = {"connected":False,"columns":None,"rows":None,"phase":None,"records":[]} + peer.stop = type("NeverStopped",(),{"is_set":lambda _:False})() + connection = Connection(peer,mode) + peer.listener = type("Listener",(),{"accept":lambda _:(connection,("127.0.0.1",12345))})() + peer._serve() + return peer.state, connection.sent + + +def recording(case="shrink-reflow-cup"): + state, _ = receive(case, "batched-resize") + result = {"status":"observed-four-resize-address-states","case_id":case,"error":None,"observations":[], + "window_rect_format":"qt-qrect-inclusive-ltrb","geometry_checks":[],"full_active_grid_verified":False, + "local_shell_transport_verified":False,"presented_done":None} + for record, window in zip(state["records"],probe.WINDOWS): + rect = [113,-27,112+window[0],-28+window[1]] + lines = base_lines() + if record["phase"] >= 2: lines[6] = "6Q" + terminal = {"connected":True,"protocol":"Telnet","capacity":20,"line_count":len(lines),"lines":lines,"markers":[], + "top":None,"scroll":0,"caret_api":{"caretLine":6,"caretColumn":1,"caretPosition":200}} + result["observations"].append({**{k:record[k] for k in ("phase","label","frozen_grid","cursor_reply","cursor_reply_private","response_hex")}, + "terminal":terminal,"window_rect":rect,"window_size":list(window)}) + result["geometry_checks"].append({"phase":record["phase"],"window_rect":rect,"window_size":list(window),"expected_size":list(window)}) + return state,result + + +class ReflowAddressReferenceTests(unittest.TestCase): + def test_one_literal_action_and_four_stages_preserve_pre_action_baseline(self): + case = "shrink-reflow-cup" + values = probe.phases(case) + self.assertEqual([value[1] for value in values], [probe.BODY, b"", b"\x1b[8;2HQ", b""]) + self.assertTrue(values[0][1].endswith(b"\x1b[12;1H")) + self.assertEqual(len(probe.BODY), 222) + self.assertEqual(hashlib.sha256(probe.BODY).hexdigest(), + "094c07cb3cdcc5e8c6845f3755cb030521fae7b5844c6a1398545a39c532f14d") + self.assertEqual(probe.fixed_contract(case)["pre_action_phases"], [0, 1]) + self.assertEqual(probe.fixed_contract(case)["resize_phases"], [1, 3]) + with self.assertRaises(ValueError): probe.phases(case, 20, 90) + with self.assertRaises(ValueError): probe.phases("unknown") + + def test_actual_peer_emits_only_the_bound_action_after_ack_and_keeps_two_resize_boundaries(self): + for case in probe.CASES: + state, sent = receive(case, "batched-resize") + self.assertTrue(state["complete"]); self.assertNotIn("error", state) + self.assertEqual(sent[1:], [source+b"\x1b[6n" for _, source in probe.phases(case)]) + self.assertEqual(state["frozen_grids"], [[91,20],[61,20],[61,20],[91,20]]) + self.assertEqual(state["freeze_event_counts"], [1,4,4,5]) + self.assertEqual([v["phase"] for v in state["resize_boundaries"]], [1,3]) + self.assertEqual(state["received_hex"], (DSR*4).hex()) + self.assertEqual([v["cursor_reply"] for v in state["records"]], [[12,1]]*4) + + def test_wrong_geometry_stale_snapshot_extra_input_and_unarmed_change_stop(self): + for mode in ("stale-initial","stale-ready","wrong-shrink","wrong-restore","height-change","wrong-request", + "unarmed-action","duplicate-reply","foreign-input"): + with self.subTest(mode=mode): + state, _ = receive(mode=mode) + self.assertIn("error", state); self.assertFalse(state.get("complete", False)); self.assertTrue(state["closed"]) + + def test_missing_pre_action_ack_never_releases_action_or_restore(self): + state, sent = receive(mode="no-pre-action-ack") + self.assertEqual(len(state["records"]), 2) + self.assertEqual(sent[1:], [probe.phases("shrink-reflow-cup")[0][1]+b"\x1b[6n", b"\x1b[6n"]) + self.assertNotIn(b"\x1b[8;2HQ\x1b[6n", sent) + + def test_peer_and_launcher_namespaces_do_not_change_the_frozen_baseline(self): + original = probe.baseline.phase_records() + one = probe.peer_class("shrink-reflow-cup") + self.assertEqual(one._serve.__globals__["phases"](20, 91)[2][1], b"\x1b[8;2HQ") + self.assertEqual(probe.baseline.phase_records(), original) + for case in probe.CASES: + source = probe.launcher_source(case); compile(source, "", "exec") + self.assertIn("resize_addressing_reflow_reference", source); self.assertIn(repr(case), source) + self.assertNotIn(probe.CONTROLLER_ANCHOR, probe.probe_fragment(case)) + + def test_finite_text_allowlist_rejects_private_text_duplicate_action_and_premature_action(self): + for phase in range(4): + for line in ("PRIVATE", " "*124+"Q", probe.REFLOW_LINE.decode("ascii")+"x", "QQ", "ANCHORSECRET"): + with self.subTest(phase=phase, line=line), self.assertRaises(ValueError): + probe.validate_document("shrink-reflow-cup", phase, {"line_count":1, "lines":[line]}) + for phase in (0, 1): + with self.assertRaises(ValueError): probe.validate_document("shrink-reflow-cup", phase, {"line_count":1, "lines":["Q"]}) + with self.assertRaises(ValueError): probe.validate_document("shrink-reflow-cup", 2, {"line_count":2, "lines":["Q", " Q"]}) + probe.validate_document("shrink-reflow-cup", 2, {"line_count":1, "lines":["6Q"]}) + probe.validate_document("shrink-reflow-cup", 2, {"line_count":1, "lines":["5Q"]}) + probe.validate_document("shrink-reflow-cup", 2, {"line_count":1, "lines":[probe.REFLOW_LINE.decode("ascii")]}) + + def test_mechanics_requires_all_four_observations_and_exact_action(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp); state, result = recording() + values = {"peer.json":state, probe.RESULT_FILE:result, "cleanup.json":{"active":0,"total":3,"terminated":0}, + "profile-check.json":{"only_synthetic_session":True,"session_count":1}} + for name, value in values.items(): (directory/name).write_text(json.dumps(value)) + (directory/"stderr.txt").write_bytes(b"") + for index in range(4): (directory/f"ack-{index}").write_text("observed") + self.assertEqual(probe.audit_mechanics(directory, "shrink-reflow-cup")["pre_action_stage_count"], 2) + state["records"][2]["source_hex"] = "0d51"; (directory/"peer.json").write_text(json.dumps(state)) + with self.assertRaisesRegex(ValueError, "Exact action source"): probe.audit_mechanics(directory, "shrink-reflow-cup") + + +class ReflowAddressPlanTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="resize-reflow-tests-"); self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + for name in probe.SOURCE_FILES: + target = self.root/name; target.parent.mkdir(parents=True, exist_ok=True); shutil.copyfile(probe.ROOT/name, target) + archive = {"schema":probe.common.ARCHIVE_SCHEMA,"source_base_commit":"b"*40, + "source_files_sha256":{name:probe.sha(self.root/name) for name in probe.SOURCE_FILES}} + (self.root/"reference-source.json").write_text(json.dumps(archive)) + self.source = self.root/"original"; self.source.mkdir(); (self.source/"WindTerm.exe").write_bytes(b"synthetic, not an executable") + for target, name, value in ((probe,"ROOT",self.root),(probe,"EXE_SHA256",probe.sha(self.source/"WindTerm.exe")), + (probe.common,"host_identity",lambda:{"synthetic_host":True}), + (probe.common,"rea_identity",lambda _: {"node_executable":"inert-node"})): + patcher = patch.object(target, name, value); patcher.start(); self.addCleanup(patcher.stop) + self.out = self.root/"local/out"; self.plan = self.root/"local/plan.json" + + def prepare(self, authorized=False): + return probe.prepare_plan(self.source, self.out, self.root/"rea.cmd", self.plan, "shrink-reflow-cup", authorized) + + def test_archive_plan_is_offline_and_exactly_ten_source_files(self): + with patch.object(probe.common.subprocess, "check_output", side_effect=AssertionError("No archive Git")): + plan = self.prepare() + self.assertEqual(len(plan["source_files_sha256"]), 10) + self.assertEqual(plan["actual_original_runs"], 0); self.assertFalse(self.out.exists()) + with self.assertRaisesRegex(ValueError, "not authorized"): probe.run_plan(self.plan) + self.assertFalse(self.plan.with_suffix(".attempted").exists()) + + def test_changed_case_geometry_or_source_is_rejected_before_attempt(self): + plan = self.prepare(True) + for key, value in (("case_id","other"),("phase_count",3),("required_grids",[]),("phases",[]), + ("source_identity",{"kind":"git-checkout"}),("launcher_source_sha256","0"*64)): + self.plan.write_text(json.dumps({**plan, key:value})) + with self.subTest(key=key), self.assertRaises(ValueError): probe.run_plan(self.plan) + self.assertFalse(self.plan.with_suffix(".attempted").exists()) + + def test_local_boundary_existing_plan_and_existing_attempt_remain_enforced(self): + with self.assertRaisesRegex(ValueError, "local directory"): + probe.prepare_plan(self.source, self.root/"elsewhere", self.root/"rea.cmd", self.plan, "shrink-reflow-cup") + with patch.object(probe, "launcher_source", return_value="def run(*args):\n pass\n"), patch.object(probe, "audit_mechanics", return_value={}): + self.prepare(True) + with self.assertRaisesRegex(ValueError, "must be new"): self.prepare(True) + with patch.object(probe.Path, "write_text", autospec=True) as write_result: + probe.run_plan(self.plan) + write_result.assert_called_once() + with self.assertRaises(FileExistsError): probe.run_plan(self.plan) + + def test_failed_attempt_is_retained_and_cannot_be_replayed(self): + source = "def run(*args):\n raise RuntimeError('synthetic launch failure')\n" + with patch.object(probe, "launcher_source", return_value=source), patch.object(probe, "audit_mechanics") as audit: + self.prepare(True) + with self.assertRaisesRegex(RuntimeError, "synthetic launch failure"): + probe.run_plan(self.plan) + self.assertEqual(self.plan.with_suffix(".attempted").read_text(), probe.ATTEMPT) + self.assertFalse(self.out.exists()); audit.assert_not_called() + with self.assertRaises(FileExistsError): probe.run_plan(self.plan) + + +if __name__ == "__main__": + unittest.main()