Skip to content

test(mmc-trajectory): ship the regression guards that were only on one machine - #16

Open
avatasia wants to merge 52 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory-tests
Open

avatasia wants to merge 52 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory-tests

Conversation

@avatasia

@avatasia avatasia commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

会话轨迹 — 让每一轮轮询不再重建整场会话

本 PR 在 ab717e3(检视面板跟随筛选 + sticky 重算收敛到单一出口)之上,继续处理 L0 数据层。

0. 上一轮的状态

  • Stage 1(把 L2 在文档里收成连续的)已否决,理由见 D1:查询条件是聚合,UI 与请求构造本来就该拆开。
  • D8 / D9(检视面板无响应、sticky 偏移重算只挂一根线)已修,docs/LAYERS.md 有记录。
  • 守卫 26 条,变异 34 条全捕。

1. 问题:轮询重建整场会话

一个已结束的会话不花钱——ETag 由 stat 事实派生,会话没变就是 304,什么都不重建。

花钱的是还在增长的会话,而那恰恰是读者主动盯着看的场景:每一轮轮询都从头重建一遍。

本机实测(23 代会话,payload.session.sizeBytes = 99,986,501):

23 个 generation 文件里 22 个是 snapshot 运行时轮转代时写一次,之后在压缩之间完全不动
只有 messages.jsonl 在追加 97,526 字节 = 0.10%
trajectoryCacheKey() 把每代 byteLength 拼进键 会话一增长 → 整场缓存 miss → 重读重解析全部 95 MB
其中与上一轮逐字节相同 99.90%
线上字节(5 分钟) 1.31 GB 换 118,875 字节真实新增 = 11,006×,99.99% 重发

三层各自独立地表现出同一个比例:服务端输入 99.90% 冗余、线上字节 99.99% 冗余、页面解析 ~99.99% 冗余。会话在忙碌时每 3–6 秒变一次,轮询每 2.5 秒问一次——几乎每次轮询都撞上全量重发。


2. 修法一:按代缓存解析结果

loadParsedRows() 用 path + size + mtime + limit 做键,返回任何没有变动的 generation 文件的行。

真实文件上直接测(22 个冻结 snapshot,95.3 MB):

冷读(读 + 解析全部)   306 ms
命中缓存                 2 ms
省下                  303 ms  (99%)
解析吞吐              311 MB/s

按字节预算而不是条数——条数与体积无关,一个 generation 文件可能 40 KB 也可能 10 MB。

缓存键的每个成分都对应一个它要防的失败:

  • 用 filePath 不用 fileName —— 每个会话都有一个 messages.jsonl,basename 键会让两个会话撞车。夹具专门造了这个碰撞(同尺寸、同 mtime、不同对话),断言互不可见。
  • 用 stat 的 size + mtimeMs,不用 catalog 里的 byteLength —— 代码自己在 server.mjs:1889-1894 注释了那个值是会过期的快照,而 active 文件每轮都在它之外继续增长。
  • 含 limit —— readHead 按它截断,而"是否被截断"是这条目持有的事实之一。
  • stat 与 readHead 之间的竞态是安全的 —— 条目会键在一个它并不匹配的大小上,下一轮轮询重新 stat → size 变了 → 键不同 → 重读。只会多一次读,永远不会供出陈旧解析。

3. 修法二:原文改成按需构建

buildRawPayload() 对每一条记录执行 JSON.stringify(message) —— 18,337 次、约 100 MB —— 只为回答两个问题:序列化后是否超过 MAX_RAW_BYTES(65536)、是否含 .minimax 需要脱敏。那个字符串用完就扔:splitRawRecords() 在最终序列化之前把消息摘掉,而 /raw 带走的是消息对象,不是这个字符串。

于是每轮重建都要把 100 MB 消息体完整序列化一遍、量一下、再扔。而 raw 只有打开某条记录的原文页、复制、交接时才读得到。

离线复测(同语料、同代码路径):

之前 之后
buildTrajectoryPayload 142 ms 48 ms
折算到真实 95.4 MB lineage 214 ms 72 ms

记录现在只带一个定位符(它来自 rows 的第几条),/raw 被请求时才调 buildRawPayload()——一次一条。


4. 口径变更(契约级)

「原文过大,已省略」从 bulk payload 迁到 /raw 响应。

/api/trajectory/raw?index=N 现在返回 { index, raw, omitted }。

按本项目规矩逐个列读点,并在 tests/verify-raw-lazy.mjs 里逐个加断言:

# 位置 现在读 改后读
1 index.html:2867 交接元信息 record.rawOmitted rawText === null(交接本来就先取 rawSourceFor,copySelected 同样)
2 index.html:5989 rawSourceFor record.rawOmitted → 短路 null 无条件取 /raw,省略由服务端给出
3 index.html:5998-5999 原文页提示 record.rawOmitted /raw 的 omitted
4 index.html:6007-6008 原文页内容 record.rawOmitted → — 同上
5 index.html:6059 copySelected source === null → stringify(record) 不变

五个读点没有一个在台账行上,全部在读者打开某条记录之后才发生。

读者可见的变化只有一处:原文页对「过大」的记录会多发一次 /raw 请求(之前零次)。这是一条用户主动打开才走的路径,用一次请求换每轮少序列化 100 MB。


5. 守卫 26 → 30,变异 34 → 58,全捕

verify-parsed-rows.mjs   53 断言
mutate-parsed-rows.py    12 变异

verify-raw-lazy.mjs      39 断言
mutate-raw-lazy.py       15 变异

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. 对 D10 的更正(这条我自己写错了)

D10(按代缓存)记的是「读+解析 ≈ 1,000 ms」。这个数是错的,实测 306 ms。

错在哪:那个数是用 1,138ms 重建 − 139ms stringify 减出来的,把没有查过的 buildTrajectoryPayload 和 buildRawPayload 一起算进了「读+解析」里。

所以诚实的账:

D10 里承诺的 实测
按代缓存收益 ~1,000 ms / 3.5× ~300 ms / 约 21%
常驻堆 130–290 MB(外推) 116 MB(实测,95.3 MB 源 × 1.22×)

顺带修了 D10 自己发的代码里一个缺陷:预算代理写成 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. 验证

node tests/run.mjs                          30/30
python tests/mutate-parsed-rows.py          all 12 caught
python tests/mutate-raw-lazy.py             all 15 caught
python tests/mutate-pr15-fixes.py           all 9 caught
npm run validate                            plugins/avatasia/mmc-trajectory: OK

浏览器实测(新运行时 http://127.0.0.1:55344/dashboard):

  • payload 里 raw 字段 0 个、rawOmitted 字段 0 个(18,611 条记录)
  • /raw 抽 40 条:39 条带回原文、1 条报「过大」、0 条说不清的
  • 点行 → 检视面板填出该记录;点「原文」页 → 取回并渲染出真实消息 JSON
  • console 无 error

8. 还没做的

  • 增量协议(?since=,只发新增尾部)。它比这一轮两项加起来更彻底:服务端不重建、页面不解析 36 MB、不传 36 MB,1,400 ms → ~10 ms 量级,而且不需要那 116 MB 常驻。这是这个项目真正该还的设计债,按 AGENTS.md 的规矩要先在 LAYERS.md 里定口径再动手。
  • 「显示全部」后的两百万像素 DOM:state.limit = Infinity 会一次构建 143,568 个 DOM 节点,8 万次点击测得中位143,568 节点路径是 state.limit += PAGE_STEP 的二十几倍,同样实测:这条已修(分批建行 + 进度,D12,见下)。剩下的杠杆是给「显示全部」封顶或走增量协议,两者都改承诺,未动。

9. 本轮新增:D12 分批建行 / D13 检视不再改窗口

两个缺陷,都是同一次会话上复现的,根因都是结构性的,不是少写一行。

A —「显示全部」把两万行塞进一次同步循环

实测(本机,150 轮 / 18,924 条 / 视口 735×900):

分批建行(25 轮/帧)总耗时      3,956 ms
  每批建行 JS   中位 141 ms / 最大 151 ms
  帧间(浏览器对整篇文档的 layout + paint)  ≈ 617 ms/帧

卡的不是建行 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,不这么做读者会看着计数一次次归零。

「显示全部」仍然是全部。

实测并否决的替代方案(记在 LAYERS.md D12,因为它是下一个会被问到的问题):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) 主动把窗口撑大到装下那条选中:

点下「显示最新 200 条」   已显示 200 条
一秒钟之后               已显示 15,224 条

读者自己点的按钮,在点击之后把窗口条上的数字改了。 两处错:窗口成了检视面板能写的东西(state.limit 因此有第二个写手),以及判据错了——「读者能看见什么」是窗口,不是筛选结果。

现在 revealSelection(entries) 是纯谓词:只写 state.selectedId,不再碰 state.limit;调用点挪到切片之后,参数从 matched 改成 windowed。窗口收窄到不含选中记录时取消选中,不撑大窗口——读者的窗口选择优先于更早那次点击,且与筛选轴既有规则一致。

口径变更与读点

tests/verify-selection.mjs 整份重写(14 断言)。这是口径变更不是重构:该文件原本正守着「窗口会被拉到选中所在处」(断言 limit === 8)和「判定发生在切片之前」——两者现在都反了,改为断言新承诺,并新增一条:revealSelection 源码里不允许出现 limit 这个词。

守卫

文件 状态
tests/verify-ledger-batch.mjs 新增,20 断言
tests/mutate-ledger-batch.py 新增,19 变异全捕
tests/verify-selection.mjs 重写,14 断言
tests/mutate-selection.py 9 变异全捕,其中 2 条是改写不是删除
tests/mutate-sticky.py / mutate-turn-breakdown.py / client-check.mjs 重新锚定并收紧

两条变异值得单独说:原来的「窗口不会被拉到选中」守的正是这次删掉的那行 state.limit 写入,它现在改为弄坏替换它的新规则;原来的「判定发生在切片之后」守的是相反顺序,现在改为弄坏「判定用的是窗口而不是筛选结果」。两者都是改写,没有删除。

client-check.mjs 里窗口条的「全场 N 轮」现在钉在两条分支各自上——它被写了两次,只断言「字符串出现过」会被另一条分支兜住。

验证

node tests/run.mjs                          32/32(本地与 fork 均是),--real 也是 32/32
python tests/mutate-ledger-batch.py         all 19 caught
python tests/mutate-selection.py            all 9 caught
python tests/mutate-sticky.py               all 14 caught
python tests/mutate-turn-breakdown.py       all 12 caught
npm run validate                            plugins/avatasia/mmc-trajectory: OK / repository: OK

浏览器实测(served 字节数 == 磁盘字节数 302,384):

  • 进度读数在浏览器里被当场抓到:正在显示 15,813 / 19,150 条,随后落定为 已显示 19,157 条
  • 选中 #1 后切到最新 200:检视回到 尚未选择记录,表里没有任何行带 aria-current,计数精确停在 已显示 200 条 · 更早的 18,964 条未加载——没有像以前那样悄悄撑到 18,964
  • console 无 error

这一轮没做的

给「显示全部」封顶、或走增量协议,两者都改变页面已经承诺的东西,所以没有塞进一个性能修复里。

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/.
avatasia and others added 17 commits October 11, 2026 15:40
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 MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢这么快把 D14 修掉。我在 2f6a6ba 上用和之前相同的合成会话,和 main 对照着复现了一遍:「显示全部」下单击、在建行途中连点,以及默认 200 条窗口但轮组超过 25 个的情况,最后的位置都和 main 一致(第 75 轮 / 第 248 轮),verify-anchor-carry 和 mutate-anchor-carry 也都通过。Node 22 下 npm run check 全部通过,CI 是绿的。

需要修改

  1. 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 打印出来。
  2. README 中英文第 27 行还写着类型数组「只由三个控件写入」、异常筛选「不改写类型数组」。现在勾选异常规则会调用 resetBaseFilters(),把类型改回全选,并清空搜索和时间区间,README 需要同步更新。
  3. 插件目录里的文档请清理一下。 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)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里写死了 python,在只有 python3 的 Linux/macOS 上 15 个变异测试全部失败(实测 19/34),因为没有输出 r.error,尾行是空的,看不出原因。建议依次尝试 python3、python(或读 process.env.PYTHON),找不到时把 r.error.message 打印出来。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已改。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 在文档里收成连续的

> # ⛔ 不采纳 — 请勿实施

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这份文件已经标明不采纳,也没有地方引用,内容里还有 codex exec 参数和 ~/.codex/models_cache.json 这类本机环境信息。它会随插件目录一起安装到用户机器上,建议删除,否决理由保留在 LAYERS.md D1 即可。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已删(不是标「不采纳」)。否决理由留在设计文档对应条目里,不另存一份。

补一处更正:你说「没有地方引用」这点不准 —— 设计文档里有两处引用,所以是连引用一起改的,删完不留悬空指针。

Comment on lines +484 to +485
| 7 | **跨家族验证只有 codex 一条腿** | 验证 | 环境 | claude OAuth 过期、codebuddy 要 `/login`,需 chli 交互式处理。 |
| 8 | **PR #16 reviews=0**;`.probe\perf-payload.json` 34 MB 临时产物待删 | 流程 | 等 maintainer | — |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这几行是个人流程状态(OAuth 过期、.probe\perf-payload.json 待删、reviews=0),不属于设计文档,建议移到 PR 描述或本地笔记里。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已删。第 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 字节,两处修正都在
服务版本里。
用户报:「#22501 这条消息是什么意思?为什么是 system。回复不应该是最后一条消息么。」

排查到底了,成因和边界都写进 LAYERS.md 第 2 节(D17)+ 未做清单第 11 行。纯文档,
零代码改动。

它是什么:app.asar(未压缩)里有专用工厂 task-read-settlement.js:

  createBackgroundTaskReadSettlement(timestamp) -> {
    role:'custom', customType:'background_task_read_settlement', content:'',
    display:false, details:{version:1},
    hostMetadata:{providerVisibility:'omit'}, timestamp }

全会话 19 条,跨 turn 1→168、10-10→10-12,每一轮回复后面都挂一行。

三个「不要给人看」的信号,逐个核实的结果不一样 —— 这一条改正了我最初的说法:

- providerVisibility:'omit' 真的强制:isHostOnlyMessage() 在
  projectAgentMessagesForModel() 的第一步就把它们 filter 掉,在 convertToLlm()
  之前。这些记录确定不会发给模型。我此前说「无法证实」,现在证实了。
- display:false 运行时自己一处都没执行:全 395,844,226 字节搜 `display === false`
  为 0 处命中;display 只被 appendCustomMessageEntry() 写进条目、被
  createCustomMessage() 原样读回,没有任何一处拿它过滤。
- content:'' 确实空,渲染成空白行。

所以不能说「我们违反了运行时的强制规则」—— 只有 providerVisibility 那条强制。
这点必须写清楚,否则下一个读者会以为照着 display 写过滤是在遵守一条已存在的规则。

那还算不算缺陷?算,但理由换成站得住的那条:一行没有任何内容的行不是行;同一个
文件里 compactionSummary 已经因为「客户端只按 kind 过滤」拿到了自己的 kind
(server.mjs:1203-1207),这是同一类记录。

为什么不能在前端解决:payload 的字段是 id, turnId, role, generation, timestamp,
…, kind, title, summary —— 没有 display,也没有 customType。前端只有
role:'custom' + kind:'system' + text:null,无法与任何其它系统记录区分。

「服务端直接不带出来」这条路已排除,不是保守,是它会破坏 Stage 4:server.mjs:1003
是 `index: records.length + 1`,即 record.index 是对「已发射记录」的稠密连续计数
(源行号另存 rawRow,server.mjs:995,/raw 顺着它回原始行)。少发 19 条,它之后每条
的 index 都会下移一位,正在跑的客户端手里的 ?since= 游标会指向一条它从未收到的记录,
增量协议静默丢数据。

只剩「保留记录与 index、但不渲染成一行」一条路,仍然要重答口径那一问。

排查方法也记了:app.asar 未压缩可按字节偏移搜;`display === false` 这种「字段名 +
比较」的搜法比搜 display 本身有用得多 —— .display 在 395 MB 里 2472 处命中,绝大
多数是 Function.displayName 的噪音。

未 push。
用户反馈两件事:「所以这个也是一种异常是吧?加入异常规则,支持筛查」+
「上下文代的筛选条件没有了,排查下」。第一条在这里,第二条是下一次提交。

D17 的修法方向是用户改写的,不是我原本的主张:

- 我原本说「不渲染成一行」(沿用 compactionSummary 的先例给它独立 kind),
  用户选「保留 + 可筛」。他是对的:这类记录在账本上占位是有原因的——它标记了
  一次后台任务读取被结算,抹掉之后「为什么这一轮后面多一行」就无从查证。
- 记录和 index 全部保留。Stage 4 排除「服务端不带出来」的理由不变:
  record.index 是 records.length + 1 的稠密计数(源行号另存 rawRow),
  少发几条会让其后每条 index 整体下移,正在跑的客户端的 ?since= 游标
  会指向一条它从未收到的记录。

判据用运行时实际过滤用的那个字段,不用 customType 名单:

  server.mjs 导出纯函数 isHostOnlyMessage(message)
    role === 'custom' && hostMetadata?.providerVisibility === 'omit'
  base 上带恒为 boolean 的 internalOnly

理由是「名单会过时,字段不会」。实测当场兑现:排查期间本会话又新生了一条
结算记录 #22568,页面直接把它算进「内部记账 20 条」,没有任何名单需要更新。
若当初硬编码 customType === 'background_task_read_settlement',这条一样能中,
但下一种 host-only 类型出现时就得回来改代码。

internalOnly 恒为 boolean 而非可为 undefined:规则是 r.internalOnly === true,
字段缺失会静默不命中,而缺字段恰恰是最需要它命中的那种 payload。

口径只变一处——「全部异常」的计数。四处读点逐个加了断言:
  anomalyCounts() -> syncAnomalyMode()(按钮上的数字)
  #anomaly-controls 的横向排布(多一个按钮还排不排得下)
  renderAnomalyHint()(合计 N 条 / 已叠加 N 条规则)
  ANOMALY_RULES 长度本身——verify-anomaly.mjs 原有两条断言写死 4,重锚到 5

浏览器实测(55344)。这里要纠正一处我自己写错的预期:我原本记的是
「全部异常 2098 -> 2117」,两个数都过期了——2098 是几小时前在更小的会话上
量的,19 是 D17 当时的条数。会话是活的,实测期间 toolError 从 485 涨到 506,
还新生了一条结算记录。拿旧基线比新读数永远得不到闭合的数,正确验法是同一
时刻做 A/B:

  payload 里 internalOnly:true                          20(false 22867, null 0)
  按钮「内部记账」                                        20 条
  点它之后账本实际渲染                                    20 行,索引逐条同序对上
                                                        487 1055 3438 13283 13582
                                                        13821 13935 17161 17261
                                                        17281 17311 17876 18134
                                                        18425 20862 21649 21821
                                                        22200 22501 22568,摘要全空
  多选·全勾        「全部 5 条规则同时生效 · 合计 2125 条」
  多选·去掉本条     「已叠加 4 条规则 · 合计 2105 条」
  差值             2125 - 2105 = 20

差值闭合一次证明了两件事:新规则确实进了并集,且它与另外四条零重叠——若重叠,
差值会小于 20。筛选态窗口条「已显示 20 条」,「更早的 N 未加载」后缀消失
(筛选后总数 20 < 窗口 200,全部呈现)。console 零 warn/error。

顺带回归 D15:滚到底(7554 px)后上滚 300 得 actualDeltaY -300、atStart false,
本轮多次轮询重建之后 0px 塌陷未复发。

守卫:
  tests/verify-anomaly.mjs    规则集 4->5;新增 5 条行为断言(命中 / 不命中
                              普通记录 / null·false·缺失三态不混淆 / record
                              作用域 / 单位是「条」不是「轮」)。258->265
  tests/verify-raw-lazy.mjs   新增 §6:独立 4 行 fixture(settlement /
                              custom 无 marker / providerVisibility:'pass' /
                              assistant 带 omit)+ 6 条断言。39->46
  tests/mutate-host-only.py    5 条变异全捕:丢 role 检查 / 任何
                              providerVisibility 都算 omit / flag 变 undefined /
                              host-only 记录干脆不发出来 / 规则改按 kind 选

run.mjs 45/45(原 44 + mutate-host-only.py),--real 45/45,
make-fixtures --check 未漂移,npm run validate 报 mmc-trajectory: OK。
用户报两件事:「上下文代的筛选条件没有了,排查下。怎么解决」+「希望滚动的时候,
能出现在 全部 这行的最后面。共用一行」。

先说排查结论,因为它推翻了一个更顺手的解释:它没有「没有了」。#generation-field
在页面里没有 hidden(HTML 里写死的那个已被 dom.generationField.hidden = !multi
解开),29 个选项齐全(第 0-28 代 + 全部),当前值「全部(默认)」。它只是住在
#toolbar——带子面唯一一个会滚走的块。读者滚到账本中段想换上下文代,只能爬回顶部。

所以这不是「控件坏了」,是「控件不在读者在的地方」。而且没有任何守卫断言过它住在
哪,这就是为什么这种改动不要成本。全部 47 个守卫在它回到工具栏时依然全绿。

四问(详见 LAYERS.md D18):

- 归层 L1(选哪一段上下文),不是 L2 筛选。依据:它和「会话」同类,且从不带
  data-filter-scope。
- 关联:与类型行并排,但不进 #kind-controls——那是 role="toolbar"
  aria-label="按类型筛选轨迹",放一个不筛类型的控件进去那个 label 就开始说谎;
  也不带 data-filter-scope——那条属性由 body[data-anomaly-mode='on'] 的 CSS
  变灰加 syncAnomalyMode() 里的 inert 消费,标上它就是在读者正读着一屏失败项、
  最想换上下文代的那一刻把它变灰并移出 Tab 序。
- 布局:#filter-sticky 内新增 .filter-row,不新增 sticky 区域。flex-wrap: wrap 是
  全部的降级设计——.traj-controls 是 flex: none,不 wrap 的话放不下会把整行撑得
  比页面还宽,而不是降到自己那一行。
- 口径不变。选项、值、hidden 逻辑、横幅文案与其来源 statsAll() 全部原样。唯一受
  影响的是钉住偏移,而它由 syncStickyOffsets() 实测(D9 原就覆盖,横幅出现/消失
  会让带高变化,实测跟着变)。

标签走 sr-only 而不是删掉,因为算过了:845px 下类型行右侧余 176px,picker 收成
20px/12px 后实测 141px,加 12px 间距 = 153px 放得下;外露「上下文代」是 48px 文字
+ 8px 间距 = 再多 56px -> 209px,放不下。<label> 的全部意义是可及名,不是那 56 个
像素。

我第一次算错了,错在漏了容器自己的内边距:

  类型行内容 574 + 间距 12 + tail(141 + 左右 16*2 = 173) = 759
  可用内容盒                                          = 750   -> 差 9px

flex-wrap 按设计生效,picker 换到了自己那一行。什么都没坏,只是没共用成一行。去掉
tail 的左内边距(margin-left: auto 本来就拉开了距离)之后共用一行。结论:共用一行
这件事只能由浏览器回答,算术只能给出候选。

守卫自己抓到的一个真缺陷:margin-left: auto 在我改内边距的那次编辑里被漏掉了。
picker 仍然落在右边——因为 642px 既是间距算出来的位置、也是 auto 会给的位置,今天
的数字恰好一致。截图看不出任何问题,而类型行宽度一变它就会漂。是那条「tail 不得有
左内边距」的断言抓到的,不是眼睛。这条也补了变异。

横幅 #generation-hint 一并搬进带里:它是这个 picker 的 aria-describedby 目标,只在
钉住某一代时存在,而那正是读者滚了几千像素进冻结快照、最容易忘记自己正看着快照的
时刻。留在账本面板里,它会一边滚走一边把制造它的控件留在钉住状态。

tests/verify-generation-pin.mjs  23 条
tests/mutate-generation-pin.py   12 条变异全捕

浏览器实测(55344,视口 845x900):

  #generation-select rect  x 650, y 6, w 141, h 20
  #kind-all                 y 6, h 20            -> 同一行
  右缘 650+141 = 791,对齐内容盒右缘 790
  滚 6000 px 后仍在带里,类型行末尾
  异常规则开着:类型行整排变灰,picker 全亮;工具栏里搜索框/时间区间/快捷按钮全灰、
                「会话」全亮。未标记控件确实不受影响
  钉到第 27 代、滚 4000 px:异常计数 513/28/14/3/20/2,132 -> 18/1/0/0/3/22,
                横幅落在带里,概览条与窗口条依次下移无重叠
  窗口条 全场 8 轮 · 共 914 · 已显示 200 · 更早的 714 未加载(200+714=914)
  console 零 warn/error

一条查了才敢写的结论:深滚时总览条和窗口条之间会漏出约 1-2 px 的一行轮组标题。我
一度以为是自己引入的,用 git stash 回到改动前的版本量了同一个数——#overview-track
两版都是 y 111 / h 50,带高与接缝几何完全一致,差别只在于那一瞬间正好是哪一行轮组
贴在缝上。那是既有缺陷,记进未做清单第 12 项,本轮不动。

顺带记一条踩坑:git stash pop 把 fork 那份 index.html 检出成了 CRLF(332,754 字节),
workspace 那份仍是纯 LF(325,311)。在这个仓里不要对 index.html 用 git stash /
git checkout,一律从 workspace 拷贝覆盖。

run.mjs 47/45 -> 47/47(新增 verify-generation-pin.mjs + mutate-generation-pin.py),
--real 47/47,make-fixtures --check 未漂移,npm run validate 报 mmc-trajectory: OK,
served = disk = 325,561 字节。
D18 把上下文代 picker 搬进了钉住带,位置对了,但同时给了它一个永久的位置:读者
滚到第 40 轮,它仍钉在顶上占一整行,而那行此刻没有任何用处。用户要求「根据滚动动态
移位置」——动态移位置不是换个地方放,是它在不在。这是 D18 的账,不是新缺陷。

四问(详见 LAYERS.md D19):

- 归层 L1,但是呈现状态,不进 state。选哪一代上下文是 L1(state.generation,D18 已
  定);「此刻让不让位」是纯呈现,不该有字段。放进 state 就会被 2.5s 一次的 render()
  按当前 state 重算回来,读者连续下滚时每 2.5s 闪一次。所以它是 body 上的一个 class,
  与异常轴的 body[data-anomaly-mode] 同处一个元素、互不冲突,且只有滚动监听一个写者。
- 关联:与类型行 chip 并列不互斥(chip 全部常驻,picker 让位时类型行不动);钉住态
  永不隐藏(state.generation !== null 直接否决)——读者正因为选了某一代才需要反复
  确认「我还在这一代」,把它藏起来是在他最需要的时候添乱。
- 布局:不新增容器、不改带高、控件不搬家。不搬来搬去是因为那会在滚动中更换可聚焦
  控件的 DOM 父节点,销毁读者根本没碰过的键盘焦点——他 Tab 到 picker 上、继续读、
  往下滚,焦点就没了。同一个家里变 visibility 没有这个问题。
- 口径不变。选项、值、横幅、计数一个字未动;--sticky-band-h 由 syncStickyOffsets()
  实测(D9 原就覆盖)。

visibility 而非 display:none:display 会把 --sticky-band-h 变成滚动方向的函数,每滚
一下就要重测重排一次带高,并把 D9 的 syncStickyOffsets() 拖进滚动热路径。visibility
保留布局盒,带高恒定。隐藏态延迟 140ms、显示态立即,反过来会让键盘用户 Tab 到看不见
的东西。

三个数字,以及它们各自在替谁挡:

  READ_AWAY_PX   = 240   一次连续下滚要走多远才让位,约六行。想在读到一半时换上下文
                       代,读者得先往上滚一点把它叫回来——这个代价是真的,所以阈值要
                       够长,让「只是安定下来读账本」的人永远不必付。
  SCROLL_JUMP_PX = 120   单次位移超过这个数就不算滚动。restoreRowAnchor() 每次账本
                       重建都会挪窗口,一次 700px 的重建跳转读起来就是「正在下滚」,
                       会在读者什么都没做的时候把 picker 收走。跳变重置 run 但不改状态。
  SHOW_EPS_PX    = 8     文档顶部永远显示。

五分支的顺序本身是被守卫钉住的:页顶 -> 跳变 -> 上滚回来 -> mayStepAside 否决 ->
累加 run 达 240 才隐藏。

守卫抓到的一个真 bug:页顶判断原本排在跳变守卫之后。读者从 500px 一键跳回顶部,那是一个
-500px 的跳变,被跳变守卫当「不是滚动」吃掉,picker 就留在隐藏态——恰好停在它最该
出现的地方。修法是把页顶提到最前。「跳变不算滚动」这条规则本身没错,错在它排在「页顶
必显示」前面。

tests/verify-reading-away.mjs  24 条。除静态断言外用 node:vm 桩驱动 onScrollFrame 做
                             行为测试——静态断言证明不了顺序。
tests/mutate-reading-away.py   15 条变异全捕

浏览器实测(55344,视口 845x900 @2x,截图 1690x1800):

  阈值      100px x1(scrollY 200)    class=""                  200 < 240
  阈值      100px x3(scrollY 300)    class="is-reading-down"
  视觉      截图                       类型行末 picker 消失,原位留空、行高未变
  上滚      100px                      class=""
  焦点否决  点选 picker(#generation-hint 仍 hidden -> 未钉住,隔离干净)
            + 累积 300px               class=""
  钉住否决  键盘钉第 29 代(a11y 树「第 29 代(当前)」selected: true)
            + Tab 失焦 + 再累积 300px   class=""
  跳变      单次 500px 下滚             仍显示(跳变分支不改状态)
  页顶      隐藏态下 -300px 跳顶       class=""
  带高 A/B  显示态 --sticky-band-h: 84px / 隐藏态 84px           一致
  位置      scrollY 800 钉住态         #generation-select x 650, y 6, w 141, h 20
                                                            与 D18 同值
  console   warn + error(重载后)     0 / 0

撤回一条自己的错误结论:先量到「钉住时异常行 y 40」再量到「未钉住时 y 67」,一度判成隐藏
让带塌了。那 27px 是 #generation-hint 横幅,只在钉住时存在;两次读数取自不同的筛选状态,
不是同一次 A/B。改用 --sticky-band-h 这个带高真正的消费方做同状态对照,才有上面那行
84 = 84。顺带把这条钉死:带高 84px(未钉住)/ 111px(钉住,横幅在)——D18 表里那个
「深滚后 111」是钉住态的数。

一次「怎么滚都不隐藏」的排查结论:先怀疑 state.generation 没被「全部」复位,查了赋值点
(index.html:7390-7391,'all' 不匹配 ^\d+$ -> null)排除,也不是焦点残留。真因是跳变
守卫撞上 2.5s 轮询的 restoreRowAnchor(),而我的探针每次 query/inspect 要 1-2.5s,必然
插进两次滚动之间把 downRun 清零。这是设计(D14 的出口会动窗口,跳变守卫就是为它写的),
不是缺陷;重载后连着滚三次即复现隐藏。真实读者连续滚动时 2.5s 内会产生多个小 delta,
run 照样累积。

一条浏览器工具契约备忘:这个 provider 的 select_option 与带 target/source 包装的
click/press_key 一律 invalid_input,ref 要平铺在 input 里;原生 select 改值的可行路径是
click 聚焦 + 裸 press_key,而 query(kind="dom") 读不出 select 的 value(序列化里没这个
属性),判据要用 #generation-hint 的 hidden。

LAYERS.md 补 D19 小节(四问 / 三个数字的由来 / 五分支顺序 / 实测表 / 两条自我纠错 /
一条工具契约备忘),§7 第 13 行指向 D19。

run.mjs 47/47 -> 49/49(新增 verify-reading-away.mjs + mutate-reading-away.py),--real
49/49,make-fixtures --check 未漂移,fork checkout 内重跑 49/49,npm run validate 报
mmc-trajectory: OK,served = disk = 331,898 字节。
用户截图报:深滚时概览条和窗口条之间出现一行被切掉顶部的文字。未做清单第 12 项记的
是「约 1–2 px 的视觉残留」,那个描述是错的:实测是一整行里约 7px 高的字形残片,横跨
窗口条整行宽度;而且内容不是轮组标题,是 .window-bar 自己那一行的文字。

根因不在 CSS,在守卫。verify-sticky.mjs 断言的是:

    // The strip paints through its plot, not through the section: the section is the sticky box
    // and the plot is the 50px that fills it.
    assert('the overview strip is opaque', isOpaqueBackground(value('.overview-plot', 'background')));

断言的是一个关于子元素的真命题,而真正必须为真的那个命题挂在钉住盒子 .overview 自己
身上。那条注释把这个做法合理化了,于是「概览条是钉住链里唯一一段直接压在另一段上面、
却唯一不自带底色」这件事,从来没有任何守卫在看。.overview 的盒子透明,下面 z-index 3
的 .window-bar 滚上来就从接缝里透出一行它自己的文字。

这是本仓最贵的一条教训:守卫可以全绿,同时它断言的是一个错的东西。「有没有守卫」不是
「有没有被挡住」。当时该问的不是「缝在哪」,是「谁负责遮住自己脚下」——而这个问题的答案
在守卫里已经被写错了。

四问(详见 LAYERS.md D20):

- 归层 不写任何 state。归属 D9:这条缝就是 sticky 链相邻两段的接缝,改的是谁负责遮住
  自己脚下,不是偏移的计算。D9 的唯一写手 syncStickyOffsets() / 唯一调用点原样有效。
- 关联 与 .filter-sticky / .window-bar / .turn-head 是同一条 sticky 链上的并列关系,不
  新增层、不新增区域。改完之后四段全部自带底色(.turn-head 是 --mcode-bg-secondary,
  其余三段 --mcode-bg),这是这条链第一次状态一致。
- 布局 只加一条 background。background 不改盒模型,带高与偏移一个数都没动。
- 口径 不变,零个数字改动。

读点(.overview 之下滚过的一切,逐个确认过):#ledger 的所有 .row、.turn-head、以及
z-index 3 的 .window-bar。其中只有 .window-bar 会正好停在接缝上——它的 top 就是
band + overview,两者首尾相接。

守卫生效方式不是加一条,是把错的那条改对,并拆成三条不同的要求:

- the pinned strip owns its background          -> 断 .overview 自己,本条缺陷真正的那条
- the strip's lanes area is painted             -> 断 .overview-plot。它也是真不变量(三条
                                                   轨道之间的空隙就是它的底色,账本行会
                                                   从那里透出来),但是另一条,不能顶替上面
- every pinned strip has something scrolling into its seam
                                                -> 断 z-index 链上「上段必须压过下段」。
                                                   这条不是装饰:正是它让上面两条成为承重
                                                   假设,而不是可有可无的美观要求

方法论:拆开之后才发现,原来那条变异(删掉 .overview-plot 的底色)在新守卫下会存活。
按本仓规矩「变异存活 = 补守卫,绝不删变异」,处理办法是把它改名成它真正守的东西,再补
一条删 .overview 底色的——两条变异、两条断言、各自指名。变异 25 -> 28,全捕。

浏览器实测(55344,视口 897x900 @2x):

  scrollY 1456 接缝   修前 工具轨道下方一行被切顶的字形残片   修后 干净
  scrollY 3000 接缝   修前 同上                              修后 干净
  --sticky-band-h     84px  -> 84px
  --sticky-overview-h 51px  -> 51px
  --sticky-window-h   51px  -> 51px
  console             零 warn/error

偏移三个数改前改后逐个相同,这就是「口径不变」的实测依据,不是推断。

一条我查了半天才敢下的结论:我最初的推理是「.overview-plot 的不透明底色已经把那 51px
画满了,所以给 .overview 加背景色不该有任何区别」。实测它有区别,幽灵消失了,而我原先
的解释解释不了这一点。记录这一点是因为它正好说明「推理听起来自洽」和「机制真的成立」是
两件事——和 D19 那条错误的带高 A/B 是同一个教训的两种形态。我不声称完全弄清了浏览器在
这一步上的确切绘制路径;能确证的是:幽灵可复现(chli 截图 + 我本地两次)、加底色后消失、
而几何一个数没动。

run.mjs 49/49(verify-sticky.mjs 断言 25 -> 28),--real 49/49,mutate-sticky.py 变异
25 -> 28 全捕,make-fixtures --check 未漂移,fork checkout 内重跑 49/49,npm run validate
报 mmc-trajectory: OK,served = disk = 332,583 字节。

LAYERS.md 补 D20 小节,§7 第 12 项标记已修,并改正原描述里两处错误。
chli 报:「当前上下文轮这个筛选框不见了. 只要滚动 就不可见. 只有在那个区域才会显示,
应该跟全部这一行在一个层里控制.」

D19(3f65f1b)让 picker 在连续下滚 240px 后让位、任何上滚立刻回来。四问当时一条不差地
答了, 而结论仍然是错的:

- 筛选控件不该在读者刚开始读的时候消失。让位态的存在前提就是一种互斥, 而「读者正在往下
  滚」不是「读者不要这个筛选器了」的表态; 找回它的动作没有任何可发现的含义。
- 它把带内控件的可见性交给了带外信号。body.is-reading-down 挂在 <body> 上, 由滚动方向
  驱动。筛选带是 L1, L1 不读滚动方向——名义归属和真实归属不一致, 正是这套分层文档要防的
  那件事。
- 240px 是我估的阈值, 而读者要付的代价是「读到一半想换上下文代, 得先往上滚把它叫回来」。

D19 自己实测表里那条「带高 A/B 84 = 84」测得没错, 但它只证明 visibility 保住了布局盒,
没证明这个控件该被藏——用一个技术正确的实现掩护了一个产品上错误的决定。

撤回范围
- index.html: body.is-reading-down 规则、.filter-row-tail 的 transition、三个常量、五个
  函数、wire() 里的 scroll 监听, 以及随之变成死代码的 dom.filterRowTail
- tests/verify-reading-away.mjs + tests/mutate-reading-away.py 整条删除。整条特性撤回,
  它的守卫就整条撤回——这不是「变异存活」, 那是另一次判定。
- D18 的成果一条没动: picker 仍在 .filter-row-tail, 仍在类型行末, 带高仍 84(未钉住)/
  111(钉住)。

新不变量(verify-generation-pin.mjs 23 → 26 条, mutate-generation-pin.py 12 → 15 条全捕)
- 带内没有任何元素被 <body> 上的状态 class 显隐
- 页面不注册任何 scroll 监听器
- 带默认显示 picker, 它自己的规则不 visibility / opacity
三条变异分别把那条规则、那个滚动监听、tail 自带的 visibility 加回去, 守卫逐条点名报出。

LAYERS.md 的 D19 小节整段改写为撤回记录, §7 第 13 项的后续说明改正。

验证: 全量 47/47(含 --real), fixtures 未漂移, index.html 纯 LF 327068 字节,
served = disk = 327068; 浏览器滚 600 + 900px 后 picker 仍在类型行末, 钉住链几何与改前
基线逐项相同, console 零 warn/error。

未 push。
chli 在 e5e847f 撤回 D19 后复测: 「当前滚动没有变矮 ... 但是我发现好了.」

LAYERS.md 的 D19 小节末尾原本写着「这条我不复现, 也不替它编机制」。那句话已经过时,
整段改写。

确证的: 撤回 D19 去掉的是一整条机制——滚动监听切 <body> class, 改带内后代的
opacity/visibility, 走一条 140ms 过渡。同机同会话同一个产物, 症状随之消失。判据是
chli 的复测, 不是我的截图——我的每一帧都在滚动结束之后。

没隔离的: CSS 规则、.filter-row-tail 的 transition、scroll 监听是一起删的, 事后拆不开。
「带内的 opacity 过渡本身会不会导致这个」未被证实也未被证伪。要彻底隔离得把三样分开
加回去各测一次, 那是一次故意把缺陷放回去的实验, 本次没做。

我错的地方: 那个分岔问题 chli 没答成, 他直接报了更要紧的 picker 消失。我撤回了 picker,
然后没有回头收口这条。修 picker 是对的, 但这不是顺手解决的廉价副产品——它是被同一段
代码带走的, 而我手里已经握着答案的形状, 没有去验。这是本轮第三次撞到「我没抓到那一帧
不等于那里没有机制」这堵墙。

现状可核: grep 全表, #filter-sticky 内 transition / animation 零命中。仅存的
.kkpi-tip 那处 opacity 过渡在关键指标层(L2, 会滚走、非 sticky), 且只在 hover/focus
时跑。

留一句给下一个人: 以后要在筛选带里做淡入淡出(最可能是钉住上下文代时把
#generation-hint 淡入), 先在这个页面上滚一遍验一遍。

未 push。
原先写「很可能已被 D15 那个缺陷解释」而不写「已解释」, 理由是当时只写了候选解释、没有
专门回去复测。现在查完了, 分两层:

结构层(决定性): is-skipping-offscreen 跨重建留存这个机制现在结构上不存在——
index.html:5426 在 clear(ledger) 之前 ledger.classList.remove(...), 且被
verify-intrinsic-size.mjs 钉住(skipOff > 0 && clearAt > skipOff, 外加正则确认删的是
ledger.classList), mutate-intrinsic-size.py 里有一条变异专门删掉这一行并被点名捕获。

时序是关键: 那次 77,226px 的观测发生在 D15 实测期间, 而该缺陷是之后才定位并修掉的,
也就是观测发生时它还活着。

实测层(对齐了当初错开的条件): 条件必须是「显示全部」且读者不在首批附近——默认窗口只有
200 条、跨 2-3 个轮组, 小于 LEDGER_BATCH_GROUPS = 25, 整个账本就是首批, 缺陷不发作,
这正是当初复测对不上的原因。照此复现: pageHeight 892,011、scrollY 884,913.5(锚点恢复
后屏上仍是 #24337-#24355 同一批行), 连续等距滚动 300px x 2, actualDeltaY 精确等于请求值,
文档未塌。

样本只有 2 个, 证明不了罕见事件不发生——关掉这一条的是结构层, 不是样本。

未 push。
同一行印两个不能相加的数:筛选开着时「共 22,066 条」是整场未筛选的
state.flat.length,而同一行的「已显示 200 · 更早的 313 未加载」加起来只有
513(筛选后)。读者把同一行两个数相加会得到两个答案。

基线就有(origin/add-mmc-trajectory-tests 的 index.html:5202 / 5206 就是
这两行),8 个提交一行未改,不是 Stage 2 制造的。

修法是加数,不是改口径。现有四个数字的口径一个都不动,只在
updateWindowBar() 结算那一支补上缺失的那个:

  var matchedCount = shown.length + hiddenAbove;
  var matchedLabel  = matchedCount === total ? ''
                    : ' · 命中 ' + formatInt(matchedCount) + ' 条';

三个取舍:

- 派生而非传参。windowed 和那两个数本来就在函数手上,再加第四个参数就是
  同一事实的第四份拷贝,而四份拷贝的计数正是两份开始打架的起点。
- 只在不同时打印。无筛选时不打「共 24,657 / 命中 24,657」。
- 进度那一支刻意不插。那支的 shown / hiddenAbove 存于 ledgerWindow,是上一
  趟的值,插进去是过期数;且 ledgerProgress.total 本就是窗口规模,那行不可
  相加,本来就没有歧义要修。配了一条断言专门禁止它印。

变长不会溢出是既有结构保证,不是新加的:.window-bar-actions 有
flex-wrap: wrap、计数 margin-left: auto,变长最多换行。

浏览器实测(http://127.0.0.1:55344/dashboard,897×900):
  取消「思考」→ 共 24,657 · 命中 20,540 · 已显示 200 · 更早的 20,340
                200 + 20,340 = 20,540  闭合
  下一次轮询  → 共 24,661 · 命中 20,543 · 已显示 200 · 更早的 20,343
                200 + 20,343 = 20,543  闭合
  取消「思考」→ 共 24,644 条(无命中数,不重复印)

守卫:verify-ledger-batch.mjs 21 → 25 条(派生而非第四参数 / 结算行打印 /
只在不同时打印 / 进度行不得打印),mutate-ledger-batch.py 21 → 25 条变异
全捕。

顺带重锚两处:新字面量让 client-check.mjs 的两条分支断言与
mutate-turn-breakdown.py 的一条变异锚点失效(0 命中),已重新锚定到含
`+ matchedLabel + ` 的文本并保持原有严格度——锚点失效必须重锚,不能因为
「新写法更顺眼」就顺手动断言。

LAYERS.md §7 第 10 项已关闭,并更新了它下方那条邻近的跨面单位不一致:
本条只让窗口条这一行自洽,「共 / 命中」现在都是记录数;选项括号里的数字
来自服务端 generations(消息数),仍与记录数不同,那是独立的一件。

验证:fork 内 47/47(node tests/run.mjs 与 --real)、make-fixtures --check
未漂移、npm run validate 报 plugins/avatasia/mmc-trajectory: OK /
repository: OK。index.html 328,695 字节纯 LF,已发布且 served == disk。
症状:视口窄于 640px 时文档比视口宽,横向滚动最多 262px。没有人报过这条——
它来自未做清单第 6 项「窄屏 sticky 只有代码证据,没有浏览器实测」本身。

## 那条环境判断是错的

「in-app 浏览器视口固定 735×900 且不可 resize」不成立。headless Edge + CDP 的
Emulation.setDeviceMetricsOverride 可设任意视口,Node 24 自带 WebSocket 不需要装
依赖。视口宽度从此是**可扫的参数**,不是本机碰巧的值。

三处踩坑都在探针这一侧,已修并记在 .probe 里(不提交):

- mobile:true 会把布局视口从 390 撑到 623。「窄屏」在这里指窄桌面窗口(735 那类),
  不是手机,必须 mobile:false。
- --window-size 与 override 打架,去掉。
- 第一版判据在未钉住态判相邻性——未钉住时 sticky 元素在正常流里,本来就有文档流
  间距,那是正确布局。判据改为只在钉住态判。

## sticky 三段堆叠本来就是对的,溢出是另一件事

390px 钉住态:band|overview = -0.2,overview|window = 0.0,三段实测高度与
syncStickyOffsets() 写的 var 全一致,窗口条文字不溢出。

溢出的是整页:文档恒为 622px。元凶是 .traj-controls-actions 那十个中文标签,恒为
574px 不随视口变(360→640 全程实测),而 .traj-controls 是 flex:none——整条链的
宽度由内容决定,所以 .filter-row 的 flex-wrap 只能让 picker 掉行,对一个本身比页面
还宽、且拒绝收缩的子项无能为力。

样式表注释写过「no clipping, no sideways overflow」,verify-generation-pin.mjs 也
真的断言了 .filter-row 有 flex-wrap: wrap。那句话对行成立,对页面是假的。

## 这次的关键不是缺陷,是修法里有一半会骗人

我第一版只给 .traj-controls-actions 加了 flex-wrap: wrap,同时加了一条断言断言这个
属性。守卫绿了,变异也抓得到。**但实测 documentElement.scrollWidth 一个像素没变。**

原因是规格行为,不是声明写错:shrink-to-fit 的 flex 容器永远恰好等于它最宽的一行,
所以它永远不可能换行,无论被告知什么。「声明了 wrap」和「wrap 生效了」是两件事,
而我那条守卫断言的恰好是前者。

同刻 A/B(.probe/wrap-ab.mjs,运行时注入样式,一个页面一份数据,只变样式):

  变体                            360 时 docW   chips 高  band 高  ≥640 时
  A 现状                          622 (+262)    20       219      —
  B 只加 wrap                     622 (+262)    20       219      —
  C + .traj-controls max-width    606 (+246)    20       219      —
  D C + chips 可收缩              360 (溢出 0)  64       263      与 A 逐值相同

B 等于 A 是决定性的那条。真正要打破的是「宽度由内容决定」这个循环:
.traj-controls 需要一个来自视口的宽度上限,chips 需要允许收缩并且 min-width:0
(flex item 的 min-width 默认 auto,即内容最小尺寸)。三条属性任何一条单独存在都
不起作用。

修法两条声明:

  .traj-controls          { max-width: 100%; }          /* 新增:cap */
  .traj-controls-actions  { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; }

.traj-controls 的 flex: none **原样保留**——它守的是「类型行不压缩,按钮不被压扁」,
本条加的是 max-width,不是把它改成可收缩。

## 四问

1. 归层 — L2 筛选层的呈现规则,纯 CSS,不写任何 state。
2. 关联 — 与 .filter-row 的 flex-wrap 是同一套两级降级的下半级(上:picker 掉行;
   下:chips 换行),与 .window-bar-actions 的 flex-wrap 并列。
3. 布局 — 不新增任何节点、容器、sticky 区域。带高变化由 syncStickyOffsets() 实测接住:
   实测 360 时 --sticky-band-h 219→263px、390 时 241px,下游两个偏移跟着走。
4. 口径 — 零个数字改动。

## 守卫拆成三条,因为三条互不替代

wrap(chips 换不换)/ max-width: 100%(有没有地方换)/ flex-shrink + min-width: 0
(换的时候会不会收缩)。只断言 wrap 就是一条恒真守卫——它满足、变异能过、页面一个
像素不变。第三条对 flex 简写做了解析(none → 0,0 0 auto → 0),否则 flex: 0 0 auto
这种写法能绕过它。

verify-generation-pin.mjs 26 → 29 条,mutate-generation-pin.py 15 → 20 条变异全捕,
新增五条:删 wrap / 写成 nowrap / 删 max-width / chips 改回 flex:none / 删 min-width。

过程中两条旧变异的锚点因 CSS 改写而失效,一条新锚点匹配 2 次、一条因注释块夹在中间
匹配 0 次——全部重新锚定,没有一条是靠放宽匹配蒙过去的。

## 修后实测

同一扫描,360 / 390 / 420 / 480 / 560 / 600 / 640 / 735 / 900 **全部 ovf = 0**;chips
宽度随视口走(296 / 326 / 356 / 416 / 496 / 536 / 574)不再钉死;**≥640 时 chips=574、
--sticky-band-h=181/143/84,与修前逐值相同**——窄屏以外零回归。

验证:fork 内 47/47(node tests/run.mjs 与 --real)、make-fixtures --check 未漂移。
index.html 331,010 字节纯 LF,docs/LAYERS.md 132,874 字节纯 LF,已发布且
served == disk == 331010。

LAYERS.md:第 6 项已关闭,新增 D21 小节。

一条与 D20 同族但方向相反的教训,写进 D21:**D20 是「守卫全绿,断言的是错的东西」,
这一条是「守卫全绿、变异能抓、断言的是真属性,而属性没有产生任何效果」。断言一个
属性存在,不等于断言它起作用。** 只有把「声明了 wrap」和「页面变窄了」放进同一个 A/B
才知道哪个是真的——所以本条的全部证据是一次同刻对比,不是一组绿勾。
LAYERS.md 未做清单第 9 项。codex 那轮交叉核对定的排序照办——先做「默认值只有
一个写者」,`state.base` 那个命名空间放最后——所以本条不是收字段。

## 缺陷

`clearFilters()` 和 `resetBaseFilters()` 读起来都叫「重置」,做的却不是同一件事。
`resetBaseFilters()` 清 5 样,`clearFilters()` 平行地又赋了一遍其中大半,而且**漏掉了
`state.overviewSelection`**——那个字段参与 `visibleEntries()`(`index.html:4931`)与
`isFiltered()`。

于是空态里那个「清除筛选」按钮清了类型、搜索词、异常规则和时间区间,唯独把概览条上
的刷选留着,而刷选正是账本为空的原因之一。读者按了按钮,什么都没回来;再按一次,还
是什么都没回来。这是基线就有的,不是本轮任何改动引入的。

## 四问

1. 归层 — L2 基础轴的默认值重置。纯函数重构,不新增 DOM、不新增层。
2. 关联 — `clearFilters()` 从「与 `resetBaseFilters()` 并列但内容漂移」改为**从属**
   (调用它);`clearOverviewSelection()` 从自己写字段改为共用新的 `dropOverviewSelection()`。
3. 布局 — 不动。
4. 口径 — **这一问不是「不变」**:`clearFilters()` 现在会清刷选,所以点了它之后账本行数
   会变——那正是缺陷本身。读点已列全:`visibleEntries()`(`:4931`)、`isFiltered()`
   (`:5178`)、`renderOverviewSelection()`(`:4361`)、两个 pointer 处理器、`setActualDuration()`。

## 做法

新增 `dropOverviewSelection()`:`abortOverviewDrag()` + 写 null。不动 zoom,不重新决定窗口,
不 render——因为它是**重置路径**要的那一半,而 `clearOverviewSelection()` 是**控件**,控件
还要清 zoom 并重画。

```
function clearFilters() {
  resetBaseFilters();          // ← 基础轴默认值只有这一个写者
  state.anomaly = defaultAnomaly();
  resetWindowMode();
  ...
}
```

顺带修掉一个小缺陷:`resetBaseFilters()` 原先直接写 `state.overviewSelection = null`,
**从不中止正在进行的拖拽**——拖拽会继续按已被丢弃的范围重画。现在走
`dropOverviewSelection()`,会 `abortOverviewDrag()`。

## 浏览器同页 A/B(.probe/brush-reset.mjs)

页面是 IIFE,刷选只能真拖、按钮只能真点,读源码看不到行为。探针用 CDP 派发真实指针
事件画出刷选,再用真实鼠标点「清除筛选」:

  无筛选    共 25,271 · 已显示 200 · 更早的 25,071     (无「命中」)
  刷选后    共 25,271 · 命中 2,533 · 已显示 200 · 更早的 2,333
  搜索打空  rows: 0,「清除筛选」按钮出现
  点清除后  共 25,271 · 已显示 200 · 更早的 25,071     (无「命中」)

  把缺陷放回去 →  同一位置变成「命中 2,520」仍在,探针精确报出两条 FAIL
  还原后     →  pass = true,文件逐字节校验一致

**行数不能当判据**:无筛选和刷选后都是 200 条窗口上限,两者都读 200,天然不敏感。判据
只能是窗口条的「命中」出没。第一版探针用行数,得出过一条「没变化」的假读数。

## 守卫

- `verify-anomaly.mjs` 265 → **269**:新增**运行时**跑 `clearFilters()`。同时按本文件既有
  风格 stub 而非提取 `abortOverviewDrag()`(拖拽中止属于概览条,不属于异常轴)。
- `mutate-anomaly.py` 74 → **75**:新增「退回自己赋值并漏掉 brush」——这是代码自己会漂
  回去的形状,比删掉整个调用更安静。
- `verify-cross-layer.mjs` 12 → **15**:R5 的写点数仍不可断言(断言「已收敛」过不了,断言
  「当前 4 个」恒真),所以断言的是**真的那条**——两个「清空」路径共用一个写者,且各自
  不得同时又自己赋值。每条都带否定半边,只有肯定半边会漏掉这正是起始形状的漂移。
- `mutate-cross-layer.py` 6 → **9**,其中第三条就是**缺陷原样**(`resetBaseFilters` 自己
  赋值),不是它的某种变体。

## 代价(记清)

- `state.kinds` 的字面赋值点 4 → 3(控件数没变,两条路径共用一个写者了)。
  `client-check.mjs` 那条 policy 改成「3 处直接 + 1 处委托 = 4 条路径」,两半都查——三处
  赋值而无委托是三个控件,委托而不发生是一个控件。
- `verify-anomaly.mjs` / `mutate-anomaly.py` 共 5 条锚点因函数搬家与 `clearFilters` 瘦身而
  失效,逐条重新锚定。其中 `verify-anomaly.mjs:891` 那条原本找
  `resetBaseFilters` 源码里的 `state.overviewSelection = null` 字面量,重新锚定后**同时**查
  调用和被调用者里的写入——只看调用的话,一个什么都不清的 `dropOverviewSelection()` 能通过。
- `mutate-cross-layer.py` 的元组顺序是 `(title, old, new, expect)`,我先按四元组反序写,
  结果拿期望名去 index.html 里找,0 命中。已改。

## 顺带:R5 从 5 个写点降到 4

`dropOverviewSelection()` 现在服务两个调用方,所以 `state.overviewSelection` 的写点由 5
降到 4(`dropOverviewSelection` / `setActualDuration` / 两个 pointer 处理器)。**R5 仍未
闭合**,LAYERS.md 明确记「离 R5 还差三个」——后三个语义各异(模式切换 / 拖拽中 / 落点),
不是同一事件的三种写法,不该塞进同一个 setter。这里只记进展,不记收敛。

## 验证

fork 内 47/47(`node tests/run.mjs` 与 `--real`)、`make-fixtures --check` 未漂移。
index.html 332,695 字节纯 LF,LAYERS.md 137,990 字节纯 LF,已发布且
served == disk == 332695,探针 pass。

LAYERS.md:第 9 项关闭(含 `state.base` 为什么仍不做的理由——收字段唯一的实际收益是
「默认值只有一份」,而这一份现在有了,剩下的只有命名空间,不足以支付 35 个跨层读写点加
一次 L3 形状变更);D5 写者表重数;Stage 2 的 2-c 拆成两半重写;Stage 3 的 R5 段落更新;
未做清单第 4、5 行同步。
chli 报的是缺一个能力,不是缺陷。检视是「点某条消息看详情」的结果面板,
宽屏下它在右列常驻,≤767px 塌成单列后它排在账本后面——读者要点完才看得见
自己点的内容,而看到它要滚到文档最末尾。

做法:position: sticky; bottom: 0 + 两个状态类,三态 flow / docked(50vh) /
full(JS 实测高度),把手复用既有 .section-toggle 的视觉与 aria 词表。
不新增任何 DOM 节点、容器或 sticky 层:sticky 在自己的包含块内吸附,
main.columns 高 936,000px,天然行程充足,不需要 travel padding。
口径零改动。

实测 420×844 + headless Edge CDP,点「显示全部」使文档达 937,954px:
10% / 50% / 90% 三站均 docked 且 bottom == innerHeight == 844 逐值成立,
按把手 full(447 > 422)、再按回 docked、最底部回 flow 且把手隐、footer 在屏内。
取样按文档比例走而不是按面板位置走——面板被钉住时 rect.top + scrollY 恒等于
scrollY + 422,用它算目标会把每一站都送到文档顶部去。

三条走错了的修法都留在代码注释里防复犯:
  1. rect.bottom >= innerHeight 分不出「已滚过」和「还没滚到」,改成带容差的相等。
  2. 加 main.columns 底边作第二参照——面板是它的子节点,钉住面板就钉住容器,
     合取式在每个状态下都为假,面板一次都不吸底。
  3. offsetTop——账本虚拟滚动,实测 926,767 对真实位置约 934,100,差 7,300px。

守卫 verify-inspector-dock.mjs 37 条 + mutate-inspector-dock.py 34 条变异,
34/34 全捕 0 存活。其中三条变异专门挑「看起来像工作代码」的改法。

顺带收窄三条既有不变量(收窄时逐条复查了检测力):
  - verify-generation-pin:原「不注册任何 scroll 监听」拆成「没有 scroll handler
    写 body 的 class」+「每个 scroll handler 名字必须存在」。D19 那条变异只加
    调用不加函数,只有第二条抓得到。
  - verify-sticky:resize 断言从取第一个匹配改成通过注册解析 handler,否则检视
    的第二个 resize 监听会把删掉 sticky 那个的变异顶替掉。
  - client-check:no fixed h-screen 改成扫 codeOnly,原文扫描会让人正确解释
    「为什么不用 100vh」时反而失败。

已知未解决:面板自然高 445px 对半高 422px,full 与 docked 只差 25px。三个态
确实不同(docked 截掉一截内容并出现滚动条),但短会话上视觉差别很小。50vh
是按「半高」字面选的,可改。

LAYERS.md D22 记录四问、实测表、三条错修法与收窄不变量表。
chli 的反应:「我没看到检视面板,你说的半高差别很小,从何谈起。」
两个问题同一个根:我只在 420px 上量过,而真实窗口不在那一档。

一、断点方向错了,功能整体不生效
D22 把吸底只做在 @media (max-width: 767px),而 .columns 塌成单列是在 1080px。
于是 768–1080px 这一档:面板被埋在账本下面(单列了),又没有吸底补偿
(断点更窄)——致病因素和补救措施同时缺席,比任何单一状态都差,读者看到
的就是「这个功能没有」。而这一档恰好就是 in-app 浏览器实际所在宽度。

实测证据:#inspector-toggle 存在但带 hidden="",aria-label="完全展开检视",
即 toggle.hidden = !narrow || !docked 里的 narrow 为假,整个三态逻辑一次都没进。

修法:三条 CSS 规则搬出 767px 块,进入属于它自己的 @media (max-width: 1080px);
INSPECTOR_DOCK_QUERY 同步改成 1080px。

守卫那条不变量本身是反的:原来只断言 dockCondition <= 单列断点,正好放过了
这次 shipped 的方向。改成两条分开断言——覆盖整个被埋没区间(>=)、不在并排
时吸底(<=)——两条一起才是「吸底断点必须等于单列断点」。另加一条断言 CSS 与
脚本里的宽度必须一致,因为守卫是从样式表读断点的,只改脚本那份对其它规则不可见。

二、「半高 vs 完全展开」的差别取决于内容高度,与视口宽度无关
max-height 是上限,不是高度。上限高于内容时两态逐像素相同:
  420x844  内容 445px  上限 422px  docked 422  full 447(差 25px)
  900x1000 内容 384px  上限 500px  docked 386  full 386(差 0)
上一轮拿「差 25px」当结论,却没说那是内容恰好比上限高 23px 的巧合。
这条改数字掩盖不了,是设计问题,留给 chli 定。

一条方法论,与 D20/D21/D22 同族但更该早做:D22 的全部实测跑在一个固定宽度上,
而功能的作用域是一个 media query。「在某个宽度成立」不等于「在它声明的宽度区间
内成立」——探针把宽度写死成 420,恰好落在区间内,于是 39 条断言和 36 条变异全绿,
而功能在真实窗口里从未触发过。探针的宽度已改成参数。文档 D23 记录全过程。

守卫 37 -> 39 条断言,变异 34 -> 36 条,36/36 全捕 0 存活。
顺带按 verify-comment-shape 的既有约定把新 CSS 注释的 */ 收到正文行尾
(该守卫只把整行等于 /** 和整行等于 */ 计入,单独一行 */ 会造成闭标记多于开标记)。
chli 直接指定:「这个检视是半高么?建议中间状态是整个窗口的1/4,然后支持拖放,
resize。然后没有选中的时候,应该是不可见的,只有选中记录,才默认1/4显示。」

先答他的第一个问题:不是半高。50vh 是上限,而 900×1000 那一档内容只有 384px、
上限 500px——上限比内容还高,所以「半高」等于内容原高。他看到的和实测是同一件事。

四问
1 归层:L6 检视层呈现规则。新增一个字段 inspectorDockH(拖出来的 px,null=没拖过)。
  可见性不新增字段——判据是既有的 state.selectedId,面板可见 ⟺ 有选中。这是最要紧的
  一条:可见性由既有字段推导,不引入第二个真相源。
2 关联:拖拽 grip 与面板头的 .section-toggle 并列不互斥,一个改高度一个改档位,都只在
  docked 存在。取舍写明:full 时拖动即落回 docked,因为「完全展开」的定义是「没有上限」,
  而读者刚选的高度正是一个上限。与概览刷选拖拽互不干扰。
3 布局:新增 1 个 DOM 节点(面板上边缘的 grip)。这一条推翻了上一轮我自己设的
  「不新增任何 DOM 节点」——那是上一条功能的约束,拖拽必须有握柄,是 chli 本轮要求的。
  不新增容器和 sticky 区域。
4 口径:--inspector-dock-h 从 :root 常量(50vh)变成 JS 实测写入的 px,默认
  window.innerHeight/4,被拖过则用拖出来的值,两端都夹(>=160,不高于钉住带留下的
  available)。读点仍只有一个选择器。空态代码保留不删,只是不可见时读者看不到。

三处会坏的地方(都写进注释了)
- touch-action: none 不是装饰:没有它,触屏上从 grip 起手的纵向拖动会被当成页面滚动,
  窄屏布局真正是手机的那些设备上这个功能等于没有。
- 拖拽用 clientY 不是 pageY:面板是吸底的,页面坐标会把滚动量算进高度。
- pointercancel 必须一起注册:拖拽以任何其它方式结束都会让拖拽对象活着,之后每个
  pointermove 都在改一个没人碰的面板。

守卫 39 -> 61 条断言,变异 36 -> 49 条,49/49 全捕 0 存活。

两处「写点闭集」的诚实记录
- dock 态写点从 3 涨到 5(失去选中落回 flow、拖拽落回 docked)。守卫没有只改数字了事,
  它把五个写者逐个点名、各自允许写哪个字面量,因为「数目对」和「位置对」是两回事。
- 两条变异存活,都是守卫断言太宽,补的是断言不是变异:①「任何指针都能驱动拖拽」删掉
  move 里的 pointerId 检查后仍绿,因为守卫搜整个文件而同一行在 up 里还有一份,改成只在
  move 函数体内搜;②「展开高度不再实测」把 available 算式换成常量后仍绿,因为守卫只查四个
  名字是否出现而它们确实还在,改成断言那个算式本身。

一条贯穿本轮的方法论:D22 的实测跑在一个写死的宽度上,D24 的守卫把「名字出现」当成
「被使用」——同一类错误的两面。凡是断言一个标识符是否被使用的规则,都该断言它参与的那段
算式,而不是它的存在。LAYERS.md D24 记录全过程。
833b55a captured a mutated index.html: line 2605 read record.kind !== 'reply'
instead of === 'reply', which is the first mutation mutate-anomaly.py applies.

How it got in: the fork suite ran mutate-anomaly.py, that script left the file
mutated instead of restored, and the commit was made straight afterwards. git
status was clean afterwards, so nothing in the normal workflow signalled it --
the diff between the commit and HEAD~1 was the only evidence.

Restored from the workspace copy, which never held the mutation. Verified:
  - byte-identical to the workspace tree
  - verify-anomaly.mjs 269 assertions, 70 anomalous of 88, OK
  - mutate-anomaly.py 75 mutations, each caught, 'index.html restored
    byte-for-byte'

Why the earlier run failed to restore is NOT yet established. It is a defect in
the mutation harness, not in this feature, and it is not fixed by this commit.
chli 直接指定:「然后全高,把轮次的表格给覆盖了,建议表格至少留整个页面高度的1/4。
只有正常滚动到最底部,才完全显示检视。当前点展开全部,检视也没有完全显示,也有滚动条。」

三条都复现于他眼前那个窗口(900 CSS px 宽),修前:
  展开面板高 865px → 展开时可见账本行仅 51px(窗口的 1/4 是 250px,账本一行都不剩)
修后:
  展开面板高 615px → 可见行 301px(≥250)
  1/4 吸底 250px = 窗口的 1/4,可见行 666px
  文档最底部 flow 态无上限 1345px,footer 在屏内 924px

改的是一处算式,不动 DOM 不动 CSS:
  panelMax = max(160, innerHeight - band - overview - ledgerReserve)
  ledgerReserve = max(140, innerHeight * 0.25)

一条规则两个用处:「表格至少留 1/4」同时约束完全展开的上限和拖拽的上限。
只约束一个等于留了另一扇门进同一个缺陷——读者把上边缘拉上去,行照样消失,
而且那时没有任何状态发生变化。变量同时改名 available -> panelMax,因为旧名字在
新语义下会说谎。

探针在这一轮暴露了它自己的两个错,两个都不是页面造成的:
  1 它在量一个隐藏的面板。D24 加了「无选中即隐藏」而探针从不选记录,于是七步全部
    读到 rect=0、mode=flow,并自信地报告「没有吸底」。探针报一个它从未进入的状态,
    比探针失败更糟。已补:先点内容最长的一行再量,并加一条「选中后面板只有 N px 高,
    它多半还藏着」。
  2 它从来没滚到底。scrollTo(scrollHeight) 只滚一次,而虚拟账本让文档在滚动之后继续
    变长——实测停在 y=960,517 而 maxScroll=961,613,差 1096px。于是「面板和 footer
    之间有 1020px 空档」这个读数是假的,footer 就在下面 1096px 处。已改:反复滚到
    maxScroll 直到 scrollY 和 docH 都不再变,并连采两次,第一次才是要记的那个数。

守卫 61 -> 65 条断言,变异 49 -> 52 条,52/52 全捕 0 存活。三条新增变异分别破坏:
预留份额被减掉、份额从 1/4 改成 1/10、拖拽上限不再跟随展开上限。

口径变更与检测力复查:--inspector-full-h 的含义从「钉住带以下剩下的全部」变成
「剩下的全部减去表格那一份」,断言表达式跟着改。只改名字或只改常量是蒙混,所以断言
仍然钉在算式本身,并新增一条断言要求 ledgerReserve 确实参与了这个减法。

静态守卫读的是算式,算式对了不等于像素对了,所以另在探针里量像素:展开时可见行高度
≥ 窗口的 1/4。与 D21「断言属性存在不等于断言它起作用」同族。

LAYERS.md D25 记录全过程。
chli 直接问:「窗口的1/4 和 检视的完整高度,取最大还是最小,你来评估。如果窗口
高度的1/4 远大于检视的高度,应该以检视高度为准,也就是说检视的高度是兜底值。」

答案:取最小,而且渲染出来的已经是 min。但这一支在本页不会触发,所以要守的是机制
不是结果。

实测(900x1000,同一页取两端):
  最短的一条记录(第 1 轮 #487 系统)   内容 752px,1/4=250,吸底 250,展开 615
  最长的一条记录(第 90 轮 #11555)    内容 1345px,1/4=250,吸底 250,展开 615

「窗口 1/4 远大于检视高度」在本页不存在:最短的内容也有 752px,而 1/4 只有 250px。
原因是摘要标签页永远要渲染那张十几行的元信息表,内容高度有一个约 750px 的地板,与
记录本身长短无关。

渲染已经是 min 的原因:两个吸底态用的是 max-height 而不是 height。上限渲染出来的是
min(内容, 上限)——内容短于 1/4 就按内容高度显示,长于 1/4 就按 1/4 并内部滚动。
这正是「以检视高度为准」。

所以改的是守卫不是行为。风险在于它是个副作用:哪天有人把 max-height 写成 height,
短面板里会出现一截空白,而 height 在 code review 里看起来完全合理、其余断言一条都
不会响——因为这一支今天无法用测量保护(最短内容 752px > 250px,测不到)。

三条断言守机制:
  - 两个吸底态规则都用 max-height
  - 任何规则都不给面板设 height 或 min-height(min-height 会把短面板撑出来,症状一样)
  - 基础规则 #inspector-panel 刻意不在第一组里——无状态类时面板在文档流里本就没有
    上限,而 chli 要求「只有滚到最底部才完全显示」,给基础规则加上限恰好把这条收回去

配两条变异:max-height 改成 height、给上限加一个 min-height。两条都必须被抓,
且报告的名字要各自那一条。

探针自己错了一次:新增的 short/long 探针第一版,第二档继承了第一档留下的 full 状态,
把一个展开态面板读成「docked」并报了两条不存在的缺陷。已改:每档开始前先把状态复位
到 docked 再量。与 D25 那两条同族——探针报一个它没有亲手放进去的状态,比探针失败更
糟,而且方向相反时它同样会报。

守卫 65 -> 68 条断言,变异 52 -> 54 条,54/54 全捕 0 存活。

一条方法论,与 D21/D24/D25 同族:D21 是「断言属性存在不等于它起作用」,D24 是「名字
出现不等于名字被用」,D25 是「探针的读数是它自己造成的」,D26 是「行为正确不等于机制
正确」——今天测不出差别的那一支,恰恰是最容易被下一次改动毁掉的那一支,因为它没有
任何测量在看着。测不到的不变量要守机制,别守着那个测不到的数字。
chli 的原话:「检视的中间高度改为2/5,刚才说的支持resize,没做。」
两句话,第一句是改数值,第二句是报缺陷。两件都成立。

────────────────────────────────────────────────────────────
D27 —— resize 写了,但读者一次也够不着

D24 写了完整的 resize:grip 节点、四条 pointer 监听、拖拽算式、上下夹取、
touch-action。全部正确。但抓手一次都没有出现在页面上。

实测(900×1000,mode=docked,--inspector-dock-h: 250px):
    grip: hidden=false  display=none  rect={x:0,y:0,w:0,h:0}
    elementFromPoint(grip centre) = null
    DRAG: not attempted (grip had no box)

hidden 属性是**对的**(false),applyInspectorDock() 判「已吸底」也**是**对的。
但 `.inspector-resize` 的基础规则无条件写着 display: none,而全文件**没有任何
一条规则把它设回可见** —— display 压过 hidden 属性,盒子 0×0,不在命中树里,
elementFromPoint 返回 null。四条 pointer 监听挂在一个永远收不到事件的元素上。

为什么 68 条断言和 54 条变异全绿:
每一条都在问「这个属性在不在这条规则里」,没有一条在问「有没有东西能推翻那条
让它一直不可见的规则」。top/cursor/touch-action/pointercancel 全部在场且正确,
而元素完全不可见。

> D27 是 D21 的升级版。D21 是「断言属性存在 ≠ 它起作用」;D27 是**「一条规则的
> 每个属性都对,而整条规则从没生效过」**。display:none 不是 bug 的一行,它是
> **一个没有被任何东西推翻的默认值**。

修法(零 DOM):基础规则保持 display:none(关是 CSS 的默认状态,不依赖 JS 跑过),
新增一条以 :not([hidden]) 为条件的配套规则。可见性的唯一开关仍是 JS 写的那一个
hidden,CSS 第一次把这个开关接上。

三条新断言:
  - 抓手默认关,且是 CSS 关的,不等脚本
  - 存在一条规则能把它打开 —— **这一条才是能抓住 D27 的那条**
  - 能落到抓手上的 display 规则恰好两条(默认 none + :not([hidden]) → block),
    第三条就是 D27 当初进来的形状,会让它再次消失
另加一条:抓手必须由「移除那个隐藏它的属性」来点亮(脚本只写一次 hidden,
样式表回来的路挂在同一个属性上)。最后这条挡住的是同一个缺陷换顶帽子:
配套规则还在,但条件换成一个谁都不写的 class。

两条变异:删掉配套规则;把 :not([hidden]) 换成 .is-open。都被抓,且报告各自的
名字。第二条一开始**存活**了 —— 因为那条断言只查了脚本侧,没查样式表那一半。
名字承诺了它没检查的东西,那是守卫的缺陷,补断言,不删变异。

────────────────────────────────────────────────────────────
D28 —— 中间高度 1/4 → 2/5

四问(已先写进 LAYERS.md):
  归层  同既有 dock 表现同层,不新增层、不新增 state 字段,写者仍是
        applyInspectorDock(),唯一出口仍是 renderInspector()
  关联  2/5 并列于拖拽夹取(同喂一个 --inspector-dock-h),从属于 panelMax;
        **不碰 INSPECTOR_LEDGER_SHARE**
  布局  零 DOM、零 CSS 规则改动,一个常量
  口径  读点逐个点名,见下

读点(每一个都改了或有明确理由不动):
  index.html :root 的 25vh fallback        → 40vh,并加断言把它和常量绑在一起
  applyInspectorDock() 的 innerHeight / 4  → 走常量
  inspectorDockHeightNow() 的 / 4          → 走同一常量
  verify 的「a quarter of the measured window」 → 改名并改判据
  mutate 的三条旧锚点(25vh×2、/4×1)    → 改锚点,不删
  .probe/inspector-dock.mjs 的 innerFourth → **不动**,见下
  .probe/inspector-short.mjs 的 quarter    → 改,且变量改名 cap(它比的是面板高度)
  LAYERS.md 的 D22/D24/D25/D26 小节        → 历史记录,不回改
  INSPECTOR_LEDGER_SHARE = 0.25 及其守卫   → **不是读点**。那是 D25「表格至少留
                                            1/4」,另一个量。动它会顺手挪走账本预留

顺带收掉一个重复写者:改之前 innerHeight / 4 有**两处**各自算了一遍(面板被给的
高度,和拖拽起始的高度)。两个写者算同一个数,漏改一处,拖拽就会从一个读者从没
见过的高度起手。收法是 INSPECTOR_DOCK_SHARE + inspectorDefaultDockHeight(),
两个调用点都走它 —— 一个常量、一处算式、两个读者。

数值校验(900×1000):ledgerReserve 250(D25 的量,不变)、band+overview 135、
panelMax 615、INSPECTOR_MIN_DOCK_H 160,新默认 2/5 = 400 落在 [160, 615] 内。
完全展开仍 615,可见行 1000-135-615 = 250 ≥ 250,「表格至少留 1/4」没被动到。

────────────────────────────────────────────────────────────
实测(900×1000,发布后重跑探针)

  吸底态    panelH=400  dockVar=400px            ← 2/5
  抓手      display=block  rect={x:25,y:594,w:850,h:14}
            elementFromPoint(抓手中心) = inspector-resize   ← 进命中树了
  真实拖拽  按下 (450,601) 上拖 120px → panelH 400 → 520,dockVar 520px
  展开态    panelH=615,可见行 301 ≥ 250
  文档底部  panelH=1345 无上限,footer 在屏内 925px

────────────────────────────────────────────────────────────
探针自己错了一次(D29,与 D26 同族)

第一次跑 dock 探针报 pass=false:「footer 在 1869,屏幕外,面板下方 1000px 空白」。
差点当成 D28 的回归去改产品代码。

查证:那个采样时 scrollY 比 maxScroll 少 945px —— 页面根本没滚到底。单独写了个
只测这一件事的探针,连续按到底:
    pass 1: short=945  footerTop=1870  gap=870  footerVisible=false
    pass 2: short=0    footerTop=925   gap=16   footerVisible=true
footer 够得着,**不是产品缺陷**。

成因在探针的沉降循环:它停在「scrollY 不再上涨且 docH 不再变化」。虚拟化账本
会让 docH 在沉降途中塌陷,塌陷那一下满足了条件,而页面还差 945px。已改成停在
「真的到了」(short <= 1),并且不再单凭「没动过」就退出。

> 与 D26 那一课是同一条:**探针报一个它自己造成的状态,比探针失败更糟**,而且
> 方向相反时它同样会报。判据「footer 在不在屏内」是对的,错的是它采样那一刻
> 页面还没滚到底。

────────────────────────────────────────────────────────────
守卫

verify-inspector-dock.mjs  68 → 75 条断言
mutate-inspector-dock.py   54 → 59 条变异,**59/59 全捕 0 存活**
client 逐字节还原,还原后复跑仍绿
reviewer: MyPrototypeWhat,2026-10-12,commit 2f6a6ba。
**全部 8 条零回复**,本轮逐条查证:4 条必改全部真实存在,4 条建议里 3 条属实可做、
1 条属设计变更不做(理由见下)。每条都加了守卫和变异,不是只改文字。

────────────────────────────────────────────────────────────
必改 1 — tests/run.mjs 写死 `python`

属实。`tests/run.mjs:42` 写死 `['python', join(HERE, name)]`。

reviewer 描述的「看不出原因」是第二个独立的缺陷,比写死更值得记:一个**从未启动**的
进程既没有 stdout 也没有 stderr,所以 `tail` 取到空串,34 条守卫里 15 条全红而输出
里找不到任何理由。修了解释器(`PYTHON` → `python3` → `python`,探测一次全程复用,
命中即 `break`),并把 `r.error` 印出来、把「完全找不到解释器」写成一条明确的失败
而不是静默跳过。

reviewer 提的 `r.error.message` 打印已做,另外补了一条:**同一轮只能有一个解释器**
(探测循环命中即 break,否则两台解释器都有的机器上会由「最后一个能用的」静默接管)。

────────────────────────────────────────────────────────────
必改 2 — README 第 27/29 行与代码不符

属实,而且比 reviewer 说的更具体一点:`tests/client-check.mjs` 里早就有一条断言写着
「`state.kinds` is written from **exactly four controls**」—— 测试早就知道,文档没跟上。

代码事实:`index.html:7549`,勾选任一异常规则时 `if (next.length > 0) resetBaseFilters();`
而 `resetBaseFilters()` 会把 `state.kinds` 重置为全选、清搜索、清时间区间、丢掉总览刷选。
所以 README 的两句话都是假的:「written from exactly three controls」(实为四个)和
「Neither rewrites the kind array」(异常筛选恰恰会改写)。

中英文各改一处,都改成陈述真实代价而不是删掉这句话:异常筛选是**唯一一个会改写类型
数组的横跨控件,而且是故意的**(否则读者勾了异常规则、取消了问答、看到空白,却不知
道该回去改五个控件里的哪一个)。并点明「类型选项只回答一个问题」这句话只在异常模式
之外成立 —— 而那也正是类型选项唯一还在起作用的时候。

────────────────────────────────────────────────────────────
必改 3 — 插件目录里的文档清理

属实。分三件事:

**3a 删 `docs/STAGE1-SPEC.md`。** 它标注了不采纳、18 KB,里面留着当初跑交叉核对的
本机命令与路径(`codex exec …`、`~/.codex/models_cache.json`、`gpt-5.6-sol` 等模型名),
会随插件目录装到每个读者的机器上。**reviewer 说「没有地方引用」这点不准**:LAYERS.md
里有两处引用,所以是连引用一起改的,不是只删文件 —— 删完不留悬空指针。否决理由留在
LAYERS 5.1,不用第二份文件再存一遍。

**3b LAYERS.md 的流程状态行。** 未做清单第 8 项记的是「PR MiniMax-AI#16 reviews=0」和一份已进
回收站的临时文件 —— 两条都只在写下来的那天为真,且谁也靠它做不了事。已删(保留一行
说明为什么删)。

第 7 项「跨家族验证只有 codex 一条腿」**保留** —— 它是真待办;只把「需 chli 交互式
处理」这种把读者当行动人的写法改成中性描述。

**3c 归属不能一起洗掉。** 顺手给这一点加了守卫:LAYERS.md 把决定归属给出决定的人,
这是全文最吃重的部分 —— 没有它,读者分不清「已定的结论」和「某人当时的偏好」。
清理只针对进程快照,不针对 provenance。

────────────────────────────────────────────────────────────
建议 1(采纳)— dispose() 没清 parsedRows

属实。`server.mjs` 的 `dispose()` 清了 `descriptorCache` 和 `trajectoryCache`,独独漏了
`parsedRows` —— 三个里最大的那个,装着一整个会话的已解析行。它有现成的 `clear()`
(`createParsedRowsCache()` 返回值上就有),只是没人调。补一行。

────────────────────────────────────────────────────────────
建议 2(采纳)— 异常模式下空状态指向失效控件

属实。空状态固定一句「可以放宽类型或搜索词」,而异常规则勾上时 syncAnomalyMode() 正
是把类型轴和搜索框标成 inert 的(正因为勾规则时已经重置过它们)。所以这句话把读者
送到两个按了没反应的控件上。改成按模式分支:异常模式下点名「清除筛选」—— 那是此
状态下唯一还能按的控件,而且按钮本来就在。非异常分支保持原句。

────────────────────────────────────────────────────────────
建议 3(采纳)— 版本号

`1.0.0` → `1.1.0`。

────────────────────────────────────────────────────────────
建议 4(本轮不做,说明理由)— 建行期间固定 min-height

reviewer 实测到建行途中账本塌缩(114710px → 19786px),这几秒里视口显示的是别处的
内容,此时点击会命中别的行。这个观察我认同,但**这一轮不做**,理由不是工作量:

- 它是 L5 的**行为**变更,不是缺陷修复,而 LAYERS 第 4 节把「归层 / 关联 / 布局 /
  口径」四问定成硬顺序,答案没写下来之前不许写实现代码。
- 它直接压在 D14 刚收尾的那条不变量上:`clear(ledger)` 前固定高度,就是在
  `restoreRowAnchor()` 的转交链中间插入一个「文档高度不再等于读者以为的那个」。
  在窄屏钉住 + 虚拟化 + 轮询建行三者同时发生时,那条链的正确性目前只有
  `verify-anchor-carry.mjs` 的 10 条断言守着。

所以它留在未做清单里而不是消失。下一轮做它的时候,四问的答案会先落到 LAYERS.md。

────────────────────────────────────────────────────────────
守卫

新增 `tests/verify-review16.mjs` **34 条断言** + `tests/mutate-review16.py` **25 条变异,
25/25 全捕 0 存活**,被改的 7 个文件全部逐字节还原,还原后复跑仍绿。

守的七组:
  1. 解释器可被发现、且「找不到」是一句说明而不是一行空白
  2. README 中英文不得回到「三个控件 / 不改写类型数组」,且要与代码实际用的
     `resetBaseFilters()` 对得上
  3. 本机环境(`codex exec` / `~/.codex` / 用户目录)不得出现在任何随插件分发的文件里;
     已删除的 spec 不得复活、不得留悬空引用
  4. LAYERS.md 不留进程快照,但**必须**留住决策归属(按基线 19 个归属标记设下限 16,
     变异洗掉最大的一类 6 个后剩 13,必须报出来)
  5. dispose() 三个缓存全清,且 `createParsedRowsCache()` 确实提供 `clear()`
  6. 空状态有异常分支,且该分支点名「清除筛选」
  7. 版本号不再是 1.0.0

> 两条变异一开始**存活**了,都是守卫太弱而不是变异太轻:`state.anomaly.rules.length > 0`
> 在别处也出现,整文件搜索被无关代码满足了;「归属还在」只要求存在一条,洗掉一条不
> 算失败。补法分别是**把搜索范围收进空状态那一块**、**把归属断言从「存在」改成计数下限**,
> 而不是删变异。另有一条锚点在 server.mjs 上用了 `\n`,而那个文件是纯 CRLF —— 与它在
> 客户端上犯过的同一个坑,锚点已按 `\r\n` 重新锚定。

`tests/run.mjs` 49 → **51** 条守卫(新增 verify-review16.mjs 与 mutate-review16.py)。
fork 内 `node tests\run.mjs` 与 `--real` 均 51/51,`npm run validate` 为 `repository: OK`。
`server.mjs` 全程 CRLF,`index.html` 全程 LF。
`--check` 从来不是一个选项,`mutate-anomaly.py` 的「已还原」以自己为基准,
而第一版 `--check` 守卫被一条变异打穿还全绿。三处同一个形态:一个检查读的是
自己刚刚写下的状态,于是它永远和自己一致。详见 docs/LAYERS.md D30。

tests/make-fixtures.mjs
  --check 变成真的:判已提交的 fixture,不读活存储,因此没有 ~/.minimax 的 CI 上也能跑。
  定案是「比形状,不比字节」—— 承诺从来不是「与本机今天蒸馏出的结果逐字节相同」,
  活存储一直在动,逐字节比在本机会永久红。真正承诺过的四条:按服务的读法解析、
  覆盖十种 shape、不含本机信息、再蒸馏一次读出同一个结果。第四条抓的是空字符串
  负载经 synth 变成占位文本后不再是空的,silent 规则随之悄悄失效。
  新增 --root=<dir> 和 --emit-demo-tree=<case>,让守卫能驱动真正的 CLI 分支。
  --record 与 --check 同时给出时拒绝运行:写手不能给自己打分。

tests/verify-fixture.mjs(新,18 条断言)
  一半证明这个门能红(六个坏树各按自己的原因被拒),一半证明已提交的 fixture 是绿的。
  后一半是关键:第一版只做了前半,变异把打印结论的分支废掉之后守卫全绿 ——
  自检是在进程内调函数的,函数判得完全正确,被废掉的只是「把判定变成退出码」。
  一个性质可以算得完全正确,同时对别人真正会走的每一条路径都不可见。

tests/mutate-fixture.py(新,10 条变异,10/10 全捕 0 存活)
  变异 A 就是上面那条:if (r.problems.length) → if (false && ...)。第一版守卫在这条上
  存活,补守卫而不是删变异。shapeOf() 的结构检查刻意不在表里,理由写在文件里:
  它比对的每个字段都是那十个谓词也读的字段,因此任何改变结构的再合成必然同时翻一个
  flag,无法隔离 —— 宁可写明,也不编一条假的变异。

.gitattributes(新,放插件目录不放仓库根)
  * text=auto eol=lf + *.png binary。所有 blob 本来就是 LF,零 blob 变动,只是工作区
  不再与提交不一致。server.mjs 顺势从 CRLF 归一回 LF,两棵树同步转换,blob 哈希不变。
  D18「不要对 index.html 用 git stash / git checkout」那条禁令据此解除。

tests/mutate-host-only.py
  上面那条属性立刻暴露了它:L = "\r\n" 硬编码,四个锚点全部停止命中。其余 harness
  早就在从文件自身读行尾,它唯一的例外。已统一为:任何锚点都不含换行,必须插入时
  的那一个换行从待编辑文件里量出来。

tests/mutate-anomaly.py
  「已还原逐字节」这句以它自己读进来的字节为基准,所以起点脏时它照说不误。实测证明:
  注入一条注释后运行,它打印「restored byte-for-byte」、退出 0、报 75/75 全捕,而文件
  仍是脏的。范围界定准确:会打破守卫的泄漏基线本来就抓得住,缺的是这句在「起点脏但不
  打破任何守卫」时为真。改为扫完再跑一次基线探针并要求仍全绿,由一个没参与过变异的
  过程判定,而不是由写盘的那一方宣布。最初那次泄漏如何触发仍然未知,不编解释。

tests/mutate-review16.py
  两条 server.mjs 锚点改为不含换行符。原锚点之所以能命中,纯粹是 core.autocrlf 重写了
  checkout;一旦 .gitattributes 钉住 LF 就会静默失效。

docs/LAYERS.md
  新增 D30(五节:还原检查以自己为基准 / --check 是空的 / 函数对了分支瞎了 / .gitattributes
  与被行尾暴露的老 bug / codex 第二轮评审是环境阻塞不是结论)。同时在 D18 那条禁令上加
  补注,写明它已解除以及为什么。AGENTS.md、tests/METHODOLOGY.md、tests/README.md 同步
  行尾口径与 --check 的真实含义。

codex 第二轮评审回读:环境阻塞,不是结论。只读沙箱内 CreateProcess 一律被拒,PowerShell
与 cmd 都在执行前失败,它连一个文件都没读到。与已删除的 STAGE1-SPEC.md L216 记录的是
同一个 Windows 沙箱错误,第二次复现。不得当作「评审通过」或「评审有意见」。

守卫 51 → 53。

.gitignore(新)
  __pycache__/ 与 *.pyc。tests/ 里有 25+ 个 .py,每跑一次 tests/run.mjs 都会在包目录里落一个 __pycache__。它未被跟踪,也不能被跟踪:机器相关、随 Python 版本变形、而插件目录就是要发的包。本机工作区里已有一个遗留的 cpython-314.pyc,已删。仓库根的
  .gitignore 没有覆盖它。

两树 fixture 漂移一并结清(旁项,不在本轮改动范围内)
  两棵树里 8 个 fixture 文件有 6 个内容不同(早先就存在,被空的 --check 掩盖到今天)。先确认哪一侧是权威:fork 这份与 HEAD 逐个 blob 对得上(0/8 不同),即它就是提交版;workspace 那份是某次未提交的 --record 产物。以提交版为准刷回 workspace。
  现在两棵树逐字节 0 差异。

一条现场观察,已写进 tests/METHODOLOGY.md
  不要在同一棵工作树上并行跑两次套份。变异 harness 是原地写盘的,两个套份同跑会互相把对方的中间态当成自己的基线。我实测过:run.mjs 与 --real 并行时 mutate-anomaly.py 报“第一条变异失败”、mutate-compaction-turn.py 报“得到 1,期望 2”,
  两条都是互踩。停掉后 index.html 确实停在中间态(与 workspace 副本 SHA256 不同),恢复后与 HEAD 对齐。与本轮主题同源:harness 写完之后不能只相对自己写下的内容判断。

验证:node tests\run.mjs 53/53,--real 53/53(两次均串行),npm run validate 为 repository: OK。server.mjs 的 LF 归一是零内容改动:git diff 对它无输出,blob 哈希不变,因此它不在本次提交里。
前一个提交把 --check 从空操作做成真检查。这一条记它被外部评审打回来之后的事:三个洞,
一个我自己下错的结论,一个「反例做得更有说服力反而更糟」的坑。

checkFixture 之前只比形状和泄露。现在它必须和它所代表的服务说同一件事:

- 会话发现改用 collectSessionEntries()。原来递归找任何叫 messages.jsonl 的文件并按它
  计数,于是把 demo 树的 v2 改名成 unused,它照样报 6 个会话、退出 0,而服务按自己的
  规则能找到 0 个。数的东西必须和消费者数的东西同一个。
- 加 readSessionIdentity() 校验 sessionId 必须 mvs_<32 hex>,即 verify-parsers.mjs:89
  断言的那一条。原来 manifest 只扫泄露,{"sessionId":"broken"} 也过。
- 加 toolCall / toolResult 配对,按整语料两遍统计。原来完全不看,于是把结果侧的 id 改掉
  之后语料里没有任何配对结果,而 verify-parsers.mjs 的配对断言会因此因为错误的原因通过。
  和 D30 那条空字符串 silent 是同一种病。

配对我第一版写成单遍扫,结果已提交的 fixture 报了 21 条未配对:蒸馏器按 shape 首见加步长
取行,配对的 toolCall 可能排在结果之后。我还顺手加了一条 durationMs 断言,21 条又红了 ——
查数据才发现 durationMs 根本不是原始消息的字段,是服务产出时才有的。把服务输出的不变量
搬到原始载荷上断言是越界,删掉了。

tests/verify-fixture.mjs 18 → 21 条断言。两条旧断言被外部评审指出是越界的:

- 「--check reports coverage for all ten shapes」匹配的是脚本打印的 NEEDED_NAMES 常量,
  删掉覆盖强制它照样通过。删掉,真实主张交给 hole 那条 CLI 断言。
- 「--check counted the sessions without reaching for the live store」只是数了个输出里的
  数字,任何一次运行都会打印一个数字。改成真断言:把 HOME / USERPROFILE 指到一个空的临时
  目录再跑一次,~/.minimax 不存在,它仍然通过 —— 那才是「不依赖活存储」。

tests/mutate-fixture.py 10 → 14 条变异,14/14 全捕 0 存活。补进去的四条:

- synth 颠倒 content 数组顺序。这条对应我此前声称「无法被独立观测因而刻意不配变异」的
  结构检查。外部评审给了一个我没试过的反例:调换一个 assistant 的 thinking / text 两个
  part 的顺序,shapeOf().parts 变了,而那十个谓词全部用 parts.includes(),一个都不翻。
  实测:--check 报 134 条 is not shape-stable、零条 coverage 翻转。完全可隔离,我错了。
- 配对不致命 / sessionId 不致命 / 会话发现退回朴素递归,各一条。

**一个更深的反例会同时骗过变异和断言。** 给「会话发现」做 demo 时我第一版把会话目录放到四层
中间层之下,想的是更深更有说服力。结果「退回朴素递归」那条变异存活了:服务自己有
MAX_WALK_DEPTH = 4,四层之下朴素递归也被挡住,于是变异是因为第二个无关的原因失败的,而它
报的正是同一条消息,断言照样通过。降到一层中间目录之后,递归找得到、服务找不到,变异立刻
被干净捕获。先证明反例能穿过守卫,再把它当成反例。

**「让守卫红」和「把名字写对」在输出上长得一模一样。** 补完变异跑第一遍报 2 条存活,逐条跑
才发现两条都让守卫红了(synth 那条红 6 条断言,配对那条红 1 条并直接点名 on a "unpaired"
tree),是我把期望字符串写成了 an。改名字之前先确认了红的是哪几条。

外部评审同时确认了两件否定结论:没有 CRLF 回归(改动过的 harness 在 LF checkout 上 5/5 与
25/25),14 条变异本身没有空变异。

codex 第二轮评审(00:25)那一节保留:它没有结果,CreateProcess 在只读沙箱里一律被拒。第三轮
先探沙箱(让它回一个词,它回了 ALIVE),确认通道活着才真跑,拿到的就是上面这些。

守卫 18 → 21 条断言、10 → 14 条变异,全套仍是 53/53。
f80d5f7 的 tests/make-fixtures.mjs 里 SESSIONS_FLOOR 是 0,不是 6。

原因是我自己的操作顺序:--real 套件还在后台跑的时候,我执行了 npm run validate 然后直接
commit。mutate-fixture.py 当时正停在「the session floor stops being enforced」这条变异的
中间态,git add 抓到的就是它。harness 随后自己还原了文件,所以工作树看起来是干净的 —— 这
恰恰是它自己一直在警告的那种形状:写盘的一方不能是唯一判定自己写了什么的一方。

修复方式是先确认套件结束再提交。这不是新规则,本文件已有的并发警告只说了不要并行跑两套,
没说不要在套件运行时提交;两者是同一个原因。

只有这一个文件受影响:另外三个(verify-fixture.mjs / mutate-fixture.py / LAYERS.md)逐一
核对过,HEAD 与磁盘、与 workspace 副本三者一致。
两个变异控制没有 finally,且没有一个能被信号或管道关闭救回来。

mutate-fixture.py 和 mutate-review16.py 是仅有的两个既无 try/finally 也无 atexit 的
控制,恰好也是出事的那两个。25 个控制全部原地改写 shipped 文件(index.html、server.mjs、
make-fixtures.mjs、client-check.mjs、两个 README、docs/LAYERS.md、plugin.json),所以一次
中断留下的不只是脏文件,而是下一次运行读到的源——报出来的是一堆不相关的红。

补三层:

1. try/finally —— 变异写盘后到读结论之间全程受保护;还原先于判定,否则还原失败会被
   读成守卫失败。
2. atexit + SIGINT/SIGTERM/SIGHUP —— finally 覆盖不了管道关断和 Ctrl-C,那两种不会展开
   栈。还原幂等,永远写启动时读到的字节,不是重新计算的 original。
3. tests/run.mjs 在第一条控制之前对这八个文件做 SHA256 快照,最后一条之后校验。硬杀
   (taskkill /F、断电)不跑任何清理,这是快照存在的理由。

实测(taskkill /F 落在改写与还原之间)留下 31232 字节的变异态;finally 与 atexit 都救不
不了这一档,第 3 层能。下一次运行会报成一条守卫失败并给出还原命令,而不是让下一个人
从源的样子去推断。

顺带修一个真 bug:mutate-review16.py 的文件级分支在文件原本存在时直接删掉它,现在记录
真实字节再还原。当前唯一的目标文件(已删除的 docs/STAGE1-SPEC.md)不存在,所以这条路径
一直没走到——但它一旦指向一个会发布的文件就是数据丢失。

快照文件 tests/.shipped-snapshot.json 已进 .gitignore:它是本机簿记,而插件目录是要装到
读者机器上的包。
@avatasia

avatasia commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

感谢这次的 review —— 8 条都逐条查证并处理了,其中 4 条必改全部属实并修掉,4 条建议采纳 3 条,第 4 条给了不做��理由。前一轮零回复,这次补上。


必改 1 — tests/run.mjs 写死 python

属实,已修。 tests/run.mjs:45 现在是探测链 [$PYTHON, 'python3', 'python'],探一次全程复用、命中即 break。

reviewer 提的第二个问题比写死本身更值得记:一个从未启动的进程既没有 stdout 也没有 stderr,所以 tail 取到空串,34 条守卫里 15 条全红而输出里找不到任何理由。现在找不到解释器时会输出 no Python interpreter found; tried ... 并附 r.error.message。

另外补了一条 reviewer 没提的:同一轮只能有一个解释器。探测循环命中即 break,否则两台解释器都有的机器上会由「最后一个能用的」静默接管。

必改 2 — README 与代码不符

属实,而且比 review 说的更具体:tests/client-check.mjs 里早就有一条断言写着「state.kinds is written from exactly four controls」——测试早就知道,文档没跟上。

代码事实:index.html 勾选任一异常规则时走 resetBaseFilters(),而它会把 state.kinds 重置为全选、清搜索、清时间区间、丢掉总览刷选。所以两句话都是假的:「written from exactly three controls」(实为四个)和「Neither rewrites the kind array」(异常筛选恰恰会改写)。

中英文各改一处,改成陈述真实代价而不是删掉这句话:异常筛选是唯一一个会改写类型数组的横跨控件,而且是故意的(否则读者勾了异常规则、取消了问答、看到空白,却不知道该回去改五个控件里的哪一个)。并点明「类型选项只回答一个问题」只在异常模式之外成立 —— 而那也正是类型选项唯一还在起作用的时候。

必改 3 — 插件目录里的文档清理

分三件事。

3a 删 docs/STAGE1-SPEC.md。 它标注了不采纳、18 KB,里面留着当初跑交叉核对的本机命令与路径(codex exec …、~/.codex/models_cache.json、gpt-5.6-sol 等模型名),会随插件目录装到每个读者的机器上。

reviewer 说「没有地方引用」这点不准 —— LAYERS.md 里有两处引用,所以是连引用一起改的,不是只删文件,删完不留悬空指针。否决理由留在 LAYERS §5,不用第二份文件再存一遍。

3b LAYERS.md 的流程状态行。 未做清单第 8 项记的是「PR #16 reviews=0」和一份已进回收站的临时文件 —— 两条都只在写下来的那天为真,且谁也靠它做不了事。已删,保留一行说明为什么删。

第 7 项「跨家族验证只有 codex 一条腿」保留 —— 它是真待办。只把「需在交互式会话中处理」这种把读者当行动人的写法改成中性描述。

3c 归属不能一起洗掉。 顺手给这一点加了守卫:LAYERS.md 把决定归属给出决定的人,这是全文最吃重的部分 —— 没有它,读者分不清「已定的结论」和「某人当时的偏好」。清理只针对进程快照,不针对 provenance。

建议 1(采纳)— dispose() 没清 parsedRows

属实。 清了 descriptorCache 和 trajectoryCache,独独漏了 parsedRows —— 三个里最大的那个,装着一整个会话的已解析行。它有现成的 clear(),只是没人调。补一行。

建议 2(采纳)— 异常模式下空状态指向失效控件

属实。 空状态固定一句「可以放宽类型或搜索词」,而异常规则勾上时 syncAnomalyMode() 正是在把类型轴和搜索框标成 inert 的(因为勾规则时已经重置过它们)。这句话把读者送到两个按了没反应的控件上。改成按模式分支:异常模式下点名「清除筛选」—— 那是此状态下唯一还能按的控件,而且按钮本来就在。

建议 3(采纳)— 版本号

1.0.0 → 1.1.0。

建议 4(本轮不做,说明理由)— 建行期间固定 min-height

reviewer 实测到建行途中账本塌缩(114710px → 19786px),这几秒里视口显示的是别处的内容,此时点击会命中别的行。这个观察我认同,但这一轮不做,理由不是工作量:

  • 它是 L5 的行为变更,不是缺陷修复,而 LAYERS 第 4 节把「归层 / 关联 / 布局 / 口径」四问定成硬顺序,答案没写下来之前不许写实现代码。
  • 它直接压在 D14 刚收尾的那条不变量上:clear(ledger) 前固定高度,就是在 restoreRowAnchor() 的转交链中间插入一个「文档高度不再等于读者以为的那个」。在窄屏钉住 + 轮询建行三者同时发生时,那条链的正确性目前只有 verify-anchor-carry.mjs 的 10 条断言守着。

所以它留在未做清单里而不是消失。下一轮做它的时候,四问的答案会先落到 LAYERS.md。


守卫

新增 tests/verify-review16.mjs 34 条断言 + tests/mutate-review16.py 25 条变异,25/25 全捕 0 存活,被改的 7 个文件全部逐字节还原,还原后复跑仍绿。

守的七组:

  1. 解释器可被发现、且「找不到」是一句说明而不是一行空白
  2. README 中英文不得回到「三个控件 / 不改写类型数组」,且要与代码实际用的 resetBaseFilters() 对得上
  3. 本机环境(codex exec / ~/.codex / 用户目录)不得出现在任何随插件分发的文件里;已删除的 spec 不得复活、不得留悬空引用
  4. LAYERS.md 不留进程快照,但必须留住决策归属(按基线 19 个归属标记设下限 16,变异洗掉最大的一类 6 个后剩 13,必须报出来)
  5. dispose() 三个缓存全清,且 createParsedRowsCache() 确实提供 clear()
  6. 空状态有异常分支,且该分支点名「清除筛选」
  7. 版本号不再是 1.0.0

两条变异一开始存活了,都是守卫太弱而不是变异太轻:state.anomaly.rules.length > 0 在别处也出现,整文件搜索被无关代码满足了;「归属还在」只要求存在一条,洗掉一条不算失败。补法分别是把搜索范围收进空状态那一块、把归属断言从「存在」改成计数下限,而不是删变异。另有一条锚点在 server.mjs 上用了 \n,而那个文件是纯 CRLF —— 与它在客户端上犯过的同一个坑,锚点已按 \r\n 重新锚定。


另外:reviewer 这套流程帮我找出一个真缺陷

顺着 review 的查法往下走,发现 mutate-fixture.py 和 mutate-review16.py 是仅有的两个既无 try/finally 也无 atexit 的变异控制,而 25 个控制全部原地改写 shipped 文件(index.html、server.mjs、make-fixtures.mjs、client-check.mjs、两个 README、docs/LAYERS.md、plugin.json)。所以一次中断留下的不只是脏文件,而是下一次运行读到的源 —— 报出来的是一堆不相关的红,看起来像回归。

实测坐实了:本地一度出现 52/53,mutate-fixture.py 报「baseline did not pass」、11 条断言挂掉;同一个 commit 在干净 worktree 里 53/53 全绿。是上一轮运行的残留,不是回归。

补了三层:try/finally(变异写盘到读结论之间全程受保护,且还原先于判定)、atexit + 信号处理(finally 覆盖不了管道关断和 Ctrl-C)、tests/run.mjs 在第一条控制之前对那八个文件做 SHA256 快照、最后一条之后校验。硬杀(taskkill /F、断电)仍然救不回来 —— 实测确实留下 31232 字节的变异态 —— 这一档由快照兜住:下次运行报成一条守卫失败并给出还原命令,而不是让下一个人从源的样子去推断。

顺带修了一个真 bug:mutate-review16.py 的文件级分支在文件原本存在时直接删掉它,现在记录真实字节再还原。当前唯一的目标文件(已删除的 docs/STAGE1-SPEC.md)不存在,所以这条路径一直没走到 —— 但它一旦指向一个会发布的文件就是数据丢失。

本轮结果:干净检出下 node tests/run.mjs 全绿。上面提到的 34 条断言、25 条变异都是这一轮实跑确认的,不是从代码里数出来的。


还想确认一下 PR 描述:目前 description 还没有覆盖异常筛选、tests/ 整体说明和这一轮的中断残留修复,squash 时提交信息会偏片面。要我补一版完整的吗?版本号已经按建议升到 1.1.0 了。

chli 报:「还是有问题. 当前页面,随便选中一条,然后检视完全显示,占满了.」

根因:#inspector-panel 的基础规则(@media max-width 1080px 内)对三态一律给出
position: sticky; bottom: 0。bottom: 0 是一个关于高度的承诺 —— 它把元素下边缘钉在滚动视口
下边缘,这只在元素比视口矮的时候才叫「保持可达」。元素比视口高时它没有合法位置:浏览器把它
往上滑一个差值,于是它离开了自己的网格行。

于是布局是对的、位置是错的。main.columns 的网格行实测 7400px 849px,检视面板老老实实排在
账本之后(文档位置 8302),是 sticky 的偏移量把它拽到 885,叠在账本上。

测量(本机 863x803,窄屏断点生效,选中一条记录):

  面板渲染高   972 px -> 467 px (= panelMax)
  面板占视口   138%   -> 58%
  面板渲染位置 y 885  -> y 8314
  账本剩余可见 66 px  -> 336 px
  面板底边     视口底之下 416 px,够不到 -> 正好在视口底

修法:把上限从两个吸住态移到发出 position: sticky 的那条规则上,用同一个 panelMax,使
「sticky ⟹ 有上限 ⟹ 比视口矮」成为结构性质而不是对内容长度的祈望。零 DOM,不新增 state 字段。

.is-inspector-full 因此变成冗余规则,它的注释「the cap comes off」也已是假的 —— 已改写并写明
为什么保留这条规则。守卫现在断言两条声明保持一致,而不是允许它们各自漂移。

口径变更,读点全部列出:
  不变  --inspector-dock-h(2/5,D28);--inspector-full-h 的算法
        panelMax = max(160, innerHeight - 吸住区 - max(140, 25% * innerHeight))
  新增  #inspector-panel 基础规则开始读 --inspector-full-h。CSS 里这个变量现在有三个读者
        (基础 / .is-inspector-docked 读 dock / .is-inspector-full 读 full),守卫逐个断言
  变的  flow 态面板从「内容高度(本机实测 972px)」变成「<= panelMax(本机实测 467px)」,
        即「完全显示」不再等于「内容全部展开在文档流里」,而是「全部可达、在面板内滚动」

为什么 68 条断言全绿:verify-inspector-dock.mjs 全部断言样式表文本与函数源码文本,其中两条
恰好编码了相反的规则 —— `no cap applies while the panel is in the document`,以及它上面那句
「基础规则刻意不在列表里:无状态类时面板完全没有上限,那正是「完全显示」意为不设上限的唯一
地方」。守卫主动声明了「只有两个吸住态有上限」,而 flow 恰恰是那个带 sticky 却不受限的态。
守卫守的是规则,实现违反的是规则,两者都没测过真实盒子。

updateInspectorDock() 的判定也是对的:|rect.bottom - innerHeight| <= 1,flow 下面板底边在
1141、视口 706,差 435,判定「没吸住」,状态机保持 flow。判定没错,面板的位置错了,而没有
任何一条检查问过 flow 面板是否还在布局给它的位置上。

候选是量出来的不是推出来的。注入 CSS 覆盖实测三个版本:现状 972px 盖住账本;A(flow 不
吸底)覆盖没了但 |rect.bottom - innerHeight| <= 1 永远不成立、吸底机制整个失效;A′(flow
也用同一个天花板)394px、落在折叠线以下 —— 采纳 A′。

守卫:verify-inspector-dock.mjs 74 -> 77 条断言,两条编码旧契约的断言被替换成在决定处陈述
的性质而不是逐态点名:the rule that makes the panel sticky-bottom is the rule that caps it /
in the document and fully expanded are bounded by the same number / the document state is
bounded by the ceiling that spares the rows / every state the panel can be in carries a cap。
mutate-inspector-dock.py 59 -> 62 条变异,62/62 全捕 0 存活。

一条变异报「存活」但守卫其实抓到了:the docked panel is given a fixed height instead of a cap
点的期望名是 D32 里被替换掉的那条断言。手工跑一遍发现它被三条断言抓到,其中一条是本来就管
这件事的 D26 机制规则。改期望名之前先读了守卫自己的输出 —— 否则「为了让变异通过而改断言」
和「证人改名了」在输出上长得一模一样。

方法论补一条:断言规则 != 断言盒子。这次全部断言读的都是文本,而缺陷在布局里。文本断言能守住
「规则没被改回去」,守不住「盒子在它该在的地方」。

已在真页面复测:发布后 served 360949 = disk 360949,探针实测面板 467px / 占视口 58% / 底边
在视口底 / 账本剩余 336px。

全套 53/53。

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢这一轮非常细致的逐条回复和补测。上一轮的几项我都在 a77b1da 上复跑过:run.mjs 在只有 python3、只有 $PYTHON、两者都没有这三种环境下都符合预期,没有解释器时的提示也很清楚;README 中英文、删除 STAGE1-SPEC.md、dispose() 清 parsedRows、空状态指向「清除筛选」都已确认。用 SIGINT 中断变异运行后文件都能还原;D14 的几个场景和 #15 的几项回归也都正常。

需要修改

  1. 增量 ?since= 会让已经显示的工具调用行停在旧内容。工具调用记录在结果到达时会被回写耗时和「→ 结果预览」(server.mjs 1286-1291),但 delta 只回 index > since 的记录,客户端又按 index 去重保留旧对象。复现:会话停在一条还没返回的 toolCall,打开页面后追加 toolResult 和答复,等一次轮询:main 上是 read{path="a.txt"} → RESULT-PREVIEW-TEXT,这个 PR 上请求是 since=4,那一行一直是 read{path="a.txt"},也没有耗时,要刷新才对。建议对有新记录的轮次回传整轮记录、客户端按 index 覆盖,或者把 since 回退到最早一个未配对的调用。
  2. run.mjs 的快照没法在下一次运行时发现硬杀残留:起点哈希是本次启动时算的,.shipped-snapshot.json 只有已存在时才写,也没有地方读取。实测 kill -9 掉 mutate-anomaly.py 后 index.html 留在变异态,下一次 node tests/run.mjs verify-anomaly 报 35 条无关失败,没有出现 “shipped files restored”;跑 mutate-steer 则直接 1/1 通过。建议启动时先对这几个文件做一次 git status --porcelain 检查,或者真正把快照写盘、下次启动先比对。
  3. README 中英文第 7 行的版本号还是 1.0.0,和 plugin.json 的 1.1.0 不一致。

建议(可选)

  1. 窄屏吸底时,在检视面板里往下滚动后拖拽抓手会跟着内容滚走(863×803 下 scrollTop=300 时,抓手位置上点到的是异常筛选条),把它放到不滚动的外层或改成 sticky 会更稳。
  2. tests/run.mjs 第 86 行注释说只探测一次,实际是每个 .py 文件探测一次,改注释或把探测移到循环外即可。
  3. LAYERS.md 里有几处引用了仓库中不存在的 .probe\… 脚本,以及 codex 评审会话的环境记录,这些会随插件分发,建议移出或改成可在仓库内复现的说明。

关于 PR 描述:是的,麻烦补一版完整的。我们这边会 squash 合并,描述会直接成为提交信息,最好覆盖异常筛选、?since= 增量、tests/ 的用法以及检视面板的变化。

let records = 0;
for (const turn of turns) {
const kept = (turn && Array.isArray(turn.records) ? turn.records : [])
.filter((record) => typeof record.index === 'number' && record.index > since);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里只保留 index > since 的记录,但工具调用记录在结果到达时会被回写 durationMs 和 summary(1286-1291 行),客户端 mergeTurnRecords 又按 index 保留旧对象,结果是已显示的调用行一直没有结果预览和耗时,刷新后才正确。建议对有新记录的轮次回传整轮记录,客户端按 index 覆盖。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你说得对,而且比这还多一层原因。两边都核对过:

  • 服务端确实是在构建 payload 的收尾阶段,把 durationMs 和「→ 结果预览」写回已经 push 出去的那条记录对象上;
  • 客户端 mergeTurnRecords() 按 index 取并集,遇到已存在的 index 直接 continue,所以即使那条记录出现在增量里,也会被丢掉。

两个独立成因,任何一个单独修都不够:增量只带 index > since,而 union 又丢弃同 index 的新副本。

你给的修法(对有新记录的轮次回传整轮)我认同,但光回传整轮不够,还要把客户端从「并集去重」改成「按 index 覆盖」,否则只是把问题从服务端挪到客户端。

这条单独开一个改动:先复现(一条已显示的工具调用行,等它的结果消息到达,确认耗时不出现而刷新后才出现),再改,不塞进这个 PR。

Comment on lines +52 to +56
const snapshotBefore = Object.fromEntries(SHIPPED.map((rel) => [rel, digest(rel)]));

function checkShipped() {
const drift = SHIPPED.filter((rel) => digest(rel) !== snapshotBefore[rel]);
try { existsSync(SNAPSHOT) && writeFileSync(SNAPSHOT, JSON.stringify(snapshotBefore)); }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshotBefore 是本次启动时算的,结束时和同一次的起点比较;快照文件只有已存在时才写,也没有地方读取,所以上一次被 kill -9 留下的变异态不会被报告(实测下一次跑 mutate-steer 是 1/1 通过)。建议启动时先 git status --porcelain -- <这些文件>,或者把快照写盘、下次启动时先比对。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接受,问题成立:快照写盘但没有任何地方读,所以上一次被 kill -9 留下的变异态报不出来。

git status --porcelain -- <files> 我想采纳,但有个前提要一起说清楚:套件会跑在有未提交改动的机器上,不能简单地「这些文件非空就红」—— 那会把 contributor 正常的在写状态报成失败。倾向于把「本次启动前已经脏」和「本次跑完变脏」分成两件事报,而不是一律当失败。

单独处理。

@@ -6,6 +6,8 @@ Browse the model trajectory of a MiniMax Code conversation: messages, reasoning,

Author: [avatasia](https://github.com/avatasia) · Version: `1.0.0`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plugin.json 已经是 1.1.0,这里和 README.zh-CN.md 第 7 行还是 1.0.0。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

属实,已改。两个 README 第 7 行现在都是 1.1.0。

顺带认一条自己的错:这条的守卫只读了 plugin.json 就当成了,两个 README 一个都没查 —— 守的是没人抱怨的那个字段,漏的正是你点名的两个文件。已经补上三条断言,断言的是两个 README 携带的值等于 plugin.json 的值,而不只是「不是 1.0.0」:否则下一次版本号往前一走,README 又会安静地留在原地,而这正是这次漏掉的同一个形状。对应三条变异,其中一条专门把 plugin.json 改成 1.2.0 而 README 不动。

这一版还没推送到本 PR。

Comment on lines +2171 to +2174
position: absolute;
left: 0;
right: 0;
top: -7px;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

抓手是放在 overflow-y: auto 面板里的绝对定位元素,面板内容往下滚动后它会跟着滚走(863×803、scrollTop=300 时这个位置点到的是异常筛选条)。可以考虑把它放到不滚动的外层,或者用 position: sticky; top: 0。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

属实,根因和结构就是这样:.inspector-resize 是 #inspector-panel 的第一个子元素,position: absolute; top: -7px,而面板被钉住时自己就是滚动容器(overflow-y: auto)。绝对定位的子元素跟着容器的滚动内容走,scrollTop 一变它就离开可视区,原位被下面滚上来的内容顶住 —— 你点到的是异常筛选条,就是这个。

修法我倾向用 position: sticky; top: -7px 配一个负的 margin-bottom,把它在文档流里占的 14px 抵掉:面板内部结构不用动、视觉位置不变,而它会钉在滚动口的上沿。

放到不滚动的外层是另一条路,但窄屏下面板本身就是滚动容器,要另起一层就得改面板结构,会牵动检视面板那一整套守卫。所以先按 sticky 这条做,做完实测再给你看。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants