Repository navigation
test(mmc-trajectory): ship the regression guards that were only on one machine - #16
Conversation
The PR MiniMax-AI#15 review asked whether the rule-name guard could be reproduced, and noted that the script behind it is not in the repository. It was not: the mutation controls and verifiers lived in a scratch directory on the machine that wrote the client, with absolute paths in them. Fifteen of them now live in tests/, and they run on a clean checkout with nothing but Node and Python: node tests/run.mjs Every path resolves from the directory's own location, and client-check.mjs writes its extracted copy of the inline script to a temporary directory rather than into the checkout. Why half of them are mutate-*.py: a guard that cannot fail is not a guard, because it reads as coverage. Each one breaks exactly one rule in the shipped source, requires the guard that owns that rule to fail *and* to name the right check, then restores the file and requires green again. Two of the six report "all N mutations caught"; the rest print each mutation and the check that caught it. Four were left out rather than carried: mutate-filter-guards.mjs, mutate-qa-lens.py Guard the layer buttons and the turn lens. Commit 91464cd removed the layer presets in favour of the type chips; turns-btn, setLayer, LAYER_KINDS and applyLayerToKinds now occur zero times in index.html. The rule they guard is gone. mutate-steer.py, mutate-window-default.py The rules are still live -- verify-window-widen.mjs still covers the second one -- but the mutation anchors no longer match the source. These need re-anchoring, which is a separate change; shipping them red would only teach reviewers to ignore the suite. tests/README.md records the same reasoning, and states plainly that the one-shot patch scripts that write the package are not here and must not be run: they have no dry-run and no backup, they write straight back to index.html / server.mjs / the READMEs, and re-running one edits text that has since moved on. 15/15 pass. The four payload files are byte-identical to main -- this commit adds only tests/.
verify-parsers.mjs and verify-prompt-fix.mjs read this machine's ~/.minimax. On a runner without one the suite drops to 13/15: verify-parsers crashes and verify-prompt-fix fails two checks, so the guards would have gone red the moment CI was allowed to run. They now read a committed synthetic store under tests/fixtures/minimax-home. The fixture is distilled from real transcripts rather than hand-written: make-fixtures.mjs keeps the structure the assertions depend on -- roles, content-part types, stopReason (the reply/narration split turns on it), toolCallId pairing, usage numbers, and the injection markers splitUserMessage() branches on -- and replaces every string payload. Three things keep that honest: - a leak assertion on the rendered bytes refuses to write if a path, user name or store reference survived synthesis (--selftest proves it trips); - a coverage gate refuses to write unless all eight required shapes are present, so a sparse sample cannot pass by accident; - toolCall id / toolResult toolCallId are memoised per original value, so corpus pairing survives -- regenerating each occurrence independently silently broke it and the pairing assertion would have failed. `node tests/run.mjs --real` still runs the two guards against the local ~/.minimax, for checking a change against transcripts nobody curated. Also corrects verify-prompt-fix.mjs, which passed resolveSessionsRoot().home to createRedactor -- that property does not exist on the returned object, so the store root was never one of the redacted secrets. 15/15 on this machine, 15/15 on a runner with no ~/.minimax, 15/15 with --real. The four payload files are byte-identical to main; this adds only tests/. npm run validate reports plugins/avatasia/mmc-trajectory: OK.
…of its own The suite went 15 -> 22. The six guards that were only runnable against a booted service on a developer machine now read a committed payload instead, and the two mutation controls whose anchors had stopped matching work again. Seven guards wanted one /api/trajectory body and got it by fetching it from a port read out of .probe/port.txt. The route is buildTrajectoryPayload(...) plus a few session.* fields, so the same body falls out of the fixture store offline -- make-payload.mjs writes it, with --check to catch drift between the two fixtures. Distilling that store properly needed the whole lineage. A compacted session keeps its old messages in snapshots/g<gen>--ctx_*.jsonl, and the shapes the guards need -- an empty reply above all -- live in those older generations. Reading only the active file found none of them and verify-anomaly failed for want of something to find. The distiller now walks the lineage and the coverage gate requires all ten shapes, including one that produced no segment at all: server.mjs only pushes a text:null reply when segmentOrder is empty, so an assistant message that called a tool but said nothing is not a silent reply. Two of these were not what they looked like: - mutate-steer.py was not stale. server.mjs is CRLF and the anchor joined its lines with \n, so a multi-line anchor matched zero times while every single-line anchor in the same file matched fine. Anchors are now built in the file's own line ending. - mutate-window-default.py was stale, but not where it looked: the rule is intact and rePinWindow had gained a time-range early-return *between* the two lines the anchor spanned. verify-block-order.mjs was wrong about the rule it names. It broke a run only on a non-assistant record, so two assistant messages in one turn were ranked against each other and three toolCall -> thinking pairs were reported that never occurred inside any message. The rule is per-message; splitting runs at the message boundary finds more runs, not fewer (16 -> 19, 0 violations). mutate-anomaly.py does not carry a case that nulls the page's liveTurn, even though that looks like the obvious thing to break. verify-anomaly.mjs re-derives the live turn itself instead of calling the page's function, so the mutation is invisible to every assertion in the file -- tried, and the probe passed. Shipping it would ship a mutation known to be unobservable. The gap is named in tests/README.md instead. verify-time-range.mjs and mutate-time-range.py are present but skipped by run.mjs: their assertions are calibrated against a full multi-generation session (stats.messages > 1000, a checkpoint nested inside a turn) and reproducing that means committing a multi-megabyte payload. Relaxing the numbers would stop exercising the timezone arithmetic the file exists for. 22/22 here, 22/22 on a runner with no ~/.minimax, 22/22 with --real. The four payload files are byte-identical to main; this adds and changes only tests/. npm run validate reports plugins/avatasia/mmc-trajectory: OK.
The 异常 row was the last field of the search form, which put it above the KPI cards and the type axis — four cards away from the control it reads as part of. It now sits directly under 全部/用户/追加, because both axes answer the same question: which records the ledger shows. It is a second filter system rather than another field of the first. Ticking a rule switches the type axis, the search box, the time range and the overview brush off; a new 全部数据 button — the state the page opens in — switches them back on with their values intact. The inactive controls are `inert`, not merely dimmed: a control that takes a click and then changes nothing on screen is the defect this change is about, and reintroducing it one line away would be worse than the original. The session and generation pickers are deliberately left working — they choose which session is on screen, not which records of it. The counts followed. anomalyCounts() walked passesBaseFilter while the ledger filtered the same way, so a button promising 283 rows sat above a list of 1,494 — reproducible on a 15k-record session, and the arithmetic was right in both places, which is why nothing caught it. It now walks the records anomaly mode actually shows, so the number on a button is what ticking that button puts on screen. A rule scoped to a turn is counted once per turn and published in 轮: 无答复 was advertising 1,244 "unanswered" rows in a session that was broken 13 times, none of which carried a sign of being unanswered. The unit comes from the rule table, so a rename or a rescope cannot leave it lying. verify-anomaly.mjs 116 → 158 assertions. The two that required the count to follow the other axis are inverted rather than deleted — they were correct while the ledger filtered the same way, and they are the reason the mismatch had a written spec. Added: the unit follows the rule table, a turn rule is counted per turn, the axes are alternatives rather than a stack in both the ledger and the overview strip, the way back exists and is not a toggle, the inactive axis is inert, and the position of the axis on the page. mutate-anomaly.py 36 → 45 mutations, one per new invariant. Two cases were repointed and one deleted: they encoded the stacked-axis behaviour that has just been removed, and the anchor of a third had moved with the markup. Also fixes a dead class — `.anomaly-btn.is-empty` was toggled on every render and had no rule anywhere in the stylesheet. 22/22 locally, 22/22 on a clean runner, 22/22 with --real.
The button added in the previous commit as 全部数据 was neither of those things. It is 重置: not a reverse selection — nothing is being inverted — but the row returning to the state it was in on arrival, where the table is filtered by the row above. That distinction has a consequence worth spelling out. In 异常 mode the row above filters nothing, so a 重置 that restored what that row used to hold would be restoring a condition the page is not applying. It resets it instead — and so does ticking a rule, on the way in. Otherwise the axis keeps drawing 「用户」 while the table is showing tool failures: the same defect as a button whose number does not match its list, one control over. Ticking a rule therefore puts the row above back to its default — every type, no search, no range, no brush. `resetBaseFilters()` sets the state *and* calls `syncKindButtons()`, because a write without the sync is what leaves the axis drawing what was there before. That combination has its own mutation control. 重置 also drops its count. 「怎么多会留下多少」 is a question about a rule, and a control that keeps whatever the row above says has no answer to it; printing a number there only invites adding it up against the numbers beside it. verify-anomaly.mjs 158 → 172 assertions. mutate-anomaly.py 45 → 50 mutations, five new controls for the reset. One of them survived the first run, because the assertion that catches it was in client-check.mjs and this runner does not execute that file; the assertion moved to the file whose runner can, rather than being dropped. client-check's "state.kinds is written from exactly three controls" becomes four, for the reason its own comment demands — change the check in the same commit that adds the control. 22/22 locally, 22/22 on a runner with no ~/.minimax, 22/22 with --real.
多选是一个模式开关,不是第五条规则:它在规则按钮组之外,所以不会被
syncAnomalyMode 的 data-filter-scope 打成 inert——那会把它唯一一条退路掐掉。
单选语义下点第二条是替换而非叠加,重复点同一条才关掉;多选下点第二条才叠加,
点已选中的那条只摘掉它。关掉多选时保留最后勾选的那条(读者刚才在看的那条),
而不是表格第一条。重置把规则和多选一起清回默认,并复位 checkbox。
守卫:verify-anomaly 172 -> 196,mutate-anomaly 50 -> 62。
- 两条旧断言(ticking a second keeps table order / unticking removes only that
one)编码的是 toggle 无条件叠加的旧语义,本轮按需求推翻重写:单选写到单选
块,原来的并集语义挪到多选块重断言,不是删掉。
- 新增覆盖:关多选保留最后勾选那条、多选开关不改变已选集合、重置同时关掉多选
并复位 checkbox、每次 render 从 state 重绘 checkbox、checkbox 不得带
data-filter-scope、change 而非 click 绑定。
- 修了 FAILED_NAMES 用 split(':')[0] 取名会把带冒号的断言名截断的 bug——它
让变异跑器把「已抓住」读成「没抓住」。名字改为单独收集,可读输出不变。
浏览器实测(大会话 126 轮):单选替换、并集叠加(343 = 200 已显示 + 143 未
加载,与 324+19 有 1 条重叠吻合)、关多选保留最后勾选那条、重置回到默认且
checkbox 视觉复位。
黑盒走了一遍按钮矩阵,发现三处缺陷——都是「控件说了什么」而不是「控件算了什么」, 读代码读不出来,因为算的那部分是对的。 1. 部分并集时,提示把「合计」指向「全部异常」。实测:多选下勾 工具失败+上下文压缩, 提示写「合计见「全部异常」」,而「全部异常」是四条规则的全集 1,707 条,当前所选的合计 约 354 行。按提示去找合计,拿到的是另一个选择的数。 改为在 anomalyCounts() 同一次遍历里数出所选规则的并集,提示直接给这个数。 2. 多选已勾但未选规则时,「重置」自称「已是默认状态」且 aria-pressed=true。实测证据: 多选可见、规则全未选,重置仍读「已是默认状态」。但默认是单选,而且多选会改变之后每次 点击的语义(替换 vs 叠加),重置是唯一能把模式拨回去的控件——告诉读者已经在默认态, 他就没有理由去按它。 3. 单选下按「全部异常」会用多选词汇描述状态:提示写「已叠加 4 条规则」,而多选是关的, 读者并没有叠加。改为「全部 4 条规则同时生效」。 顺带把 counts 改成单次快照:render() 原来为提示和按钮各走一遍,而会话在轮询间隙还在长, 两遍可能给出不同数字。现在一次 anomalyCounts() 同时喂两者——提示和按钮不可能各答各的。 守卫:verify-anomaly 214 -> 234,mutate-anomaly 62 -> 70。 - 补上了此前根本没测的部分并集:�ll.any 是四条规则的全集,逐条规则的检查又只选一条, 所以多选唯一能产出的那种选择此前一条断言都没有。现在对 6 个规则对逐一断言 counts.selected == visibleEntries().length,并用与页面无关的代码独立算出期望值。 - 两条新变异第一次跑就存活:一条是我把变异写成了空操作(sel.length === 0 || 在有选中时 等价于原式),换成了「去掉 break」——只在两条规则重叠于一条记录时才出错的那种;另一条 指向了不存在的断言。两条都是补守卫,不是删变异。 - 每条规则对的断言名带规则对,人读着好用,但变异跑器按名字精确匹配,哪一对命中取决于夹具。 因此另加族级断言(名字稳定)作为变异的归属,逐对的保留作诊断。 新增 tests/METHODOLOGY.md:验证方法论。每条规则都对应一次真实代价,包括本轮踩的—— 读父容器而不是过滤子元素、期望值只能来自冻结夹具、空操作变异、断言名不能带冒号、 散文要跑出来断言而不是看源码形状、以及工具驱不动的事要写明驱不动。 浏览器实测:多选两条 → 提示「合计 354 条」,账本 200 已显示 + 154 未加载 = 354; 单选按全部异常 → 「全部 4 条规则同时生效 · 合计 1,716 条」,账本 200 + 1,516 = 1,716; 多选未选规则 → 重置改为「点击回到默认状态」。 未验证:多选 checkbox 的键盘操作。Shift+Tab 在该 harness 里移不动焦点(焦点环仍在重置上, Space 无效),所以该结论只由静态守卫(绑定 change 而非 click,配变异对照)加原生行为支撑, 不做端到端声称。
滚动时看不到异常筛选:类型轴自己 claim 过 position: sticky,异常轴没有,于是 筛选过的会话滚下去之后,页面上没有任何一行还在说明「现在被筛过什么」。 - `.filter-sticky` 把类型轴和异常轴包成一个钉在 top: 0 的不透明块。它们回答 同一个问题(账本显示哪些记录),分开钉就会出现「类型轴还在、异常轴滚走了」 的半截状态,比完全不钉更糟。 - 下方三层的偏移不再写死。原来的 0–32 / 32–83 只在「总览条上方只有一行筛选轴」 的布局里成立,而异常轴会随窄屏换行、提示行的出现和消失改变高度,任何常量都会 悄悄变成重叠。`syncStickyOffsets()` 在 `render()` 末尾量三个块,写进 `--sticky-band-h` / `--sticky-overview-h` / `--sticky-window-h`,样式表只读变量。 - 轮头从 `top: 0; z-index: 2` 移到实测偏移下方。它原先压在总览刻度和类型轴按钮 上,两处都读不出来;z-index 6 的筛选带盖住它只是把重叠藏起来,不是修好。 守卫:`verify-sticky.mjs`(20 条断言)把 syncStickyOffsets() 拿 137 / 65 / 57 的 stub 高度跑一遍——文件里没有任何常量等于这三个数,所以「写数字而不是量」的版本 过不去,把 band 量成 strip 的版本也过不去。`mutate-sticky.py` 18 条变异逐条要求 它以正确的名字失败,其中三条是补了守卫才有的:样式表读到的变量集合必须等于测量 写出的集合(单向都不够)、被测量的块必须真的绑定到 markup 里存在的 id(stub 永远 喂得起函数,抓不到这种静默回退)、以及任何钉在账本之上的层都必须有不透明背景 (账本行是 `background: transparent`)。 测试 22 → 24 个守卫,全绿。
这个页面的筛选逻辑是一次加一个功能长出来的,代价能逐条指到代码: - 「看哪些记录」这一个职责住在文档位置 3 和 5 两处(工具条 / 筛选带), 中间隔着整块 KPI 区。上一轮的 sticky 修复是这笔债的利息不是本金。 - state 里两句注释互相矛盾:`kinds` 写「类型选项是唯一筛选」,`rangeFrom` 写「区间是 KPI 跟随的唯一筛选」。两句各自描述一个更早的页面。 - kpiStats() 只跟区间,不跟类型轴、不跟搜索、不跟异常轴——汇总层和筛选层 共用了同一套视觉语言。 - 异常轴被实现成「第二套系统」而不是筛选层的一个字段,于是互斥只能散落在 两个渲染函数里手写。上一轮修的三处缺陷没有一处是算错,全是「两个筛选器 各自推理自己」的症状。 - window-bar 在列表面板里,写的却是「渲染多少」这一层的事。 docs/LAYERS.md 给出 L0–L6 的归属表、六条不可违反的规则,以及实现之前必须先 答的四个问题:归层、关联、布局、口径。最后一问是「联动异常」的主要来源—— 加了一个能力改了一个数字,别的地方还在按旧口径说话。 本 PR 不改页面代码,Stage 1–3 在文档里分阶段列出。
按 AGENTS.md 的强约束,Stage 1 在实现之前先把设计冻结成可判定的断言。
docs/STAGE1-SPEC.md:
- 四问在本阶段的答案。L2 容器装四组控件(搜索 / 区间 / 类型轴 / 异常轴),
L1 的会话与上下文代留在外面,不动 Stage 2 的模型,一个数字都不改。
- 口径读点清单:KPI↔区间这条口径共有 4 个页面读点、4 个守卫读点。其中
verify-time-range.mjs 在 run.mjs 的 SKIP 集合里默认不跑,所以这条口径在
默认套件里目前没有保护——而 Stage 1 恰好要动 KPI 的位置,所以必须补一条
能默认跑的断言,不能依赖那个跳过文件。
- 10 条断言,每条都写了「必须拒绝的错误实现」和配套变异。其中 V7(把区间塞进
类型轴)是这一阶段最容易犯的错。
- 4.1 节记了搬家会引出、原 scope 没写的后果,逐条对过代码:
F1 .toolbar-row 承载区间那行的 flex,移出表单后静默失配;
F2 form#toolbar 的 submit handler 只为拦搜索框的隐式提交,搬走后表单里只剩
两个 select,role="search" 成了假话;
F3 异常模式的 inert 靠后代选择器 [data-filter-scope],搬家后仍生效,异常轴
本身不带该属性(否则会把自己 inert 掉);
F4 applyFilterScope() 名字说 L2、只写 L4 的口径行——记进 LAYERS.md 的 D7,
本阶段不改名,留给 Stage 2。
跨家族只读查漏试了 codex / claude / codebuddy,三个都被认证挡住(模型不被
ChatGPT 账号支持 / OAuth 过期 / 需要 /login),三次都设了只读机制且比对过文件
哈希,未被改动。所以这份 spec 是「还没查过漏的」,已在 7.1 节写明解锁方式。
24/24 守卫全绿,validate OK。
用 codex / gpt-5.6-sol(OpenAI,与本会话的模型不同家族)做只读查漏。 Windows 沙箱在 exec 下初始化失败,第一次交白卷——它明确拒绝捏造文件:行, 且哈希确认未改动文件。改为把材料内联进 stdin 后跑通。 只有 gpt-6.1-sol 被 ChatGPT 账号拒绝,gpt-5.6-sol 等可用。 它找对的(已接受并改进 spec): - V1 的变异是空操作:把 KPI 接在四组之后时四组仍连续,V1 照样通过。 这是我给守卫定的规矩(变异存活就补守卫)在自己身上失效了。改成把 KPI 插在搜索与区间之间,并让 V2 用「接在四组之后」的互不等价变异。 - V1/V2 共用同一变异,等于其中一条没被测到。已拆开。 - V10 只覆盖一种丢标记方式。拆成 V10a/b/c + V11。 - 第 4 问的口径表漏了一个读点:#kpi-scope 不只是三句措辞,还发布 stats.messages 与 stats.turnsWithPrompt 两个数字。 - 同一份表里我把写 #kpi-scope 的函数写成了 updateKpiScope()——那个函数 不存在,真正的写入者是 applyFilterScope(),也就是 D7 说「名字与职责不符」 的那个。我在自己的文档里把它的名字写错了一次。 - 「保留 form 因为 select 需要 label 关联」是错的:for/id 关联与是否在 form 内无关。改为直接删掉失去对象的 submit handler。 - verify-sticky.mjs「只改指向」不足:现有子树断言只要求类型轴与异常轴同在 band 内,扩到四组必须重写成员与排他性,并显式排除 L1 与 L4。 - I1 用下标比大小发现不了中间的插入;改为解析容器子节点的标签序列。 - I4 的不透明判断太粗;限定为已定义的 --mcode-* token。 - 焦点顺序变化(会话→代→搜索→区间→KPI 变成 KPI 插进筛选流程中间)不能列为 「不验证」——DOM 顺序正是本阶段直接改的东西。新增 F5 并撤销原排除。 - aria-controls 只指向 overview、而同一筛选也控制账本;L2 容器也没有可访问 名称。新增 F6。 - 窄屏下四组全 sticky 可能占满视口。新增 F7 与 B1–B4 四条浏览器补测。 它指错的(已驳回): - 「目前没有保护」过度表述。spec 第 94 行原文是「在默认套件里目前没有保护」, 已经限定范围;它引的第 88 行是表格行,那行里没有这句话。 - verify-sticky.mjs:79 的行号落在 value() 这个辅助函数上,不是它要谈的 子树成员断言。 断言 10 条 -> 15 条(V10 拆三条 + 新增 V11/V12),后果 4 条 -> 7 条, 浏览器补测 4 条。spec 第 8 节留了完整对照表,含驳回项与理由。 24/24 守卫全绿,validate OK。spec 仍未冻结,需再查漏一轮。
Selecting a record and then changing any filter left the inspector describing a row that was no longer on screen. Two functions decided what 'selected' meant and they read different lists: the rows were built from visibleEntries(), while revealSelection() and selectedRecord() scanned state.flat — the whole session, unfiltered. Worse, the only caller of revealSelection() was applyPayload(), and only when the payload signature changed, so no local filter change ever reached it. The ledger read 已显示 2 条, the inspector still named the old record, and no row anywhere carried the selection mark; 复制 JSON and 交给 AI 分析这条 read the same stale record and stayed enabled. revealSelection() is now handed the very array the rows are built from and is called from renderLedger(), the one exit every path that changes what the table shows goes through — nine of them call it directly instead of calling render(). It reports whether it dropped the selection, and the exit redraws the panel when it did: clearing the id alone is not enough, because those nine callers never redraw it, so the search box left the panel naming a record that had just been dropped. selectedRecord() stays a plain lookup and documents why that is safe. The sticky offsets had the same shape of problem. syncStickyOffsets() had exactly one call site — the end of render() — while nine writers change what the table shows without going through render(), so the invariant lived in a convention rather than in the structure. There was also no resize hook at all, and on a session whose payload has stopped growing every poll is a 304 and render() never runs, so a window resize that wrapped the type or 异常 row left the pinned strips at the old width indefinitely. The measurement now runs at the same renderLedger() exit, and resize re-measures through a requestAnimationFrame so a window drag does not measure on every event. docs/LAYERS.md D1 is withdrawn. Treating the filter controls being split across two regions as structural debt was wrong: query conditions are an aggregation, and the frontend UI being separate from the request construction is the design. docs/STAGE1-SPEC.md is marked not-adopted with the reason. D8 and D9 record what was actually wrong, and both are closed. Guards 24 -> 26. verify-selection.mjs runs revealSelection() against a state carrying both the session and the filtered list, so a version that reads the unfiltered one again answers wrongly instead of throwing; mutate-selection.py breaks nine of its rules. verify-sticky.mjs gains five assertions for the new exit and the resize hook; mutate-sticky.py gains seven mutations. One of the nine mutations survived the first run — it moved the call to the line above the slice, which still raised state.limit in time, so it changed the text and not the page; the mutation was rewritten to actually move it past the slice.
A finished session costs nothing: the ETag is derived from stat facts, so an
unchanged session is a 304 and nothing is rebuilt. The cost is the session that
is still growing, which is the one a reader is watching on purpose — and there
every poll rebuilt everything from scratch.
Measured on a 23-generation session (payload.session.sizeBytes = 99,986,501):
* 22 of the 23 generation files are snapshots, written once when the runtime
rotates generations. Between compactions they do not move at all. Only
messages.jsonl is appended to — 97,526 bytes, 0.10% of the lineage.
* trajectoryCacheKey() carries each generation's byteLength, so a growing
session misses the whole-session cache every poll and re-reads and
re-parses all 95 MB, 99.90% of it byte-identical to the previous poll.
* On the wire: 1.31 GB delivered over five minutes to carry 118,875 bytes of
real growth. 11,006x amplification, 99.99% re-sent.
Two fixes, both on the read path.
Per-generation parsed rows. loadParsedRows() keys a cache on
path+size+mtime+limit and hands back the rows of any generation file that has
not moved. Cold 306 ms, warm 2 ms over the 22 frozen files, measured directly
against the real files. Bounded by bytes rather than entries, because entries
say nothing about volume — one generation file is 40 KB and the next is 10 MB.
* The key names the path, not the basename: every session has a
messages.jsonl, so a basename key lets two of them collide. The fixture
builds exactly that collision — same size, same mtime, different
conversations — and asserts neither sees the other's rows.
* The key carries the size and the mtime rather than the catalog's recorded
byteLength, which this file already documents as a snapshot that goes
stale and that the active file grows past between polls.
* The key carries the limit, because readHead truncates at it and the
truncation verdict is part of what the entry holds.
* A file that changes between the stat and the read lands on a key that
describes a size it does not match; the next poll stats again and misses.
The race can only cost a redundant read.
The untouched message, on demand. buildRawPayload() ran JSON.stringify on every
record to measure it against a 64 KB cap — about 100 MB per rebuild — to
produce a string that was thrown away, because splitRawRecords() strips the
message off the bulk response before it is ever serialized and /raw serves the
message object rather than the string. A row now carries the position of its
message instead, and /raw builds it for the one record that asked.
That moves 「原文过大,已省略」 out of the bulk payload and into the /raw
response. It is a contract change, so every reader is listed in LAYERS.md D11
and asserted by name: none of the five is in the ledger — all of them are on
the path a reader takes after opening a record. The only visible difference is
that the raw tab now spends one request to learn a message was too large
instead of knowing it in advance.
Guards 26 -> 30, mutations 34 -> 58, all caught.
verify-parsed-rows.mjs 53 assertions. A hit is compared against a cold read
by serializing both bodies and requiring them equal,
not merely similar. A version that read the wrong
source, or keyed on the wrong fields, produces the
wrong answer here rather than looking plausible.
verify-raw-lazy.mjs 39 assertions. The 64 KB cap is asserted through the
path that now owns it, rather than dropped: the
locator is followed to its row and buildRawPayload()
is run there.
Three things the mutations caught in this change and the guards did not have:
* A locator written as Number(record.rawRow) accepted null as row zero —
Number(null) is 0 — so /raw would have answered with a different record's
message. Now rejected rather than coerced.
* Removing the delete that takes the locator off the row let rawRow ship on
every record. Nothing asserted its absence.
* mutate-pr15-fixes.py had a mutation whose anchor this change erased. It was
reporting SKIP, not a pass, but a dead mutation reads like a green one; it is
re-anchored to the line that now carries the same decision.
Correction to LAYERS.md D10, which this change's measurement disproved: the
read-and-parse was ~306 ms, not the ~1,000 ms written there. That number was
obtained by subtracting the serializer from the rebuild total, which folded
buildTrajectoryPayload and buildRawPayload into "read and parse". D10's
payoff is therefore ~300 ms of a ~1,400 ms rebuild, and the resident heap is a
measured 116 MB rather than the 130-290 MB estimated there.
Two defects, both reported from the same session, both with a cause that was
structural rather than a missing line.
**A — 「显示全部」 froze the page.** Building 18,924 rows inside one
synchronous loop took 3,956 ms of main thread with nothing on screen saying
so. Measured on this machine (150 turns, 18,924 records, 735x900):
batched build (25 turns/frame) 3,956 ms total
row construction median 141 ms / max 151 ms per batch
between frames ~617 ms <- layout + paint of the whole document
The blocking work was not the row construction. It was each frame's layout
pass over a document that was already two million pixels tall, so making the
batches smaller does not help — it multiplies the passes.
The fix keeps the promise and changes the pacing: one rAF builds at most 25
turn groups, then hands the thread back, and the window bar reports
「正在显示 N / M 条」 while it goes. Every pass through renderLedger() bumps
ledgerBuildToken first, so a build still in flight retires itself at its next
step instead of appending into a list that has been cleared underneath it.
The poll tick checks ledgerBuilding() first and remembers the tick — at 3,956
ms against a 2,500 ms interval, letting every tick through would restart the
build before it ever finished and the reader would watch the count reset for
ever. flushDeferredPoll() runs the held tick once the build ends.
Nothing about what the window shows has changed. 「显示全部」 still means every
record.
**Rejected, and recorded in LAYERS.md D12 because it is the obvious next
question:** `content-visibility: auto` on `.turn-group` is a real 4.0x —
3,956 ms to 979 ms, same row count, and the per-frame layout cost disappears
with it. It needs one `contain-intrinsic-size` for elements it skips, and the
150 turn groups span 1,011 px (p25) to 29,754 px (max), median 2,667 px. Any
single value makes the scroll height wrong by a large factor, and `auto` only
remembers sizes of groups that have already rendered. That is trading a
control that describes the page accurately for speed, so it is not shipped.
**B — the inspector survived the record it was describing.** Select an early
record in 「显示全部」, press 「显示最新 200 条」, and the row leaves the table —
but the panel went on naming it. revealSelection() was judged before the
window was sliced and raised state.limit to reach the selection, so the reader's
own click changed the number on the bar a second later:
press 「显示最新 200 条」 已显示 200 条
one second later 已显示 15,224 条
Two things wrong with that: the window became something the inspector could
write, which gave state.limit a second writer, and the test was the wrong one —
「what the reader can see」 is the window, not the filter result.
revealSelection() is now a predicate over the rows that will actually be drawn.
It writes state.selectedId and nothing else, and it runs after the slice. A
selection below the window is dropped rather than revealed: the reader's
explicit window choice outranks an earlier click, and it matches what the
filter axes already do.
**Guards**
- tests/verify-selection.mjs — rewritten, 14 assertions. This is a 口径 change,
not a refactor: the file previously pinned 「the window is pulled down to a
selection below it」 (asserting limit === 8) and that the judgement ran
*before* the slice. Both are now the opposite, and the new promise is asserted
instead, including that the function's source may not mention `limit` at all.
- tests/verify-ledger-batch.mjs — new, 20 assertions over the four things that
have to hold together: the frame is really handed back; a new exit retires the
build in flight; progress goes through updateWindowBar, the one writer of that
count; a poll defers without swallowing the tick.
- tests/mutate-ledger-batch.py — new, 19 mutations, all caught by name.
- tests/mutate-selection.py — two mutations rewritten rather than deleted. The
old 「the window is never pulled down to the selection」 was guarding the very
state.limit write this change removes; it now breaks the rule that replaced
it. The old 「the selection is judged after the window is sliced」 guarded the
opposite ordering, so it now breaks the new one.
- mutate-sticky.py, mutate-turn-breakdown.py, client-check.mjs — re-anchored
and tightened where the refactor moved text. The window-bar total is now
pinned on both of its branches, since it is written twice.
**Verified**
- `node tests/run.mjs` — 32/32 locally and in the fork; `--real` also 32/32
- mutate-selection 9/9, mutate-ledger-batch 19/19, mutate-sticky 14/14,
mutate-turn-breakdown 12/12
- `npm run validate` — mmc-trajectory OK, repository OK
- Browser, served bytes == disk bytes (302,384): the progress readout observed
live mid-build at 「正在显示 15,813 / 19,150 条」, then settling to
「已显示 19,157 条」; with row MiniMax-AI#1 selected and the window narrowed, the
inspector returned to 「尚未选择记录」, no row carried aria-current, and the
count stayed at exactly 「已显示 200 条 · 更早的 18,964 条未加载」 instead of
silently widening. Console clean.
The remaining lever on the A side is making the two-million-pixel number
smaller — capping 「显示全部」, or the incremental protocol. Both change what the
page promises, so neither was done inside a performance fix.
…d back Documentation only. No code in this commit: index.html and the tests are byte-identical to 5b3f29b. **Added section 7 — the eight undone items**, each with what it is, whether it is code / verification / process, what blocks it, and the next step. Item 1 is the one I left myself: D12 only fixed half of the freeze — the batching changed the shape of the wait, not its length. **Revisited the `content-visibility` rejection in D12, because the reason I gave was half wrong.** I rejected it for an 11x spread in turn-group height (median 2,631 px against a maximum of 29,754 px), which is true but is the spread *between* groups. Inside a group the height is not a guess at all. Measured over 152 groups and 19,315 rows: plain rows n=19,292 min 36 median 36 max 36 turn heads n=152 min 39 median 39 max 39 bands n=23 min 38 median 38 max 38 group chrome n=152 min 0 median 0 max 12 Plain rows are min == max == 36 px across all 19,292 of them. The summary column is nowrap + ellipsis, so it never wraps — "row height varies with content" was never true, and I had assumed it was. So I implemented the version that computes `contain-intrinsic-size` per group from (rows, compaction bands), with the three heights read off the real DOM after the first batch rather than written down as constants. It measures: total build 782 ms (3,956 ms batched -> 5.1x; 979 ms for the CSS constant) self-calibrated head 39.40 · row 36 · band 38.40 groups sized 152 / 152 predicted height 705,230 actual 705,529 error -0.04% document height 705,627 at the bottom, `auto` remembering rendered groups Verified in the browser: scrolled to the bottom of the 705,627 px document, rows render with real geometry, and a deep record (turn 27, #2129) is clickable with the inspector filling correctly. **Rolled back, because of something I saw and could not attribute.** With a record selected in 「显示全部」, after roughly three poll-driven rebuilds the viewport went from turn 27 back to turn 1. I did not find the cause and did not rule out that it predates the change. D12's poll deferral was designed for a ~4 s build; at 782 ms it almost never engages, so the table now rebuilds on nearly every poll — every 2.5 s instead of every ~6.5 s — which makes a jump more likely to be seen. That is inference, not a conclusion. The reason for not shipping is not that the gain is too small. The gain is measured. It is that the cost is not measured, and scroll anchoring is exactly what the D9 work exists to protect. Being 5x slower is better than shipping an unlocated regression on that path. D12 now records the numbers, what was verified, what was not, and the first step to finish it: instrument `restoreRowAnchor()` and confirm whether `anchorOffset()` returns a real position or 0 for a row inside a skipped subtree — a skipped subtree's rect reads as 0, not null, and 0 is a position like any other. Section 7 item 1 is updated to say the option is measured and rolled back rather than untried.
上一轮回退逐组推导 contain-intrinsic-size 版(782 ms / 5.1×)的唯一理由,是一次
「视口从第 27 轮跳回第 1 轮」的滚动回归未能归因。本轮把它查清了。
控制实验先做:在**已发布代码**上(无 content-visibility、无探针)跑同一条路径,
视口同样跳回第 1 轮,而检视面板仍正确显示 #2780 · 第 29 轮。所以它与
content-visibility 无关。
给 renderLedger() 的捕获点与 restoreRowAnchor() 打点后的实测(19,626 行 / 155 轮 /
文档高 716,000 px / 视口 841×900):
cap ySave=715080 yReal=616 doc=1515 <- clear(ledger) 把文档压到 1515px,scrollY 被夹到 616
rst after=707051 delta=707056 yOut=708185 <- 从被夹住的位置出发,仍正确还原
cap ySave=715080 yReal=616 doc=1515
(无 rst:这次建行被点击取代,restoreRowAnchor 从未执行)
cap ySave=5530 yReal=1065 doc=1965 <- 点击触发的 renderLedger,锚点已经错了
rst docYWas=5515 docYNow=5515 <- 忠实地还原到了一个错误的位置
restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回
原位。真正的丢失点是 buildStep() 的提前 return:
if (build.token !== ledgerBuildToken) return;
D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,但退出时没人把滚动位置交还给
下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到
顶,之后每轮都拿「第 1 轮」当锚点,自我维持。
触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是
selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s,
所以点一行大概率落在建行途中。
对照组证明变量唯一:同一份代码、同一处点击,点行时没有建行在途,则 delta 0、
yIn = yOut = 105,494,位置纹丝不动。
记为 D14,并给出未实施的修法:把「读者想去哪」从「建行结束时算」提前成
「第一次捕获时记下,此后每次建行都转交」,一个写手一个字段。
同时更新 D12 与第 7 节未做清单:MiniMax-AI#1 的阻塞标记为已解除,新增 #1b 记录 D14。
代码与测试逐字节未动(302,384 bytes),32/32 守卫全绿。
D14 的机制:位置不是丢失,是被摧毁再重建,而摧毁与重建之间没有任何东西。
clear(ledger) 之前 doc = 716,016 px scrollY = 715,080
clear(ledger) 之后 doc = 1,515 px scrollY = 616 ← 文档塌了,浏览器夹断 scrollY
建行 6 帧约 3,956 ms,期间 scrollY 停在被夹住的位置
buildStep() 收尾 restoreRowAnchor() 把位置算回来
restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回
原位。丢失发生在 buildStep() 的提前 return:
if (build.token !== ledgerBuildToken) return;
D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,坏的是退出时没人把滚动位置交还
给下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到
顶,之后每轮都拿「第 1 轮」当锚点,自我维持。
触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是
selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s,
所以点一行大概率落在建行途中。
修法:把「读者想去哪」从「建行结束时算」提前成「第一次捕获时记下,此后每次建行转交」。
一个模块级字段 pendingAnchor,一个写手 forgetScrollAnchor()。
- renderLedger() 只在 pendingAnchor 为空时捕获 {row, y},之后每一趟沿用。
- restoreRowAnchor() 第一句就是 forgetScrollAnchor(),它是唯一以还原结束一链的地方。
- 五条提前返回各调 forgetScrollAnchor():它们把账本换成状态块,没有可锚的行,
丢弃而不是还原,否则会把针对旧会话或旧筛选测出来的位置套到另一个上面。
顺带修掉的一处(是上面那条的前提,不是顺手):ledgerBuildToken += 1 从 applyFilterScope()
那一侧移到 clear(ledger) 之上。四条提前返回都在自增之前返回,它们同样清空账本却什么都
不退休,于是在途建行会继续往骨架屏里追加轮组,随后还会拿一张已不存在的表的锚点去移动
页面。现在自增在清空之前,五条路径走同一句。
口径不变:显示全部仍是全部,窗口条读数不变,选中语义不变。
守卫:
- 新增 tests/verify-anchor-carry.mjs(10 条断言)。捕获那三句是跑出来的——
firstVisibleRow() 与 window.scrollY 换成返回另一个位置的桩,一个每次重读的版本会给出
桩的答案而不是预设的答案;消费者按条数点,clear(ledger) 到 var groups 之间的 return;
必须与 forgetScrollAnchor() 一一对应。
- 新增 tests/mutate-anchor-carry.py,6 条变异全捕。
- mutate-ledger-batch.py 原有两条变异锚在 ledgerBuildToken += 1 后面那一行,自增移动后
失效——已重新锚定到 clear(ledger);,一条未删,并新增一条「把自增放回提前返回之下」
忠实复现本缺陷。
- verify-ledger-batch.mjs 从「数一次自增」加强为「数自增与 clear() 之间的 return;」,
原来那条对四条提前返回完全失明。
浏览器实测(本机,19,991 条 / 156 轮 / 视口 841×900):显示全部 → 滚到第 133 轮 #17188
→ 点一行 → 等重建。建行途中视口短暂塌到第 51 轮(预期内),建行收尾后回到第 134 轮
#17207,正是点行前那一行;再点一行仍在第 134 轮 #17215。修复前同一操作把读者永久留在
第 1 轮。
34/34 守卫全绿,npm run validate 报 plugins/avatasia/mmc-trajectory: OK。
index.html 纯 LF,305,391 bytes。
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
感谢这么快把 D14 修掉。我在 2f6a6ba 上用和之前相同的合成会话,和 main 对照着复现了一遍:「显示全部」下单击、在建行途中连点,以及默认 200 条窗口但轮组超过 25 个的情况,最后的位置都和 main 一致(第 75 轮 / 第 248 轮),verify-anchor-carry 和 mutate-anchor-carry 也都通过。Node 22 下 npm run check 全部通过,CI 是绿的。
需要修改
tests/run.mjs:42还是写死了python。 在只有python3的环境下,node tests/run.mjs结果是19/34,15 个mutate-*.py(包括新加的mutate-anchor-carry.py)全部 FAIL,而且没有输出原因;加一个python → python3软链接后 34/34 全过。建议依次尝试python3、python(或允许用环境变量指定),并把r.error打印出来。- README 中英文第 27 行还写着类型数组「只由三个控件写入」、异常筛选「不改写类型数组」。现在勾选异常规则会调用
resetBaseFilters(),把类型改回全选,并清空搜索和时间区间,README 需要同步更新。 - 插件目录里的文档请清理一下。
docs/STAGE1-SPEC.md已标明不采纳,也没有地方引用,里面还有codex exec …、~/.codex/models_cache.json这类本机环境内容;docs/LAYERS.md第 480、484、485 行是个人流程记录(OAuth 过期、.probe临时文件待删、reviews=0)。用户安装时会复制整个目录,建议删掉 STAGE1-SPEC.md,LAYERS.md 只保留设计结论。
建议(可选)
dispose()清了trajectoryCache,但没有清parsedRows,补一行parsedRows.clear()就能和其它缓存保持一致(39MB 的会话 dispose 后还多占约 43MB 堆)。- 建行期间账本仍会塌缩(实测 114710px → 19786px),大会话在建行那几秒里视口显示的是别处的内容,此时点击会命中别的行。可以考虑在
clear(ledger)前固定min-height,等restoreRowAnchor之后再释放。 - 异常模式下没有命中时,空状态仍提示「可以放宽类型或搜索词」,但这两个控件这时是 inert 的,可以改成提示点「重置」。
- PR 描述还没有覆盖异常筛选、
tests/的整体说明和这次的 D14 修复,squash 时提交信息会比较片面,建议补全。版本号也可以考虑从1.0.0升一个 minor。
| for (const name of files) { | ||
| const isPy = name.endsWith('.py'); | ||
| const cmd = isPy | ||
| ? ['python', join(HERE, name)] |
There was a problem hiding this comment.
这里写死了 python,在只有 python3 的 Linux/macOS 上 15 个变异测试全部失败(实测 19/34),因为没有输出 r.error,尾行是空的,看不出原因。建议依次尝试 python3、python(或读 process.env.PYTHON),找不到时把 r.error.message 打印出来。
There was a problem hiding this comment.
已改。run.mjs 现在按 [$PYTHON, 'python3', 'python'] 依次探测,命中即 break。
你提的第二点比写死本身更值得记:一个从未启动的进程既没有 stdout 也没有 stderr,所以 tail 取到空串 —— 34 条里 15 条全红,而输出里找不到任何理由。现在找不到解释器时会打印 no Python interpreter found; tried ... 并附上错误信息。
另外补了一条你没提的:同一轮只能有一个解释器。探测循环命中即 break,否则两台都有的机器上会由「最后一个能用的」静默接管。
这一版还没推送到本 PR,你刷新 diff 才能看到。
| @@ -0,0 +1,247 @@ | |||
| # Stage 1 冻结 spec:把 L2 在文档里收成连续的 | |||
|
|
|||
| > # ⛔ 不采纳 — 请勿实施 | |||
There was a problem hiding this comment.
这份文件已经标明不采纳,也没有地方引用,内容里还有 codex exec 参数和 ~/.codex/models_cache.json 这类本机环境信息。它会随插件目录一起安装到用户机器上,建议删除,否决理由保留在 LAYERS.md D1 即可。
There was a problem hiding this comment.
已删(不是标「不采纳」)。否决理由留在设计文档对应条目里,不另存一份。
补一处更正:你说「没有地方引用」这点不准 —— 设计文档里有两处引用,所以是连引用一起改的,删完不留悬空指针。
| | 7 | **跨家族验证只有 codex 一条腿** | 验证 | 环境 | claude OAuth 过期、codebuddy 要 `/login`,需 chli 交互式处理。 | | ||
| | 8 | **PR #16 reviews=0**;`.probe\perf-payload.json` 34 MB 临时产物待删 | 流程 | 等 maintainer | — | |
There was a problem hiding this comment.
这几行是个人流程状态(OAuth 过期、.probe\perf-payload.json 待删、reviews=0),不属于设计文档,建议移到 PR 描述或本地笔记里。
There was a problem hiding this comment.
已删。第 7 项「跨家族验证只有一条腿」保留 —— 它是真待办,只把「需在交互式会话中处理」这种把读者当行动人的写法改成中性描述。
另外这份文档整份移出了插件目录:plugin.json 没有声明 files/exclude,整个目录会随插件安装到每个用户的机器上,一份带本机环境信息的设计记录不该跟着一路装过去。现在它在开发仓的本地笔记里,仓库里没有对应文件。
但这一版还没推送到本 PR —— 你现在在 diff 里还能看到那份文件。推送之后这条回复才成立。
…17 ms(D15) D12 把等待的形状改了(进度可见、分批交还主线程、在途可打断),等待的长度没变。 本条改长度。 做法:.turn-group 的 content-visibility: auto 由账本容器上的一个 class 打开,不是 无条件打开;class 只在第一批建完、并且从真实 DOM 量出行高/带高/头高之后才加上—— 写死的常量就是猜别人屏幕的字体与缩放,所以不写死,用中位数不用均值。之后每个轮组 在建行那一刻按 (行数, 压缩带数) 算自己的 contain-intrinsic-size,格式带 auto 前缀, 让浏览器优先采用它已经渲染过一次的真实尺寸,来回滚动不累积漂移。 口径不变:「显示全部」仍然是全部,窗口条读数不变,选中语义不变,粘性偏移不变。 syncStickyOffsets() 仍是 buildStep() 的最后一句。 实测(同一会话内的 A/B,两臂都在同一浏览器、同一份数据上量): 基线(class 不加) 3,437 ms 159 组 20,246 行 逐组推导(class 加) 817 ms 159 组 20,182 行 文档高误差 +0.07% D12 当初挡下这条路的那个疑问已有实测答案:content-visibility: auto 跳过的子树里, 行仍然有真实布局盒——离视口 733,916 px 的行报出 top: -733915.94, w: 776, h: 36, 不是 0 也不是空矩形。于是 anchorOffset() 在 cv 之下成立,D14 的锚点没有被破坏: 顶部、深处(640,165)、近底部(740,165)三个位置跨十几趟重建,preY 与 postY 逐 像素相同,期间会话从 20,552 长到 20,573 行;深处点一行之后屏上仍是同一批行。 已知未解释项(已在 docs/LAYERS.md 记为待复现,不写成缺陷):某一趟近底部的重建 postY 比 preY 高了 77,226 px 而屏上行未变,之后同一深度未能复现。 守卫:verify-intrinsic-size.mjs(14 条)+ mutate-intrinsic-size.py(9 条变异全捕)。 另重新锚定 mutate-ledger-batch.py 里一条因 build 字面量新增字段而失效的变异。
R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于 docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。 verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对, 不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不 声明,配对就不成立。 声明 1 #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress) 声明 2 #overview 持有写 L2 字段 state.overviewSelection 的控件 六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加 一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选 落点不再写该字段、让 L2 入口不再读它。 第 6 条变异重锚过一次:var selection = state.overviewSelection; 在 renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中 的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。 本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点 (clearOverviewSelection / setActualDuration / onOverviewPointerMove / onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。 断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都 是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。 同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码: 按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。 Stage 2(异常轴建模,MiniMax-AI#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly 3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出 {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变, 但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里 守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。 Stage 4(增量协议 ?since=,MiniMax-AI#2)调研结论改变了收益估计:payload 只有 session / stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以 ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。 这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全, 写侧只有 1 处。 产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。
…(Stage 2-a) Stage 2 三步里的第一步:先让状态是一个模型,再谈入口(D5 / LAYERS.md)。 原来 axis 上有三个顶层字段 —— anomalyRules、multiAnomaly、anomalyLast。 「有没有规则开着」这一个判断要从三个地方重新推导,于是「关掉其中一个而 没关另外两个」读起来不像 bug、像另一个状态,它们之间的协调只能活在调用 顺序里,因为没有共同的模型可以住。现在是一个字段,rules / mode / last 三 个读点都在同一个对象上。 mode 用 'single' | 'multi' 字符串而不是布尔:每个读都是一次显式比较, 而不是 `multiAnomaly || !multiAnomaly`。`!state.multiAnomaly` 那种写法读 起来像「不是多选」,而它在 单选 与 未知 之间是同义的。 清零走工厂而不是共享常量:defaultAnomaly() 每次给一个新对象。一个被两处 共享的常量就是同一个双写者缺陷换了个位置。 零产品行为变化(这是 2-a 的口径): - 账本读点 2 处、strip 读点 1 处、按钮读点 2 处 —— 全部原样改写 - 合计口径不动:anomalyCounts() 仍走自己的计数通道 - isFiltering() 仍只看 rules.length —— mode 不是 filter,勾上 多选 不该让 「清除筛选」变成可点 新增 tests/verify-anomaly-model.mjs(15 条)守这个模型本身,刻意不守 「已收敛」—— 那个断言现在过不了,写成「当前 N 个」又恒真。守的是可以被 证伪的四条:一个字段、两条重建路径、一个工厂、mode 不是布尔。 mutate-anomaly-model.py:7 条变异,覆盖新建点被绕过、工厂被共享对象顶替、 mode 被写成布尔、mode 泄漏进 isFiltering。 重锚(不是新增断言,是把旧断言的锚点从旧字段名换到新字段名): - verify-anomaly.mjs:fixture 声明合并成 anomaly 对象;19 处 multiAnomaly 的读写换成 mode 的字符串比较;10 处形状断言改锚 - mutate-anomaly.py:20 条变异的锚点重锚。两条变异(strip 分支前置于另一 个轴、单击关掉已开的规则)在重锚过程中 mutant 侧一度被写成和 old 侧等价 —— 那是空变异,跑出来是「NOT CAUGHT」而不是失败。已改回。 39/39 绿(原 37/39)。index.html 310,348 → 311,217 bytes,纯 LF。
…tion)(Stage 2-b)
R2 闭合。原来「这条记录在不在屏上」有两个函数回答:visibleEntries() 和
spanPassesFilter(),各自持有完整判断。这不是整洁问题——本页已经因为两份
拷贝漂移过一次:总览条画出账本拒绝渲染的跨度,读起来是「筛选了但什么都没
变」而不是 bug(D 类缺陷的结构来源)。
三处判断,逐条对着代码:
1. 笔刷是参数,不是 state 读。两个入口对笔刷的处理本来就不一样:账本用,
总览条不用。总览条必须不用——笔刷画在它上面,一个把自己正在被拖选的记录
藏起来的条没法用来拖选。写进签名是为了让下一个人不会「顺手修正」它。
断言守的是 recordVisible() 体内不出现 state.overviewSelection。
2. turn 是参数。flattenTurns() 产出 {turn, record},账本拿得到;总览条只拿
到裸 record 所以要从 turnById 查回。两者本来就是同一个对象。断言守的是
账本不自己查——它手上已经有那个对象,再查一次是同一个对象换个来源,
将来两边会长歪。
3. 互斥由 return 表达,不由 else 表达。「二选一不是叠加」这条断言原来只读
`} else if (!passesBaseFilter`,守的是 else 的存在。新的断言要求异常分支
return,且基础判断写在它之后——一个只判不回落的分支就是叠加,只是拼法
相反。
anomalyContext() 随之删除:它只是为了把 entry 适配成 (record, turn),而
recordVisible() 直接收这两个。passesBaseFilter() 删除:唯一调用方就是
visibleEntries()。
新增 3 条断言 + 3 条变异(70 → 73)。其中最强的一条是「总览条体内一个谓词
都没有」——断言两个调用方都不可持有判断,而不是都应用了异常。适配参数形状
不是第二意见,再回答一次才是。
变异重锚(不是删除):
- 「总览条不应用 异常」→ 现在只有一个谓词持有者,改锚到 recordVisible(),
名字跟着改(仍破同一件事:轴不生效)
- 「账本不应用 异常」→ 改锚到账本的调用点,整条替换为不过滤
- 「两个轴叠加而非二选一」→ 原来是去掉 else,现在去掉 return
- 「模式读反」→ visibleEntries 里的 anomalyMode 没了,改锚到 recordVisible
的签名 + 第一行(`> 0` 在 recordVisible 与 applyFilterScope 各出现一次,
锚必须带签名才能唯一定位)
verify-time-range.mjs 的三条断言同步改写。它不在 run.mjs 的默认集合里(需要
起服务 + 多代会话),已对着 55344 手跑:149 断言全过。
index.html 311,217 → 311,482 bytes,纯 LF。39/39 绿(默认与 --real),
73 变异全捕,validate OK。
过程事故:移除 anomalyContext() 时探针从签名向前回溯找 /**,跨过空行把刚插入
的 recordVisible() 一起删了,脚本仍报 ok 并写盘。由 fnSource 抽不出
recordVisible 暴露。已从 fork 复位,回溯改为「注释紧贴签名才吸收」,并让探针
打印删了多少行。空变异同理:mutation control 只报 probe still passed,不会
告诉你它什么也没测。
D2:state 里曾有两句话各自宣称自己在描述「筛选是什么」—— 「The type chips are the only filter」与「This is the one filter the KPI follows」——两个模型不兼容,而没有一层裁决哪句作废。两句各自都对,都在描述 一个更早的页面。 三处处置: - KIND_ORDER 上方:删掉独占声明,保留「一个数组、一个写者」那半,并**列出另外 三个也在收窄表格的条件**(异常轴替换它、区间按墙钟裁、笔刷按总览裁)。 删掉独占声明却不说清剩下的是什么,等于把一句假话换成一句空话。 - kinds: 字段上方:整句删掉,保留后面关于折叠的段落——那段仍然成立。 - rangeFrom 字段上方:加限定词 →「Of everything on this layer, the KPI follows the range and nothing else」,并写明异常轴开着时 recordVisible() 一个都不查。 **这句话本身被守了**(verify-anomaly-model.mjs 15 → 17 条):index.html 里 "the only filter" 与 "the one filter" 各必须出现 0 次,而带限定词的那句仍在 README.md。守一段散文看起来奇怪,但这是本模型唯一一处改错了也不会有任何运行 时报错的地方——而一句与架构相矛盾的注释,不因为它是注释就不算假话。两条变异 分别把它插回 KIND_ORDER 上方与 kinds: 字段上方。 写入替换注释时踩了一个自指的坑:第一版把被删的原话原样引述在新注释里,于是 「零次出现」这条断言被自己的解释文本触发。改写为「claim exclusivity … with no qualifier」。 **`resetBaseFilters()` 变一次赋值那一半,本轮显式不做。** 要「一次赋值」就得把 基础轴收成 state.base + 工厂,与 2-a 对异常轴的做法对称。不做的理由是它越界: Stage 2 自己的「归层」答案是「不改 L1/L3/L4/L5/L6 任何一层」,而 overviewSelection 是 L3→L2 的回写(R5 有 5 个写点未闭合),收它就改了 L3 声明的形状;代价约 40 个站点横跨 L3/L4/L5,还要改写 verify-cross-layer.mjs 的断言。已作为未做清单第 9 行记录,并注明「要做的话四问要重答」。 **顺带修掉 2-a 的一个漏**:verify-anomaly-model.mjs 的文件头写着 「mutate-anomaly-model.py breaks each rule below」,而那个文件当时并不存在。 树里一句没有任何东西在检查的声明,正是这条守卫本身要抓的缺陷。现补上,16 条 变异全捕——其中 5 条初版写错,值得一提:三条只加了不带 `state.` 前缀的键,那 不是「第二个答案」只是一个死键;一条改了条件却没删赋值;一条把写者改成自赋值, 写者计数根本没变。变异必须真的改变被计数的东西,锚点命中不等于变异有效。 index.html 311,482 → 311,945 bytes,纯 LF。40/40 绿(默认与 --real), 73 + 16 变异全捕,validate OK。
本轮唯一一处真口径变更:applyPayload 从整体替换改为追加合并。 请求多一个可选游标 ?since=<整数 ≥ 0>;响应多一个顶层键 delta。**有 delta 就 合并,没有就整体替换**——这个标记是判定的唯一依据,客户端因此不需要猜服务端 这次发的是哪种。游标缺失 / 空 / 非整数 / 负数都不是错误,一律当没有游标返回 整包:什么都还没有的页面需要的就是整包,把首轮轮询变成 400 是比发 41 MB 更 差的答案。 三个判断: 1. stats 整包来,因为它是个累加器——里面每个计数都是遍历全部行累加出来的, 尾部无法被「加」到它上面,要那样就得由客户端重新推导服务端的算术。session 同理:几百字节,而用上一份回答它会让每个 payload 依赖一份服务端并不保留 的历史。 2. 轮整轮携带,只切 records。这重复了页面已有字段,是刻意的:对话进行中 closed 会翻转,而 无答复 由它判定——发残缺的轮,页面就会对唯一那个还在被 写入的轮持有过期的 closed。 3. 游标超过末尾返回 null(即整包)。那是页面跑到服务端前面、也就是从服务端看 「切了一代」的样子;从那儿切出的尾部是空的,而空尾部与「没有新东西」不 可区分。 服务端只在冷构建路径切尾部。缓存命中那条路径不处理 ?since= 且不是遗漏:带 游标的页面一定也带着它对应的校验器,所以带着游标走到这一行意味着会话变了。 而从缓存 payload 里切尾部还要把那个对象图留在内存里,这个缓存刻意不留。 客户端游标与校验器同一个条件、同样绑定 cache key(state.since / sinceFor), 手动刷新同时丢掉两者——它是在向服务端要真相,尾部不是真相。合并以 body.delta && state.payload 为前提,缺一不可。mergeTurnRecords 按 record.index 取并集而不是追加,所以同一个尾部重放两次什么也不会变:重试和重叠轮询都会发 生,只在顺利路径上正确的协议会在第一次重放时丢记录。 **省的是传输与客户端 JSON.parse,不是服务端 CPU。** 实测(冷缓存,同一会话): 整包 43,159,724 bytes 服务端墙钟 1,124 ms ?since=21584 19,663 bytes 服务端墙钟 1,124 ms (0.046%) 服务端墙钟两次相同:它仍全量读、解析、构造、序列化(缓存条目与 /raw 都需要整 包 body)。这不是推算,是量出来的。 浏览器端:窗口条「共 21,680 条」与同一时刻 API 的 21,679 条同步,差 1 条正是 两次读取之间我自己产生的记录——合并既没丢也没重。console 零 error / 零 warn。 网络面板:无变化轮 304 约 30 ms,会话增长轮 200 约 640–710 ms(服务端那部分没 变,正如预期)。 **证据边界**:网络面板不返回响应体大小、也不返回 URL query,所以「那些 200 是 尾部」不是从面板读出来的,是冷缓存直连测出来的;面板只能证明轮询节奏与状态 码。304 在面板里显示 outcome: failed + ERR_ABORTED(304 无正文,页面在读正文 前就 return),这条路径本轮一行未改,但**我没有做改动前的 A/B 对照**——按推理 它早于本改动存在,没有实测证据。 守卫 verify-incremental.mjs 63 条 + mutate-incremental.py 16 条变异全捕(同时改 server.mjs 与 index.html 两个文件)。核心是往返等式 mergeDelta(整包, delta) == 整包——不是「差不多」,是轮、closed、记录一次不多一次不少地回到原样,在 6 个 游标位置各跑一遍;其余断言都在回答这条等式为什么会不成立。其中一条是手算的 小合成用例,因为 fixture 证明不了:里面没有正在增长的轮。 两处过程事故记在 docs/LAYERS.md: - 守卫自己崩过一次——变异让 find() 返回 undefined,守卫直接读属性。崩在守卫不 在产品,但同样让变异拿不到「按名字捕获」的判定;守卫的健壮性也是它能不能当 证据的一部分。 - 变异控制把「通过」误报成「崩溃」:判崩溃靠「有没有打过报告」,而这份守卫只 在有失败时才打 FAIL 行,空报告被当成崩溃,每一条存活的变异都被伪装成崩溃。 从一个 runner 抄来的判定逻辑,要先确认被抄的那边真的总是打报告。 index.html 311,945 → 317,167 bytes(纯 LF),server.mjs 107,132(纯 CRLF)。 42/42 绿,16 条变异全捕,validate OK。
codex 交叉核对 Stage 2-c 时查出来的:名为「reset 忘了搜索框」的变异删的是
state.query,而守卫沙箱把 dom.searchInput 设成 null,于是
if (dom.searchInput) dom.searchInput.value = '';
这一行在任何断言下从未执行过。那两处 DOM 清空是无覆盖的,而一条不覆盖它
的断言顶着它的名字——正是本文件反复记的那类缺陷,这次出在自己写的守卫上。
新增断言 and empties the search box itself, not just the state behind it,
配变异「reset 清了 state 却让搜索框留着旧文本」。238 断言 / 74 变异。
同时修正 docs/LAYERS.md 里 codex 查出的三处错:query 站点数 ~15 实为 7、
合计约 40 实为约 35、失败模式的后果说反了(症状是取消异常规则后筛选悄悄
复活,不是它继续限制异常结果)。
2-b 的探针插入函数时写了新的 `/**`,没有删掉旧的那个,于是 recordVisible() / visibleEntries() / spanPassesFilter() 三个函数头上各多了一个空注释块。第二个 `/**` 只是第一个注释里的文本,块在后面第一个 `*/` 收尾——页面照常渲染,42 条守 卫一条没红,validate 报 OK,publish 过了,浏览器实测也做完了。本轮收尾扫到 「连续两行 `/**`」才发现,一共三处。 这类残渣没有任何行为守卫能看见,所以按结构守: - verify-comment-shape.mjs(6 条):无相邻 `/**`、无相邻 `*/`、两者计数相等、 每个独立成行的 `/**` 后跟自己的正文,外加一次正向走查(closer 不得跑在 opener 前、EOF 不得还有块没关)。 - mutate-comment-shape.py(4 条变异全按名字捕获)。第一条变异就是那个探针的 原样动作——把 recordVisible() 头上那个 `/**` 复制一份——不是构造出来的。 文件里另有两处连续空行,对 origin/add-mmc-trajectory-tests 核过是基线就有 (基线 dblBlank=2、dblOpen=0),与这 8 个提交无关。顺手删过一次又放回去了:本 次改动只该包含本次引入的缺陷。 2-b 按第 5 节的要求补了一次独立浏览器实测,证据链不靠页面自说: - .probe/stage2b-union-recompute.mjs 直接遍历 /api/trajectory 的 turns,按 ANOMALY_RULES 写的谓词自己判:工具失败 484、上下文压缩 27、并集 511、两者同 时命中 0、空答复 3、无答复 15 轮。 - 页面几分钟后读:485 / 28 / 15 轮 / 3 条 / 全部异常 2,323。不该漂移的两格 (15 轮、3 条)与探针逐个吻合;漂移的两格各 +1,因为会话是活的。 - 勾多选 + 工具失败 + 上下文压缩,三个互不依赖的面同时读:提示语「已叠加 2 条 规则 · 合计 513 条」、窗口条「已显示 200 · 更早的 313 未加载」、两者相加 200+313=513,与探针的 511 加上同样的两次漂移一致。并且 513 ≠ 2,323——这 就是两轴互斥在数字上的样子,不是「在 2,323 里再筛一遍」。 - 重置后回到「共 22,066 · 已显示 200 · 更早的 21,866」(相加等于 22,066), console 零 warn 零 error。 顺带记下一处既有的口径混用(未做清单第 10 行):筛选开着时窗口条印「共 22,066 条」取自 state.flat.length(整场未筛选),而「已显示 + 未加载」来自 matched (筛选后),相加只有 513。基线 index.html:5202 / 5206 就是这两行,8 个提交一行 未改,所以不是 Stage 2 制造的,也不该在这里顺手改——改它要重答「口径」那一问。 verify-comment-shape.mjs 带一个 --list,mutate-comment-shape.py 用它把「守卫里 没有这条断言」(写法错)和「断言在但没触发」(逻辑错)分开报——两种都非零退出, 但是两种不同的 bug。 tests/README.md 的 Files 表里 mutate-anomaly.py 写着「70 条」,实测已是 74。 验证(fork 内):node tests/run.mjs 44/44,--real 44/44,make-fixtures --check 未漂移,npm run validate 报 OK。publish 后 served = disk = fork = 317138 字节, 服务版本里孤立 `/**` 为 0。
用户报:滚到最后一条消息,点它出检视,往上滚一点,直接落到页面最上面。
不是滚动逻辑的 bug。账本塌了。
现场(读出来的,不是推理的):
- 文档最大滚动 1,223 px(视口 900),即整篇约 2,123 px
- 而 #ledger 的 HTML 有 140 KB,窗口条写着「已显示 200 条」
- 每个轮组的内联样式都是 contain-intrinsic-size: 0px
content-visibility: auto 把离屏元素按 contain-intrinsic-size 排版,没有这个尺寸的
元素按空元素排。于是 0 写上去、组占 0 高、永不回视口、永不重渲染。因为塌了,往上
滚一点就顶到头。
根因不是顺序,是 class 的寿命。is-skipping-offscreen 加在账本元素上,而
clear(ledger) 只清它的子节点、不动它自己的 class,所以一次建行加上的 class 一直留
到下一次建行:第二次之后挂上去的每个轮组一落地就是被跳过的,sizeBuiltGroup() 量到
的就是那个 0。
两处修正,缺一不可:
1. 建行开始前把 skipping 关掉(renderLedger 里 token 自增之后、clear(ledger) 之前
ledger.classList.remove('is-skipping-offscreen'))。治根因。
2. class 加在第一批的测量之后。治「测量结果取决于读者当时在哪」。
只做 2 只救得了页面加载后的第一趟建行,之后每次轮询重建照旧量到 0。这一点是踩出来
的:先只做了 2,发布后第一趟量到 7,279 px 的正常文档高,以为好了,等到第二次轮询
重建,轮组又回到 0px。
为什么 D15 自己的 A/B 没抓到:那张表是在「显示全部」下量的,159 组、首批 25 组落在
文档顶部、读者也在顶部,于是它们在视口内,content-visibility: auto 不跳过,量到真
高度,sized=159/159、误差 +0.07% 全都成立。那组数字是真的,但它是在一个恰好掩盖了
这个缺陷的条件下测的。跳过的判定是离屏与否,所以缺陷只在读者不在首批附近时发作——
默认窗口只有 200 条、跨 2–3 个轮组,小于 LEDGER_BATCH_GROUPS = 25,整个账本就是
首批,而读者为了看最后一条消息在底部。
修后实测(同会话、默认窗口、真实轮询下):
| | 修前 | 修后 |
| ---------------------- | ------------------- | ------------------------------- |
| 轮组 intrinsic-size | 0px | 7239px(跨约 8 次重建不变) |
| 文档最大滚动 | 1,223 px | 6,907 px |
| 滚到底后上滚 300 px | atStart: true | actualDeltaY: -300, atStart: false |
| 跨 5 次轮询再上滚 300 | — | 同样精确 -300,停在账本深处 |
console 零 warn / 零 error。
守卫:verify-intrinsic-size.mjs 新增两条断言(14 → 16),各配一条变异:
- the class goes on after the first batch has been measured, not before
- the ledger turns skipping off before it starts building, so a rebuild does
not measure under it
mutate-intrinsic-size.py 9 → 11 条变异全捕。
三处变异锚点重新锚定,一条都没删:
- mutate-intrinsic-size.py 的「class is switched on before the first batch
exists」锚在 ledgerBuildToken += 1;\n clear(ledger); 这对相邻语句上,
而这两行之间现在插了新的 remove。
- mutate-ledger-batch.py 里另有两条变异复用同一对相邻语句,三条一起断了。统一锚到
单独那行 bump(全文件唯一)——那才是规则真正讲的事。
新断言自己踩了一次锚点教训:一开始用 render.indexOf('clear(ledger)') 定位,索引 628
落在我自己写的注释里(renderLedger 的文档注释有一句 "clear(ledger) below collapses
a 716,000px document"),真正的语句在 3,143 之后,断言因此恒假。改成带分号的语句
形态才命中。能匹配到注释的锚点不是锚点。
未做清单第 1c 项那条「未解释的位置跳变」(postY 比 preY 高 77,226 px)很可能就是这
个缺陷的副产品——文档塌掉再长回来,77,226 ÷ 36 ≈ 2,145 行,与 25 个轮组量级相符。
但我当时是在默认窗口下复测的,那条观测是在显示全部下做的,两个条件不同,所以写
「很可能」不写「已解释」,要关掉它需要在显示全部下跨重建连续记录。
验证(fork 内):node tests/run.mjs 44/44,--real 44/44,make-fixtures --check 未
漂移,npm run validate 报 OK。publish 后 served = disk = 319331 字节,两处修正都在
服务版本里。
On a wide screen the ledger is the thing that scrolls — a quarter of a
million pixels of it — and the inspector sat at the top of the right
column as a static block. Measured at 1276x1280 with one record chosen it
is 833px tall at the top of a 7,545px grid row, so it scrolled away after
roughly 600px and stayed gone for the rest of the trajectory. The reader
selects a record, scrolls, and the thing they selected has left.
This is the mirror image of what D36 does on a narrow screen, and
deliberately not the same mechanism. There the panel pins to the viewport
BOTTOM because it is the last thing in the document; here it pins just
under the stacked strips, because the thing that scrolls is beside it
rather than below it. Both say "stop scrolling away", and they take
opposite offsets for the same reason.
* #inspector-panel becomes sticky above 1080px, offset by the sum of the
two measured strips (--sticky-band-h + --sticky-overview-h) rather
than a number. The window bar is left out of that sum: it only ever
occupies the left column, and adding it would drop the panel 45px for
nothing.
* --inspector-wide-h is a ceiling, written by syncStickyOffsets() from
window.innerHeight, with the panel scrolling inside itself. A record
measured 1,022px tall, so without a ceiling the panel would simply
grow past the fold again — the same defect by a different door. It
cannot be 100vh: this page runs in a host iframe where vh is not the
reader's viewport, which is the whole reason client-check bans it.
The comment above the block spells out a trap this block paid for once.
CSS comments do not nest, so a comment that writes the terminator inside
itself ends there, and everything after it becomes a selector prelude
rather than a rule. That shipped: the browser held no such rule at all
while every guard that reads text stayed green.
Measured after the fix:
1276x1280, one record chosen — panel sticky at top 135px, ceiling
1133px, visible height at eight scroll stations 692 / 833 / 833 /
833 / 833 / 833 / 833 / 833, never 0. Ledger column 7,403px.
1366x768 — ceiling 621px = 768 - 84 - 51 - 12. A real record (row 0,
summary) measures 831px, so the ceiling is doing real work: the panel
box holds at 621, its own scroll reaches the bottom, and the panel
stays inside the viewport at top 135 / bottom 756.
897x900 — unchanged: static, max-height 825px. The narrow path is
untouched.
verify-sticky carries the change the new JS forced on it: syncStickyOffsets()
reads window.innerHeight now, so the vm stub has a window. The gap constant
is read out of this file by pattern rather than written into the stub, and
the wide panel's max-height joins the set of properties the guard measures —
without it the new variable reads as a dead write.
mutate-sticky's window-bar anchor gained the line after it. The anchor was
the band's calc() expression at eight spaces of indent, and the wide rule
writes the same expression at ten, so the anchor had become a substring of
the new rule and matched twice.
Ten assertions in verify-inspector-dock for the wide-screen rule itself: that the block exists and sits above the breakpoint, that the two queries do not overlap, that top is the sum of the two measured strips rather than a literal, that the window bar is excluded, that nothing is pinned to the bottom, that the ceiling comes from window.innerHeight and not from vh, that it has a single writer, that the gap is a named constant, and that the block touches no selector but the panel's. Ten mutations in mutate-inspector-dock break each one in the shipped file; all are caught by name. One assertion was renamed rather than added. "flow is not pinned: the base rule declares no position at all" had always read the narrow query, not the whole stylesheet — it was named after a rule broader than the one it checked, which is the kind of name that makes a later reader trust it too much. It is now "flow is not pinned on a narrow screen". verify-sticky grew a window in its vm stub, because syncStickyOffsets() now reads window.innerHeight. The gap constant is read out of the shipped file by pattern rather than written into the stub: a stub carrying the answer is a test that agrees with itself. verify-comment-shape only ever read JS block comments, which is why the D40 break sailed straight through it. It now reads the <style> block the way a browser would — an opener starts a comment, the first terminator ends it, and a second opener inside the body is inert text — and asserts four things about it: every comment is terminated, every terminator ends its line, no backtick survives outside a comment, and no comment closes on a line of its own. Three new mutations reproduce the failure that shipped, and each is caught by the assertions named for it. METHODOLOGY 8.5 records why. An assertion about a rule proves the text is there; only the browser proves a rule exists, and between those two there was a step nothing here was watching. The check that finally saw it was not a parser at all — it was the residue. Prose at the top level of a stylesheet is never valid CSS, and this file quotes prose with a backtick every time, so a comment that closes early leaves its evidence standing in the open.
On a wide screen the report was that the header disappears as the
inspector scrolls: what is left is the tab bar and a table that starts
part-way through, with no title and no handle.
The header was never given the job on a wide screen. Its sticky rule lived
inside the dock's own media query, and the comment beside it said why:
above 1080px the panel is not a scroll container and there is nothing to
stick to. D40 gave the wide panel a ceiling and an inner scroller, so that
reason stopped being true — and the rule went with it, silently, because a
rule inside a media query stops applying without saying so.
Measured at 1600x900 with one record chosen, before the fix: the panel was
already `overflow-y: auto` with 80px of internal scroll, and the header
computed `position: static`, `top: auto`, `z-index: auto`.
So the rule moves out of the query and up to the top level, beside the
`.panel-head` base declaration it belongs with. Narrow screens keep byte-
identical behaviour, because they were the case the rule was written for.
The comment moves with it and is rewritten: it now says the panel scrolls at
every width, rather than saying why it used not to.
Unscoping a sticky header is only safe while the panel is its own scroll
container — otherwise the header falls back to the viewport and follows the
reader down a 7,000px ledger column. Both widths declare `overflow-y: auto`
on `#inspector-panel`, so that holds today; it is asserted rather than
assumed.
After the fix, measured with the panel scrolled to its bottom:
1600x900 — internal scroll 1,892px; header sticky at top 0, sitting on
the panel's top edge, 49/49 visible, title intact.
900x900 — internal scroll 2,141px; the same six readings.
The 1px between the header's top and the panel's top edge is the panel's own
border.
One number moves and nothing else does: the panel's box ceiling
`--inspector-wide-h` is unchanged, because a ceiling is a box, but the
readable content is now that box minus the 49px header sitting on it. No
surface promises a figure for readable content, so there is nothing to
restate.
Two assertions in verify-inspector-dock were rewritten and one added, 126
to 129. The two that were rewritten were not wrong before D40 — they
asserted "sticky only where the panel scrolls", which was a true statement
about a premise that had since died. They could not have gone red, because
a guard cannot notice that the reason behind a rule changed while the rule
still reads the way the guard remembers.
* "the panel header stays at the top while its content scrolls under it"
now reads the whole stylesheet rather than the dock's media body, and
says "at every width" in its name, because that is the scope it now has.
* "the sticky header belongs to the inspector panel alone, and only where
the panel scrolls" became "the sticky header is declared once, at the top
level, for the inspector panel alone". The first half is unchanged in
intent and now has to be checked properly: `valueIn` matches any
line-initial selector, so a rule buried in a media block reads exactly
like one at the top of the stylesheet. Distinguishing them means counting
braces to find every media body and confirming the rule is in none.
* New: any `#inspector-panel` rule that mentions `overflow-y` must say
auto, and both sides of the breakpoint must have said it. That is the
premise the unscoped rule leans on, and it was a sentence in a comment.
verify-inspector-dock now carries 120 mutations, all caught by name. Three
are new — one pins the header 4px down instead of to the edge, one declares
it a second time inside the dock query, one takes the wide panel's own
scroller away.
Two older mutations were re-anchored rather than deleted. Both pointed at
the header rule by its old eight-space indent inside the media query, which
the move took with it, and both named an assertion that has been renamed.
A mutation whose anchor has stopped resolving is a mutation that would abort
the harness before it could prove anything; deleting it would have turned a
broken instrument into a green one. Re-anchoring them to the same rule at
its new indentation keeps the intent and makes them run again.
METHODOLOGY 8.6 records the lesson, which is not really about CSS: a rule's
scope is part of what an assertion about it claims, and the sentence that
justifies a scope is the part that rots. Nothing reads that sentence, so it
goes stale quietly and the next reader gets a justification they can act on
— put the rule back. Moving the rule has to move the reason with it.
The section also records the mirror failure, because the probe hit it twice
in one run: an assertion sharper than the rule. `headTop === panelTop`
failed by exactly 1px at both widths, which is the panel's border, and
"more than 400px of internal scroll" failed on a panel that had genuinely
scrolled past its header. Both were tidier numbers than the rule talks
about.
The report was 「为什么窄屏和宽屏检视内部的滚动逻辑会不一样, 我认为这个是 内部的逻辑, 应该要统一的」. Measured, that is three claims rather than one, and two of them were already true. The scroll mechanism was already a single thing — `max-height` plus `overflow-y: auto` — and after D41 so was the header. The ceiling was one expression written twice: `syncStickyOffsets()` measured the band and the overview, wrote them out as CSS variables, and then computed `window.innerHeight - band - overview - 12` from its own locals, while `applyInspectorDock()` read those same two variables back and computed `window.innerHeight - band - overview - ledgerReserve` for itself. Two derivations of one sum, from two sources, each with its own guards — and no guard anywhere said the two halves had to agree. The tail is not duplication and stays different. In one column the ledger sits above the panel and cannot be squeezed out of the window; in two the ledger is in the other column and the two do not interact, so all that is left is a hairline. Measured, both: 900px window, 765 under the pins, then 540 narrow and 753 wide. So the shared part moves into `inspectorUnderPins()`, which is now the only place that reads the two measured strips for a ceiling, and both callers subtract their own reserve from it. Neither caller may still do that sum itself, and there is an assertion saying so. The second half is that each variable is now written only in the layout whose rules read it. Both used to be written on both sides, which meant that at every width exactly one ceiling was live and the others were numbers that looked authoritative and governed nothing. Measured at 1600x900 before this change: `--inspector-full-h` stood at 540px while the panel was being capped at 753px by a different variable. Nothing moves. Every ceiling is the same value it was: 1600x900 753px (wide) narrow-side variable no longer written 1366x768 621px (wide) narrow-side variable no longer written 900x900 540px (narrow) wide-side variable no longer written The edge each layout pins to also stays as it was, and it is not negotiable: D36 measured what happens when a sticky-bottom element taller than the scrollport is asked to pin to the top instead — the browser drags it out of its grid row and in front of the ledger (897x858: dragged 7,471px, covering it at all sixteen stops). What is unified here is the scroll container and the arithmetic, not where the region attaches. `verify-sticky.mjs` grows a sandbox that can run the helper: a document element that reflects what has been written to it, a `getComputedStyle` that answers from it, and a `matchMedia` that evaluates the shipped dock query against a real width. Hard-coding `matches: false` there would have made the stub agree with whatever the file did, which is the thing the adjacent comment about the gap constant already warns about.
Three new assertions in verify-inspector-dock, 129 to 132, and six existing
mutations re-anchored. The new ones are about the relationship between two
guarded things, which is what was missing.
* Both ceilings subtract their reserve from one shared sum, and NEITHER
caller may still compute that sum itself. The two negative clauses are
the teeth: adding the helper while leaving the old expressions in place
would have satisfied every assertion already in the file and left the
drift exactly where it was.
* One place reads the measured strips when it needs a ceiling. The count
is on `getPropertyValue('--sticky-band-h')` across the whole script, so
a second reader anywhere in it is the failure, not just a second one in
the two functions that currently exist.
* Each ceiling is written only in the layout whose rules read it — the
narrow writer is gated on `narrow`, the wide one on the negation of the
shipped dock query, and neither writer mentions the other layout's
variables at all.
Three assertions were rewritten rather than added. All three read the old
expression, which the caller no longer has: the expanded height, the ledger
share, and the wide ceiling. Each now names the helper instead of an
arithmetic shape, because the shape it was matching was exactly the
duplication being removed — and one of them was matching on the token
`innerHeight - band - overview`, which would have kept passing while the
sum was computed in two places.
Six mutations re-anchored, none deleted. Each one pointed at text D42 moved:
the expanded-height ceiling, the ledger-share subtraction, the two
wide-ceiling mutations, and the gap-constant one. A mutation whose anchor
stops resolving aborts the harness before it can prove anything, so
deleting them would have turned a broken instrument into a green one.
Four new mutations go with the new assertions: a caller that stops borrowing
the shared sum, the narrow writer writing on a wide screen, the wide writer
writing on a narrow screen, and a second reader of the strips.
verify-inspector-dock now carries 124 mutations, all caught by name.
verify-sticky.mjs needed a real sandbox rather than a bigger one. The
measurement now calls a helper that reads the two strips back out of the
document element, so the stub grew an element that reflects what was written
to it, a `getComputedStyle` that answers from it, and a `matchMedia` that
evaluates the shipped query against a real width. The obvious cheaper
version — `matchMedia: () => ({ matches: false })` — would have made the
stub agree with the file whatever the file did, which is precisely what the
comment beside `INSPECTOR_WIDE_BOTTOM_GAP` already warns against. The query
itself is read out of the file for the same reason.
METHODOLOGY 8.7 records the shape this whole defect was: every guard here
covered one side of the two ceilings, and none covered the relation between
them, so the sum they share could drift with the suite green.
The report was 「宽屏下, 检视向下滚动到底, title又没了。」
The rule that pins the inspector's header already described this loss as
three rows — the header with the record id, the 「第 N 轮 · #NNNNN · 思考」
line, and the whole tab bar. It pinned one of the three. The other two were
ordinary blocks and scrolled away like anything else.
Measured in a real browser with real records scrolled to their real bottom,
counting the pixels still inside the panel's own scrollport:
1366x768 212px of panel scroll header 49/49 turn line 0/36 tab bar 0/39
1100x900 169px header 76/76 turn line 0/36 tab bar 0/39
897x900 329px header 49/49 turn line 0/36 tab bar 0/39
What was left was a table starting mid-way, with no title, no turn and no
tabs. The loss scales with how far the panel can scroll, which is why it
survived two rounds of measurement reported green: at 1600x900 the panel
scrolls 80px, the tab bar survives by accident and the turn line keeps 5 of
its 36 pixels.
Fix: wrap the three rows in one element and pin that. Not three sticky
siblings with stacked `top` offsets — the header is 49px at one width and
76px at another, where a long record id wraps, so hardcoded offsets (49 / 85 /
124) would be right at one width and wrong the moment it wrapped with nothing
able to report it. This file has already paid for the other answer: the folded
state's own comment explains why the header's height was never measured into a
custom property. Wrapping the rows means the pinned element's height follows
its content and no number has to be written down anywhere.
The folded rule gains a second selector. `.inspector-chrome` is exactly what
`:not(.panel-head)` was protecting, so the outer selector now names the
wrapper and a second one a level in does the work the old one did. Same shape,
same contract: folded is still the header alone, and the panel's height is
still the header's height, whatever that turns out to be. Without that second
half folded would stand at the chrome's full 124px.
A second hypothesis was checked and came back negative, which is worth
recording: the reviewer's screenshot shows an expand control in the wide
layout while every wide-screen measurement reads it hidden, so a leak of the
narrow dock classes across a resize was plausible. It is not real — crossing
the breakpoint clears the classes and re-hides both controls and the grip.
No number changes: both caps, both writers, and every measured offset keep
their names, their arithmetic and their values.
The previous assertions were true while the defect shipped. "The header is
sticky" was true of the one row that worked; nothing asked about the other
two. So they are replaced, not restated:
- the pinned unit CONTAINS the header, the turn line and the tab bar, and is
a child of the inspector panel rather than of anything else — asserted as
containment, because containment is the claim and "is it sticky" was the
thing that hid the bug;
- no row inside it declares a sticky of its own. A member that pins itself
pins to the wrapper, which does not scroll, so the declaration does nothing
and reads as though the row were taken care of. That is the defect's shape
arriving through a different door, and it reads green against everything
else in the block;
- the folded rule has to reach inside the wrapper as well as outside it, and
the two halves are asserted separately because the failures are
asymmetric — losing the outer half leaves folded at 124px, losing the
inner half deletes the one row the state exists to keep;
- declared once, at the top level, scoped to this panel, carried over
unchanged: a scope's REASON is what expires, so the scope is part of the
property.
Also fixed here: the new block dereferenced `panelRegion` without a guard, so
a mutation that renames the panel's class left it null and the guard died
before reporting anything. The harness read that as "no assertion caught this"
— a correct verdict about a guard that crashes and a very misleading one about
the assertion meant to catch it. Null-safe now, with the reason written down.
The header's rule anchors move from `.panel-head` to `.inspector-chrome`, and
one anchor that spelled the header's indentation by hand is re-anchored after
the rows were re-indented into their wrapper.
132 assertions -> 135. 124 mutations, all caught by name, including six new
ones for the container, its three rows, the folded inner selector and the
self-pinning row.
…r's two ends A record selected in the ledger used to lose its selection to a poll. The judgement ran against the window, and the window is a tail slice that slides forward by a row every time a new record arrives -- so a selection on the oldest loaded record died after one poll, with the reader touching nothing. Measured on a real session: a record at position p of the 200-row tail survived exactly 200 - p new records, and p = 0 died after one of them inside a single poll. 「显示最新 200 条」 was never the cause; it was only where a reader happened to notice. The judgement now runs against the filter result and writes state.selectedId and nothing else. Nothing freezes and state.limit does not move, because the tail has to keep sliding for the window bar's 「已显示 N 条 · 更早的 K 条未加载」 to stay true. A selection now ends when the filters end it, like every other filter axis. The ban D13 placed on widening the window to reach a selection still stands. The two-ends control is the other half of that change: leaving the selection alive puts 200 loaded records across 7,800px with only the scrollbar to cross them. One round button, sticky inside the ledger panel rather than fixed on the viewport, with its bottom offset measured from the docked inspector rather than written down -- a flat 12px measured a 35px overlap at 12 of 18 scroll positions on a 22,527-message session, and the one position a spot check would have looked at happened to be free of it. The control shows a graphic rather than a word: a 40px circle holding one of two arrows, the direction following whether the reader is at the ledger's bottom. The markup ships neither glyph nor text and starts hidden, so the first paint is never an empty circle; aria-label still carries the sentence, so this is a change of presentation and not of what is announced. 「最上」 is the ledger's top, not the page's. It was window.scrollTo(0, 0), which lands above the filter band and the overview strip with the trajectory still off screen -- the control claimed to go up and showed the one screen in the app that contains no records. Both ends are now the panel's own edges, and the top one clears the pinned band by that band's measured height. Measured in a headless browser at 897x900 with the session pinned, sweeping every scroll position: of the 27 stops where the inspector is pinned and the button is inside the viewport, none overlaps the panel and every one takes its click; the offset reads 368px against a panel that renders at 360; the selection survives all 30 stops. 23 checks, all green.
verify-selection.mjs asserted the criterion D44 replaces, so those assertions
were rewritten rather than added to: the behaviour case now covers a selection
the window slid past but the filters kept, and the source assertion is
inverted to demand revealSelection(matched) and the absence of
revealSelection(windowed). The ordering assertion was split out from the
argument one -- a version that judges before the slice passes the argument and
fails the order, and needs its own break. Two mutations are new: one moves the
judgement ahead of the slice, the other freezes the tail into a prefix.
That second one is a rule D44 made necessary. Only the judgement was being
watched, so a version that quietly stopped sliding would have stayed green --
and a frozen tail is the one thing that would turn the window bar's
「更早的 K 条未加载」 into a sentence with no way to tell it from the truth.
New: verify-ledger-jump.mjs (38) with mutate-ledger-jump.py (30), all caught.
Four of its rules guard bugs that had already shipped once:
* a flat bottom offset, which measured a 35px overlap at 12 of 18 positions;
* scrollTo(0, 0) for 「最上」, which showed a screen with no records on it;
* r.height > 0 || r.top < viewH against a one-pixel sentinel, where the left
term is true at every scroll position -- a trivially-true term inside an ||
is the same defect as a tautology in an assertion;
* two words for one glyph, so a circle drawn around them changed size when it
turned.
mutate-cross-layer.py's first anchor was the three lines between the window bar
and the ledger section's close. D44 put a sentinel and a button there, so the
anchor stopped resolving and the control refused to start. Re-anchored to the
whole panel body, with the reason written down, because the next change will do
it again.
Three of the new mutations survived the first run and all three were the
guard's fault: assertions scoped to "anywhere in the function" are satisfied by
that function's own comment prose, and by its own typeof null check, after the
expression the rule is about has been deleted. A fourth asserted a landing
point the page was too short to reach. Both are written up in METHODOLOGY 8.9.
Suite: 55/55, twice. Fixtures unchanged.
LIST_CACHE_TTL_MS was 1000 while the page polls every 2500 ms, so the cache
expired before the next tick arrived. Every poll re-walked the session tree,
reopened the runtime database, and rebuilt the visible slice. Measured on the
machine that reported it, that was most of the 16 GB this process read in 68
minutes, and the allocation behind it held the process at a 1.2-2.5 GB
high-water mark for as long as the page stayed open.
No guard asserted the number. A suite reporting 53 green guards could not see
it, because a config value that silently disables the cache it belongs to is
not a wrong answer to any question anybody had asked.
Three changes in server.mjs:
* LIST_CACHE_TTL_MS 1000 -> 3000. Against a 2500 ms poll this turns
every-poll misses into every-other-poll, so the tree walk and the database
open each happen half as often.
* LIST_PEEK_BYTES 256 KB -> 64 KB, matching GENERATION_PROBE_BYTES. The
generation marker lives on the first row, and the counters the list derives
are approximate by design, so a narrower prefix costs accuracy nobody was
treating as exact rather than correctness.
* readSessionTitles had no cache at all. It reopens the runtime database on
every call and the list route called it once per poll, so raising the walk's
TTL alone would have left a 700 MB store reopened on a schedule regardless.
It now shares the walk's budget, and dispose releases it.
verify-list-cache.mjs (10) asserts the relation -- LIST_CACHE_TTL_MS >=
POLL_INTERVAL, across two files -- rather than a literal, the same call made by
D42's guards. The number is not the invariant; a cache shorter than the thing
that invalidates it is. Asserting === 3000 would fail for a poll interval tuned
down, which is an improvement, and would let this defect back the moment one was.
Its other four rules cover what the first fix needs to be worth anything: the
route reaching titles through the cache rather than the database, that cache
existing at all, dispose releasing it, and the peek staying within a small
multiple of the generation probe. mutate-list-cache-ttl.py (5) breaks each of
them by name.
One thing the original diagnosis got wrong, recorded because it changed the fix.
The 256 KB peek was attributed to every poll, and it is not: descriptorCache
already held it for 5000 ms, so it was re-reading every 5 s rather than every
2.5 s. What happened on every poll was the tree walk and the database open --
which is why the title cache, not the peek, is the larger half of this.
Not done here: decoupling the poll itself. The list does not need refreshing
every 2.5 s, and a 10 s TTL would cut the walk four times rather than two, but
client-check.mjs:355 asserts POLL_INTERVAL = 2500, and that is a frontend
behaviour change, which needs the four questions answered before code.
Suite: 57/57. Fixtures unchanged.
The reviewer's report is right, and the reason it survived this long is worth recording: the guard for this rule read plugin.json and stopped there. It was checking the one field nobody complained about, and not either of the two files that were named. A rule shaped like that is the same shape as the sticky-unit defect earlier in this PR -- a list of three where the rule pinned one. So the assertion is not "the READMEs do not say 1.0.0". It is that each README carries exactly the version plugin.json declares. That form fails on the *next* bump too, which is the failure that was actually going to happen next: manifest moves to 1.2.0, READMEs stay behind, nothing goes red. Three assertions, three mutations. One mutation puts each README back to the old version; the third moves the manifest to 1.2.0 and leaves the READMEs alone, which is the case the equality form exists for and which the original rule could not have seen. verify-review16.mjs 29 -> 32. mutate-review16.py 24 -> 27, all caught. Suite 57/57.
A turn's durationMs and its result preview are written onto the record
after it was first pushed, when the result message arrives. The copy that
carries a new value is therefore an OLD record whose index the page has
already seen, and the delta never contained it. Two causes, each run
against the shipped functions to prove it rather than to reason about it.
Server: buildDeltaPayload() cut at record granularity and took only
records with index > since, so the record that gains a value was exactly
the record that could not travel. It now cuts at turn granularity -- a
turn with anything new after the cursor travels whole. A call and its
result sit in the same turn, so the turn that gains a record is the turn
that gets written back onto. A result that landed in a later turn than
its call would still wait for a refresh; that is the one shape this does
not cover.
delta.records still counts what is NEW, not what travels, because three
assertions pin it as the count of additions. The cut and the count are
computed apart so the count does not inherit the cut.
Client: mergeTurnRecords() read
if (seen[added[i].index]) continue;
which is a correct way to keep a delta from doubling a turn and an
incorrect way to merge one: a delta replayed against a copy that already
has a record must leave it unchanged, and it must also be allowed to
change it. Those are different requirements and one line cannot serve
both. It now replaces by index, which is still keyed by the same index,
so replaying the same delta lays down the identical object rather than a
second one.
Measured on the live endpoint, cursor at 19880 of a 33491-record session:
whole 65.4 MB, delta 27.4 MB, 97 records below the cursor travelling,
that turn's 241 records all present, delta.records equal to the count
above the cursor. Feeding the real delta through the real merge functions
turns a null duration into 7738 ms with no duplicate index, and replaying
it changes nothing.
Two assertions had to be rewritten rather than deleted, because the fix changed what they were about. The one reading "the tail from cursor 3 is a turn carrying one record" asserted records.length === 1, which is now the whole turn carrying its own records; the delta.records === 1 line above it is kept verbatim, because "ships a whole turn" and "counts one addition" have to hold at the same time. Two mutation anchors on `kept` no longer resolved after the rename, and were re-anchored to `fresh` and `all`, which carry the same decisions. A guard hole turned up while adding to the same file. The block asserting that turns which grew nothing are absent from the tail asserted once and then broke out of its loop as soon as it found the first such turn, so a mutation that made every turn grow ran zero assertions in that block and passed. The precondition is now about the fixture instead of about the delta -- a precondition that the thing it guards can break is not a precondition -- and one assertion covers every untouched turn, printing the leaked ids in the detail. verify-incremental.mjs 63 -> 71 checks. mutate-incremental.py 16 -> 19 mutations, all caught.
The reported symptom was that the grip scrolls away once the panel is
scrolled. Measured at 900x780 on a real session, that is true and it is
the milder half: the grip could not be reached at ANY scroll position,
including scrollTop 0.
scrollTop=0 overlap with the panel 8px, 45 of 45 sampled points hit the header
scrollTop=174 overlap 0px
scrollTop=349 overlap 0px
Two independent causes, either of which is enough on its own.
It was positioned absolutely inside the panel, which is the scroll
container, so it travelled with the content and left the scrollport on
the first scroll. `position: sticky` pins a box to the scrollport
instead, which is the thing a grip has to survive. Sticky puts the strip
back in the flow, so `margin-bottom: -14px` takes back exactly the
height it now costs; the panel's scrollHeight measured 1007 before and
after, and the header sits 1px inside the panel border before and after.
And it was painted under `.inspector-chrome`, which is sticky at the
same edge with an opaque background at z-index 2, while the grip was at
z-index 1. That is the half that survived scrollTop 0: of the 8px where
they overlapped, every sampled point hit the header, so the reader
could not drag the panel taller at all. The grip is now z-index 3.
`left`/`right` went with the absolute positioning. They never applied --
a block-level box in flow sizes itself -- and keeping them would read
as "this is how wide it is" when it is not.
Dragging through the browser's own input pipeline at scrollTop 697 grows
the panel 310 -> 382, and at all five sampled scroll positions 45 of 45
points on the strip's visible part hit the grip.
The grip had `position`, `z-index`, `touch-action`, `display` and a
`top`, and this file pinned all five. Every one was present and every one
was correct in isolation, and the reader still could not drag the panel
taller. That is D27's shape one layer down: a guard that checks a
property appears in a rule cannot see a later rule undo it.
Four assertions, each aimed at a different way this could come back:
- the grip is `position: sticky`, and EXACTLY ONE rule positions it.
The enumeration is what makes the second half meaningful, and the
pseudo-elements are excluded from it deliberately rather than by
accident: `.inspector-resize::after` is the visible hairline and
legitimately carries `position: absolute` against the strip, which
is the one thing the strip itself must not be.
- the grip's z-index is above `.inspector-chrome`'s.
- the negative margin equals minus the strip's height, compared as
two numbers rather than against a constant, so growing the strip and
forgetting the margin is caught too.
The z-index assertion was wrong on its first run and the mutation found
it: both values were read out of the narrow media block, but the chrome
rule lives in the base stylesheet, so it read null, `Number(null)` is 0,
and `3 > 0` was true whatever the grip said. Two values from two blocks
written under one variable name -- the smallest possible instance of an
assertion that is wider than it looks.
verify-sticky.mjs gains the grip in `pinnedStacks`, which is the
enumeration of pinned strips: a list that quietly omits one member is how
a z-index 1 grip shipped under a z-index 2 header. Because an enumeration
cannot test its own completeness, mutate-sticky.py gets its own mutation
for it -- raising the header's z-index -- rather than reusing the one
that lowers the grip's.
verify-inspector-dock.mjs 135 -> 139 checks.
mutate-inspector-dock.py 129 -> 133 mutations, all caught.
verify-sticky.mjs 28 -> 29 checks.
mutate-sticky.py 20 -> 21 mutations, all caught.
The session picker offered a reader 「(3 分钟前 · 10 条)」 for a session
with 23,642 messages. Reconciling the two endpoints field by field for
the same session:
/api/sessions messageCount 10 turnCount 2 sizeBytes 1037810
/api/trajectory messageCount 23642 turnCount 232 sizeBytes 192012043
Neither endpoint is wrong, which is the part worth recording. The
trajectory's numbers are the sum over all 42 generations -- 23,642 and
192,012,043 both check out as exact sums of the per-generation figures.
The list's sizeBytes is exactly the active generation's byteLength. So
the two differ in SCOPE (whole lineage, versus the active file alone) and
the message count differs in PREFIX as well: /api/sessions counts roles
in the first 64 KB of messages.jsonl, and this session's active file is
1 MB.
The field names are what made it a defect rather than a footnote, because
the page then printed the prefix as the session's size. And the constant
carrying the comment 「for counts nobody treats as exact」 had become
false: something treated them as exact, and it was this.
Counting exactly is not on the table -- verify-list-cache.mjs pins the
peek to within 4x the generation probe's budget, which is the right
constraint and stays. So the prefix stays, and its counters are now only
reported when the prefix WAS the whole file. A bounded-but-reported count
was the defect; absent is the only other answer available at that budget.
Both fields are gated together, because they come out of the same prefix
and fixing one would leave the same untrue number under a second name. The
flag defaults to false, so a read that threw reports no count rather than
zero -- a zero is the same untrue number with a smaller magnitude.
On the page, a missing count omits the whole segment instead of printing a
placeholder, because 「— 条」 renders the absence of a number in the shape
of a number. Label and recency stay, which is what the picker is for.
Measured on the live page: of 21 options, 4 still carry an exact count
(their active file fits in the budget) and 17 carry none, no option shows
a placeholder, and every printed number matches what the endpoint sent.
The rule that rules out counting exactly is already here: the peek must
stay within 4x the generation probe, because a generation marker lives on
the first row. D45 is what that constraint implies and the file did not
say. If the prefix must stay bounded, then a count it can afford is a
count of a PREFIX, and reporting that as the session''s size is not a
rounding difference.
Five assertions, and the bounded-but-reported pair is the load-bearing
one -- absent is the only other answer available at this budget:
- the descriptor asks whether its prefix was the whole file, which is a
question about the FILE''s size. Asking how many bytes were read
cannot distinguish "the whole file fitted in the budget" from "we
stopped early", and there is a mutation for that.
- messageCount is not sent unless the prefix was the whole file.
- turnCount is not sent either. Same prefix, same reason; a mutation
gates one and not the other, which is the half-fix worth naming.
- a read that threw reports no count rather than zero.
- the picker omits the segment instead of printing a placeholder.
The last one is read off the client with its comments stripped. The
comment above sessionOptionLabel quotes the old string 「(3 分钟前 · 10 条)」,
so an assertion made against the raw file would be satisfied by prose
describing the defect it exists to catch -- which is what D27 was.
The mutation harness now takes an optional fourth element naming which file
to mutate, defaulting to the server. One of these five rules lives in the
page rather than in the service, so a mutation proving it can fail has to be
made there; both files are read once at startup and restored byte for byte
in the same finally, and the restored-bytes check covers both. A harness
that can only edit one file cannot pair with a guard that reads two, and
the symptom is not a red test -- it is an assertion that quietly never gets
a mutation.
verify-list-cache.mjs 10 -> 15 checks.
mutate-list-cache-ttl.py 5 -> 10 mutations, all caught.
`state.overviewSelection` had four writers -- dropOverviewSelection, setActualDuration, onOverviewPointerMove, onOverviewPointerUp -- and R5 says L3 writes L2 through exactly one setter. That rule could not be asserted in either direction: a count of four is a guard that cannot fail once the code is fixed, and a count of one is a guard that cannot pass until it is. verify-cross-layer.mjs had said so in its header for a year and then left the rule unchecked. Only the field and the invariant that belongs to the field came in. What each call site does around it differs at all four -- resetting the window, re-rendering one face or both, starting the edge pan -- and folding that in would trade 「one writer per field」 for 「one function that knows everything」. `overviewViewport` is a different field and a different write, L3''s viewport rather than L2''s range, and stays where it is. The invariant is the one the old comment above dropOverviewSelection() already stated as a rule: anything that clears the range has to disarm the gesture, because a brush in flight repaints the range on every pointer move. Two of the four writers honoured it because they were the two that had been written to honour it; the other two set the field directly. One writer makes it true for all of them, which is what a single writer buys -- not a smarter function, but nobody left who can forget. The one behaviour change is that setActualDuration now disarms the brush too. That path is unreachable today -- a reader cannot press the axis button while the strip holds pointer capture -- so this is not a fix for a live defect. It is a rule that was in a comment and now is in the code. Measured on the live page: brushing moves the ledger from 「第 232 轮」 to 「第 148–150 轮」, the clear control puts it back, switching the axis clears it, and brushing again after the switch works and filters to 「第 168–172 轮」.
The convergence is asserted by enumerating the functions that assign
state.overviewSelection and requiring the SET to be {setOverviewSelection}.
The count was the thing that could not be asserted, in either direction;
the set of names is assertable at both ends and it also names the offender
in the failure detail, which a count cannot.
Three more assertions, each aimed at a way it can come back:
- a second writer, at the mode switch (the path that used to disagree
about the invariant);
- a second writer, mid-gesture (the other half of that disagreement);
- the writer keeping its single ownership and losing the invariant --
enumeration still shows one name, so only this one can catch it.
Four of the call sites are named individually, because a convergence is
only worth anything if every path that used to write still reaches it.
Two existing assertions were rewritten rather than deleted. They still
name the writer''s JOB -- a brush has to land somewhere, the strip control
still has to clear the range -- and no longer name the spelling that is
gone. Deleting them would have dropped the requirement and kept the
silence.
Writers are enumerated with the comments stripped, and functionSource now
reads the same stripped text. Reading the raw file there while the
enumeration read the stripped one put two views of the same file in one
guard: a JSDoc block quoting the assignment it describes fails a
per-function check and not the enumeration, with nothing between to explain
it. MUST_STAY_GREEN exists because of this -- it adds exactly such a
comment and requires the guard to stay green, which is the only way to
prove the stripping is doing anything.
verify-anomaly.mjs loads these functions out of index.html into a vm
sandbox, and it was still loading only dropOverviewSelection. With the
assignment moved behind the writer, the sandbox hit `setOverviewSelection is
not defined`, and the fallout reached two mutation controls -- one of them
reporting a MISSED catch and a failed restore -- which is how a change that
touched one function turned into three unrelated red lines in the full
suite. setOverviewSelection is now loaded verbatim rather than stubbed: a
stub would have kept the sandbox green while the shipped reset quietly
stopped clearing the brush. The assertion follows the whole chain, because
checking only the middle hop would pass a writer that clears nothing.
verify-cross-layer.mjs 15 -> 22 checks.
mutate-cross-layer.py 9 -> 12 mutations, all caught, plus 1 must-stay-green.
The arrow on the ledger''s jump control points wherever a press would
take you, which meant that opening the page already resting at the end
of the ledger drew 「跳到轨迹的最上」 before the reader had touched
anything. That reading is not yet true of anything they did, so the
first screen now always points down, and the first press always goes to
the bottom:
ledgerJumpUsed starts false, so ledgerJumpGoesUp() is false
and 「在底部」 does not get a vote on the first screen
the press arms it, and from then on the arrow reports position
Both the glyph and the press read one function, ledgerJumpGoesUp(). They
used to call ledgerEndInView() each and agree because they were the same
call; they agree now for a stronger reason, since a single flag is all
that separates the two states.
The press reads the predicate BEFORE arming it. Arming first would let
the very first press consult the position, and on a ledger that already
fits on screen that sends the reader UP on the one press that is
supposed to go down. It also relabels the arrow itself rather than
leaving it to the observer: scrolling to the end you are already at
fires no intersection event, which is exactly when the arrow has to move.
The flag is never cleared. 「首屏」 is this page, not each session -- a
reader who switches sessions has already moved on this page, so the arrow
goes straight back to reporting position. Clearing it per session is a
different decision and wants its own round of questions.
Measured on the live page: first screen down, standing at the bottom
without having pressed still down, first press to scrollY 7488 with the
arrow up, second press back to 732 with the arrow down, and all seven
sampled scroll positions agree afterwards.
… set once
Two assertions were rewritten rather than deleted. The one that pinned
`var name = atEnd ? …` now points at the function that holds the reading,
and the one that pinned the click's inline ternary now requires both the
glyph and the press to go through ledgerJumpGoesUp(). Neither requirement
went anywhere: 「the click chooses from the same reading the arrow does」 is
if anything more load-bearing now, because a single flag is all that
separates the two states and two copies of the predicate could let them
say different things for the first time.
Five new checks, four new mutations:
- the flag starts false in the shipped file, so the first screen points
down whatever the page is at;
- the press arms it, and only the press does;
- the destination is chosen BEFORE it is armed. Writing those two lines
the other way round rebuilds the original defect from the inside: the
first press consults a position the reader never chose, and on a
ledger that already fits on screen that sends them UP;
- the press relabels the arrow itself, matched as an adjacent pair
rather than by index, because buildLedgerJump() carries a
setLedgerJumpLabel() of its own inside the observer callback and an
index comparison finds that one and reports the opposite defect.
Three anchors stopped resolving and were re-read against the shipped file
rather than re-worded: the arrow no longer mentions `atEnd`, and the
scroll target now goes through `up`.
verify-ledger-jump.mjs 38 -> 42 checks.
mutate-ledger-jump.py 35 -> 39 mutations, all caught.
One process note, because the probe is the part that would have shipped
the wrong conclusion: the browser check for 「at the bottom and not yet
pressed」 was written last, by which time the button had been pressed
twice and the flag was long armed. It measured neither the old defect nor
the new behaviour -- it had consumed its own precondition. A flag that
cannot be un-set means that precondition lives only at the start of the
page, so the check belongs before the first press and not after it.
这一轮推上来了:review 的 7 条 + 5 个新缺陷之前那批回复里写「还没推送到本 PR」的三条(#1 / #3 / #6),现在成立了。 每条都走同一条路:先证死 → 四问落纸 → 实现 → 浏览器实测 → 守卫 + 变异 → 套件 ×2 → 投影 → 两个提交。下面每条都附了量出来的数,不是「已修复」。 review 逐条
#2 的两个成因服务端 客户端 #5:报告说的是「滚走之后点不到」,实测是任何滚动位都点不到900×780 实测,45 个采样点: 两个独立成因:① 它是绝对定位,在滚动容器里跟着内容滚;② 同一族:每条守卫都在断言某个属性出现在某条规则里,而没有一条问过有没有别的规则能把这一条撤销掉。 会话选择器上的
|
/api/sessions |
/api/trajectory |
|
|---|---|---|
messageCount |
10 | 23,642 |
sizeBytes |
1,037,810 | 192,012,043 |
轨迹端两个数正好等于 42 个代的逐项求和;清单端 sizeBytes 正好等于活动文件的字节。口径差在两处:范围(血缘 vs 活动文件)与前缀(活动文件前 64 KB vs 整个文件)。
错的是字段名同名,而客户端把它当准数印了出来:会话选择器上的 (3 分钟前 · 10 条),实际是 23,642 条。「改成精确计数」这条路被 verify-list-cache.mjs 钉着的 peek 有界断言堵死,所以只能不给就不给:前缀不是整个文件时服务端不发这个数,客户端没有数就整段省略(不印占位符)。
实测 21 个选项:4 个小会话仍带准数,17 个不带,无占位符,21 个逐条与接口对上。
overviewSelection 四个写点 → 一个
四个写点(dropOverviewSelection / setActualDuration / onOverviewPointerMove / onOverviewPointerUp),而守卫头部一直写着故意不检查这条规则,理由很直白:断言四个是修好之后就永远过不了的守卫,断言一个是修好之前永远过不了的。这条规则整整一轮没有任何守卫。
现在收敛到 setOverviewSelection(),箭头和点击读同一个函数。只收字段和它自己的不变量(「清空区间就要解除在手势」),四个调用点各自的活留在原地 —— 塞进去是把「一个字段一个写者」换成「一个函数什么都知道」。
收敛后全套套件抓到一处隐藏耦合,只有全套能抓到:verify-anomaly.mjs 把函数抠出来丢进 vm 沙箱真跑,它抠了 dropOverviewSelection 但没抠新的写者,于是沙箱崩,连锁出三条我没碰过的守卫红、其中一个变异 MISSED。按规矩原样载入而不是打桩 —— 打桩会让沙箱变绿,而线上 reset 路径其实已经不刷区间了,正是那个文件存在的意义。
跳转箭头:首屏一律朝下
判据从「哨兵有没有在视口里」变成「这个账本被跳过没有,且哨兵在视口里」。点击处理里先问目的地、再武装标志 —— 反过来的话首按会去问位置,在一屏放得下的账本上把读者往上送。
实测 13/13:首屏朝下;站在最底部但还没按过仍朝下;首按到 7488 变朝上;二按回 732 变朝下;按过之后 7 个滚动位全一致。
守卫纪律
- 一条守卫不能失败就不是守卫。 每一处新不变量都配了能抓住它的变异,变异存活就补断言,绝不删变异。
- 这轮抓到三个守卫自己的洞:① review#5 那条 z-index 断言把两个值都从
narrow块读,而面板头在基础 CSS 里 —— 读到null,Number(null)是 0,3 > 0恒真,两个值来自两个块却写了一个变量名;② 那条枚举读剥注释的文本、逐函数检查读带注释的原文,同一个守卫里两套视图;③ 锚点失效一律重锚不删。 - 因为第 ② 条,变异台加了一张
MUST_STAY_GREEN表:加一行只描述、不改行为的注释,守卫必须仍然是绿的。没有它,「剥注释」永远无法被证伪。一个守卫过不了是坏的,一个什么都过不了的守卫一样是坏的。 - 因为有一条规则住在客户端,
mutate-list-cache-ttl.py现在可以指定改哪个文件,两个文件都在同一个finally里按字节还原。
套件 57/57 ×2,fixture check OK,每一条改动都是这样过的。
还没做的
- docs: 补充 About,明确面向开源社区贡献 MiniApp 插件 #3:
run.mjs的快照跨启动比对。已接受,未实现。 - 其余未完项:跨面单位不一致、
full与docked只差 25px、交叉核对只跑过一家模型。
…d against The doc comment on revealSelection() said the list it is handed is the *windowed* rows rather than the filtered ones, and that it used to be judged before the slice. Both halves were true when D13 wrote them and both are false of the function that ships: renderLedger() passes `matched`, and the call sits after the window is sliced. A third sentence in the same paragraph -- "the same one the rows are built from" -- also stopped being true, because the rows are built from `windowed`. That paragraph is the documentation a reader of the installed plugin has, and it named the judgement D44 spent a round measuring and overturning. It was telling readers to decide "is the reader still looking at this" the way that loses a selection after a single poll on a session that is still being written. What survives from D13 is stated instead: the window is never widened to reach a selection, and this writes `state.selectedId` and nothing else. The reason it is `matched` now points at the call site in renderLedger(), which is inside the package. The section it used to cite lives in the workspace, outside the tree that installs, so a reader following it found nothing. verify-selection.mjs 15 checks and mutate-selection.py 11 mutations, both unchanged -- the guards already pinned `revealSelection(matched)`, which is exactly why the comment could sit next to them and disagree for as long as it did. A guard that reads the call site does not read the prose around it. node tests/run.mjs 57/57.
`docs/LAYERS.md` sat in run.mjs's SHIPPED list after the document moved out of the plugin tree. It then hashed to 'absent' on every run, which is inert -- absent matches absent, so it never reported drift -- but it meant one of the entries the hard-kill guard advertises protecting was not a shipped file at all. Seven real files, not eight. The membership rule is now written next to the list: these are the shipped files a mutate-* control rewrites in place. That is a rule about files rather than about importance, so it survives the next move. No guard is owed for the removal itself. An entry naming a file that is not there hashes to 'absent' on both sides and can never report drift, so there was nothing to break and nothing to keep. What the list does not have is a completeness check -- nothing asks whether every file a mutate-* control rewrites is in it, so renaming one out from under the list would quietly stop the hard-kill guard covering that file. That is a missing invariant rather than a wrong one, and it wants a round of its own. node tests/run.mjs 57/57.
The byte came first because it is the one that hid the rest. server.mjs
carried a literal NUL inside a string literal that was meant to be
empty -- ids.join('\0') rather than ids.join('') -- so the whole file
read as binary. grep stopped halfway through it, which is how an audit
of that file reported seven references where there were seven and a
half's worth of silence: the count came out wrong in the direction that
looks like "nothing else to do here".
It was correct by luck, which is the part worth keeping. Session ids are
all the same length, so a fixed-length alphabet cannot collide and the
empty separator is safe -- but nobody wrote that, and a file that reads
as binary cannot be reviewed, and a reviewer has no way to learn that
from a diff.
The other nineteen are a different thing. Those comments told the reader
to go somewhere they do not have: they named docs/LAYERS.md, moved out
of the plugin directory so it would stop installing onto every reader's
machine, and one of them named a bare section number with no file name
at all, so grepping for the filename could not find it. A pointer is
worse than a missing number -- a reader follows it, does not find it,
and concludes that either the document is incomplete or the conclusion
is wrong.
Each one now points at the guard that actually pins the rule, which is
inside the package and can be opened: verify-intrinsic-size,
verify-cross-layer, verify-anomaly-model, verify-list-cache,
verify-anchor-carry, verify-parsed-rows, verify-raw-lazy,
verify-incremental. Two stop being references altogether. The
content-visibility alternative is now described where 25 groups per
frame is chosen, because the next person to ask is asking about exactly
that and the answer is two sentences.
node tests/run.mjs 57/57.
verify-review16.mjs has asserted since it was written that nothing in the package points at a docs/ file that does not ship. It read two READMEs, because those are what a reader opens first. The two shipped source files were never added to it -- and they carried nineteen of those pointers between them. That is the shape this project keeps finding: the check and the thing it is about turned out to be two different objects, and the label claimed a coverage it did not have. Every mutation for the assertion was anchored in a README too, so nothing could have caught it. Both files are now read, through one SHIPPED_TEXT map that the second check below also uses -- two checks that each spelled out their own file list is how they came to disagree about what ships. client-check.mjs is in that map as well, and it turned out to hold a twentieth pointer, which is the first thing the widened assertion found. Three mutations, none deleted: - a pointer goes back into the client source; - a pointer goes back into the server source; - a NUL byte goes back into the server source. verify-review16.mjs 32 -> 33 assertions. mutate-review16.py 27 -> 30 mutations, all caught.
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
这一轮在 6407f01 上复跑过了。v3 里几条产品问题都对上了:
?since=:停在未返回的工具调用上打开页面,追加结果后轮询即可看到「→ 结果预览」和耗时(本机是 3.00 s),不必刷新。服务端整轮下发 + 客户端按 index 覆盖,两边都核对过。- 两个 README 与 plugin.json 都是 1.1.0。
- 窄屏抓手:863×803 和 420 宽下,面板内容滚到 0/100/300/500 时抓手都点得到,也能拖高。
messageCount(大会话不印假条数)、overviewSelection 单一写点、跳转箭头首屏朝下,以及 57/57 套件,也都按你的说明核对过了。D14 和 #15 那几项没有回归。npm run check 与 CI 都绿。
仍按你的说明记着、本轮不做的
run.mjs跨启动快照:当前仍是「本次起点 vs 本次终点」,发现不了上次kill -9留下的变异态。你提到不能简单用git status把未提交改动报红,这个取舍合理;以后若做,把「启动前已脏」和「跑完变脏」分开报会比较稳。
PR 描述
主干已经写得很完整了。麻烦在 squash 前再补一小节本轮后半:工具调用增量回写、messageCount 口径、检视抓手(sticky + z-index)、跳转箭头、版本号对齐。我们这边 squash 合并,描述会直接成为提交信息。
这一版我这边没有新的必改项了,准备 approve。
|
补上你点名的那一小节。下面五条只写这个 PR 的 diff 里读得到的东西。 1. 工具调用增量回写(
|
会话轨迹 — 让每一轮轮询不再重建整场会话
本 PR 在
ab717e3(检视面板跟随筛选 + sticky 重算收敛到单一出口)之上,继续处理 L0 数据层。0. 上一轮的状态
1. 问题:轮询重建整场会话
一个已结束的会话不花钱——ETag 由 stat 事实派生,会话没变就是 304,什么都不重建。
花钱的是还在增长的会话,而那恰恰是读者主动盯着看的场景:每一轮轮询都从头重建一遍。
本机实测(23 代会话,
payload.session.sizeBytes = 99,986,501):messages.jsonl在追加trajectoryCacheKey()把每代byteLength拼进键三层各自独立地表现出同一个比例:服务端输入 99.90% 冗余、线上字节 99.99% 冗余、页面解析 ~99.99% 冗余。会话在忙碌时每 3–6 秒变一次,轮询每 2.5 秒问一次——几乎每次轮询都撞上全量重发。
2. 修法一:按代缓存解析结果
loadParsedRows()用path + size + mtime + limit做键,返回任何没有变动的 generation 文件的行。真实文件上直接测(22 个冻结 snapshot,95.3 MB):
按字节预算而不是条数——条数与体积无关,一个 generation 文件可能 40 KB 也可能 10 MB。
缓存键的每个成分都对应一个它要防的失败:
filePath不用fileName—— 每个会话都有一个messages.jsonl,basename 键会让两个会话撞车。夹具专门造了这个碰撞(同尺寸、同 mtime、不同对话),断言互不可见。stat的size+mtimeMs,不用 catalog 里的byteLength—— 代码自己在server.mjs:1889-1894注释了那个值是会过期的快照,而 active 文件每轮都在它之外继续增长。limit——readHead按它截断,而"是否被截断"是这条目持有的事实之一。3. 修法二:原文改成按需构建
buildRawPayload()对每一条记录执行JSON.stringify(message)—— 18,337 次、约 100 MB —— 只为回答两个问题:序列化后是否超过MAX_RAW_BYTES(65536)、是否含.minimax需要脱敏。那个字符串用完就扔:splitRawRecords()在最终序列化之前把消息摘掉,而/raw带走的是消息对象,不是这个字符串。于是每轮重建都要把 100 MB 消息体完整序列化一遍、量一下、再扔。而
raw只有打开某条记录的原文页、复制、交接时才读得到。离线复测(同语料、同代码路径):
buildTrajectoryPayload记录现在只带一个定位符(它来自
rows的第几条),/raw被请求时才调buildRawPayload()——一次一条。4. 口径变更(契约级)
「原文过大,已省略」从 bulk payload 迁到
/raw响应。/api/trajectory/raw?index=N现在返回{ index, raw, omitted }。按本项目规矩逐个列读点,并在
tests/verify-raw-lazy.mjs里逐个加断言:index.html:2867交接元信息record.rawOmittedrawText === null(交接本来就先取rawSourceFor,copySelected同样)index.html:5989rawSourceForrecord.rawOmitted→ 短路null/raw,省略由服务端给出index.html:5998-5999原文页提示record.rawOmitted/raw的omittedindex.html:6007-6008原文页内容record.rawOmitted→—index.html:6059copySelectedsource === null→stringify(record)五个读点没有一个在台账行上,全部在读者打开某条记录之后才发生。
读者可见的变化只有一处:原文页对「过大」的记录会多发一次
/raw请求(之前零次)。这是一条用户主动打开才走的路径,用一次请求换每轮少序列化 100 MB。5. 守卫 26 → 30,变异 34 → 58,全捕
verify-parsed-rows.mjs的核心:跑两次真实路径(冷读 / 命中),两边都JSON.stringify成 body,要求逐字节相同,不是"看起来一样"。读错来源或键错字段的版本在这里会算出错误答案而不是抛异常。verify-raw-lazy.mjs里的 64 KB 上限没有丢,而是跟着定位符走到它现在该在的地方被断言。变异替守卫抓到的三个真缺陷(不是补断言凑数):
Number(record.rawRow)把null当成第 0 行(Number(null) === 0),/raw会答出另一条记录的消息。改成拒绝而不是强转。delete,rawRow就会随每条记录发到页面上。此前没有任何断言说它不在。mutate-pr15-fixes.py里有一条变异锚点被本次改动冲掉了。它报的是 SKIP 不是 pass,但死掉的变异看起来像绿的,已重新锚定到承载同一判定的行。6. 一处我自己写错的性能账(以及它带出的真缺陷)
早期把按代缓存的收益记成「~1,000 ms / 3.5×」。那个数是错的,实测 306 ms。
错在哪:那个数是用
1,138ms 重建 − 139ms stringify减出来的,把没有查过的buildTrajectoryPayload和buildRawPayload一起算进了「读+解析」里。所以诚实的账:
顺带修掉一个由这个错账带出的缺陷:预算代理写成 3×,于是
95.3 MB × 3 = 286 MB计费压过256 MB预算,LRU 把放得下的条目也赶走了。实测保留率 1.22×,代理改为 2×。预算的好坏完全取决于这个数。端到端轮询中位数没有明显变化(1,405 → 1,434 ms,样本区间 348–2,916 ms)。原因有二,如实说明:300 ms 埋在这个噪声带里,而且浏览器标签一直在开、每 2.5 秒轮询同一个服务端,我的探针和页面在抢同一个事件循环——这两组数都不足以支撑结论。
7. 验证
浏览器实测(新运行时
http://127.0.0.1:55344/dashboard):raw字段 0 个、rawOmitted字段 0 个(18,611 条记录)/raw抽 40 条:39 条带回原文、1 条报「过大」、0 条说不清的这套数字承诺什么、不承诺什么。
tests/下没有一条守卫打开浏览器——整个目录对 puppeteer / chromium / msedge / DevTools /setDeviceMetricsOverride零命中。29 条verify-*.mjs只有三种形态:读index.html的字节再用正则问形状(20 条)、在node:vm里跑抽出来的函数配一个假state(9 条)、起子进程或起服务(verify-fixture.mjs跑make-fixtures.mjs;verify-time-range.mjs会打真服务端,但它在run.mjs的SKIP里,默认不跑)。所以「交互坏了」这件事套件看不见。 上面这段浏览器实测,以及本 PR 其余各节的实测数字,全部来自探针——探针不在这份 diff 里:本 PR 的文件里没有一个是探针,
run.mjs也只按tests/下的文件名发现守卫,所以它既不会被套件跑到,也不需要跑。要复现其中任何一条浏览器结论,得自己把插件装起来、开起来、手动点。8. 还没做的
?since=,只发新增尾部)。它比这一轮两项加起来更彻底:服务端不重建、页面不解析 36 MB、不传 36 MB,1,400 ms → ~10 ms 量级,而且不需要那 116 MB 常驻。这是这个项目真正该还的设计债,按这个项目「口径先于实现」的规矩,先把口径写下来再动手。state.limit = Infinity会一次构建 143,568 个 DOM 节点,8 万次点击测得中位143,568 节点路径是state.limit += PAGE_STEP的二十几倍,同样实测:这条已修(分批建行 + 进度,见下 §9-A)。剩下的杠杆是给「显示全部」封顶或走增量协议,两者都改承诺,未动。9. 本轮新增:分批建行 / 检视不再改窗口
两个缺陷,都是同一次会话上复现的,根因都是结构性的,不是少写一行。
A —「显示全部」把两万行塞进一次同步循环
实测(本机,150 轮 / 18,924 条 / 视口 735×900):
卡的不是建行 JS,是每帧对已经两百万像素高的文档做的 layout。 六批 JS 加起来约 870 ms,其余 3,086 ms 是 5 个帧间隙。所以把批调小只会让帧次变多、对不断变长的文档重复 layout——治不了。
修法不改承诺,只改节奏:一帧最多建 25 个轮组就交还主线程;窗口条在期间报「正在显示 N / M 条」;每轮
renderLedger()先ledgerBuildToken += 1,在途分批下次醒来发现 token 变了就地退出、不碰 DOM;轮询 tick 发现ledgerBuilding()为真就记住这一拍,由flushDeferredPoll()在构建结束后补跑一次——3,956 ms > 2,500 ms,不这么做读者会看着计数一次次归零。「显示全部」仍然是全部。
实测并否决的替代方案(因为它是下一个会被问到的问题):
content-visibility: auto是真实的 4.0×——3,956 ms → 979 ms,行数一致,帧间 layout 一并消失。但它需要给被跳过的元素填一个contain-intrinsic-size,而 150 个轮组的真实高度跨度 11 倍(p25 1,011 / 中位 2,667 / 最大 29,754 px)。填任何单一值都会让滚动高度大幅失真,auto关键字只能记住已渲染过的那两个。这是拿「控件如实描述页面」换速度,不采用。B — 检视面板描述着一条已经不在表里的记录
在「显示全部」下选中一条早期记录,点「显示最新 200 条」,记录离开窗口,而面板还在描述它。
revealSelection()判定在切片之前,且会state.limit = Math.min(needed, entries.length)主动把窗口撑大到装下那条选中:读者自己点的按钮,在点击之后把窗口条上的数字改了。 这是窗口成了检视面板能写的东西:
state.limit因此有两个写手(窗口控件 / 一次判定),与本 PR 里每一个双写手缺陷同一个形状。仍然成立:
revealSelection(entries)是纯谓词,只写state.selectedId,不再碰state.limit;调用点在切片之后;窗口永远不许为了够到选中而变宽。当时判错、后来推翻的:当时由此推出「『读者能看见什么』是窗口,不是筛选结果」,把参数从
matched改成windowed,并在窗口收窄到不含选中记录时取消选中。这一步错在把窗口当成了读者的意图——窗口是投影,它每进一条新记录就整体前移一格,所以「记录离开窗口」量的是投影的运动,不是读者的注意力。实测(32,386 条 / 216 轮 / 读者完全不动):选中记录在 200 行尾切片里排第p位,就只活200 − p条新记录;p = 0的一条都活不过,一个轮询周期内检视就消失——读者看到的就是「过一会检视会消失」。窄屏上的代价更直接:钉住检视面板的那次滚动,正好就是把选中行滑出窗口的那次滚动,读者没法一边看记录一边滚到面板。现在的判据:判定仍然跑在切片之后,但参数是
matched(筛选结果),不是windowed。筛选滤掉的记录失去选中——那才是清一次筛选也回不来的;被窗口滑过去的留着,因为窗口条那句「已显示 N 条 · 更早的 K 条未加载」只有尾切片继续滑动才为真,冻结窗口会让它印一句无法与真话区分的假话。所以尾切片仍然是尾切片,这条单独有断言钉着(verify-selection.mjs有一条只问它还是不是matched.slice(matched.length - state.limit))。口径变更与读点
tests/verify-selection.mjs整份重写(15 断言)。这是口径变更不是重构:该文件原本正守着「窗口会被拉到选中所在处」(断言limit === 8)和「判定发生在切片之前」——两者现在都反了,改为断言新承诺,并新增一条:revealSelection源码里不允许出现limit这个词。判据从
windowed改回matched之后这份文件又动过一次(14 → 15 条)。变化是三处:那条行为用例反过来——「选中被窗口滑过去、但筛选还留着」现在必须存活且窗口原地不动;新增一条钉住参数是matched而不是windowed;再新增一条钉住尾切片仍然是尾切片(只看判据的守卫会安安静静放过一个把窗口冻住的版本,而那正是窗口条那句「更早的 K 条未加载」唯一会变成假话的方式)。守卫
tests/verify-ledger-batch.mjstests/mutate-ledger-batch.pytests/verify-selection.mjsmatched之后)tests/mutate-selection.pymatched时新增的tests/mutate-sticky.py/mutate-turn-breakdown.py/client-check.mjs两条变异值得单独说:原来的「窗口不会被拉到选中」守的正是这次删掉的那行
state.limit写入,它现在改为弄坏替换它的新规则;原来的「判定发生在切片之后」守的是相反顺序,现在改为弄坏「判定用的是窗口而不是筛选结果」。两者都是改写,没有删除。client-check.mjs里窗口条的「全场 N 轮」现在钉在两条分支各自上——它被写了两次,只断言「字符串出现过」会被另一条分支兜住。验证
(
32/32是 §9 那一轮的条数。此后每一轮都往套件里加守卫,当前 57/57,--real同样 57/57;上面几行mutate-*的读数也只覆盖 §9 涉及的那几个文件。)浏览器实测(served 字节数 == 磁盘字节数 302,384):
正在显示 15,813 / 19,150 条,随后落定为已显示 19,157 条#1后切到最新 200:在 §9 当时的判据下检视回到尚未选择记录,表里没有任何行带aria-current,计数精确停在已显示 200 条 · 更早的 18,964 条未加载——没有像以前那样悄悄撑到 18,964。这一条在判据改回matched之后不再成立,而且方向相反:记录被窗口滑过去、但筛选还留着时,检视继续描述它;只有筛选滤掉它才清空。这个行为差异单独做过浏览器实测(钉住本会话、扫 30 个滚动位,23/23、exit 0,选中全程存活)。这一轮没做的
给「显示全部」封顶、或走增量协议,两者都改变页面已经承诺的东西,所以没有塞进一个性能修复里。