From 2d270004afe77b82388a5d40f971c5892af5f41b Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:56:06 +0800 Subject: [PATCH 1/3] test(research): freeze post-reflow CUP addressing probe (#29) Add a four-stage, one-action direct-Telnet observation that distinguishes whether CUP addressing after narrowing follows the retained (pre-resize) rows or the current reflowed display rows. The fixed body puts a 123-character soft-wrapped line before the target rows, so narrowing shifts later display rows by one while retained rows keep their numbering. The design document records both competing interpretations and their independent expectations before any original run; the exporter can publish either pre-registered target row without choosing a policy. Offline peer, plan and exporter tests only use in-memory/synthetic targets. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- ...port_resize_addressing_reflow_reference.py | 159 +++++++++ .../ui/resize_addressing_reflow_reference.md | 78 +++++ .../ui/resize_addressing_reflow_reference.py | 305 ++++++++++++++++++ ...port_resize_addressing_reflow_reference.py | 267 +++++++++++++++ ...test_resize_addressing_reflow_reference.py | 233 +++++++++++++ 5 files changed, 1042 insertions(+) create mode 100644 research/ui/export_resize_addressing_reflow_reference.py create mode 100644 research/ui/resize_addressing_reflow_reference.md create mode 100644 research/ui/resize_addressing_reflow_reference.py create mode 100644 research/ui/test_export_resize_addressing_reflow_reference.py create mode 100644 research/ui/test_resize_addressing_reflow_reference.py 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/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() From d8c79c6b0d92b6cc5cd5458be616cc6843986333 Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:05:39 +0800 Subject: [PATCH 2/3] test(research): publish post-reflow CUP addressing observation (#29) One fixed direct-Telnet WindTerm 2.7.0 run (four stages, one CSI 8;2HQ action) whose body places a 123-character soft-wrapped line before the target rows. Narrowing 91 -> 61 shifts later display rows by one while the retained rows keep their numbering. Result: Q lands on document line 6 (retained rows; the reflowed display row would be 5), DSR 8;3, narrow rect (155,144) and restored (155,126). Strict offline export passed; one original run, no retry. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .../README.md | 79 + .../shrink-reflow-cup.json | 1328 +++++++++++++++++ 2 files changed, 1407 insertions(+) create mode 100644 research/ui/results/2026-11-03-reflow-addressing-reference/README.md create mode 100644 research/ui/results/2026-11-03-reflow-addressing-reference/shrink-reflow-cup.json 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." + ] +} From b3974d9135205b639cab3c59cbb4212ffc2eda0c Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:05:43 +0800 Subject: [PATCH 3/3] test(core): replay post-reflow CUP addressing through retained rows (#29) Add reflow_addressing_core, a literal regression gate for the observed sample: four phases (91x20 -> 61x20 -> 91x20, one CSI 8;2HQ), each checked under strict/recovery and whole/bytewise, covering protocol grid, DSR, joined source logical rows and the reflowed display rows/marker positions. 16/16 phases, 0/620 assertions, exit 0; core CTest 35/35. No production source change: screen.hpp bytes are identical to main, so the existing retained-owner plus independent display-projection design already matches the observation. Default Screen::resize and Qt still clip physically; the evidence and docs state that separately. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- CMakeLists.txt | 6 + docs/EVIDENCE.md | 1 + docs/FEATURES.md | 2 +- experiments/core/README.md | 2 +- .../core/resize_reflow_addressing_test.cpp | 212 ++++++++++++++++++ .../2026-11-03-reflow-addressing/README.md | 45 ++++ .../validation.json | 68 ++++++ 7 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 experiments/core/resize_reflow_addressing_test.cpp create mode 100644 experiments/core/results/2026-11-03-reflow-addressing/README.md create mode 100644 experiments/core/results/2026-11-03-reflow-addressing/validation.json 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." + ] +}