Skip to content

Add Session Trajectory Mini App: per-turn messages, tool calls, Token usage and timing - #15

Merged
MyPrototypeWhat merged 20 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory
Oct 11, 2026
Merged

MyPrototypeWhat merged 20 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory

Conversation

@avatasia

@avatasia avatasia commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds Session Trajectory (会话轨迹), a Mini App that renders the model trajectory of a
MiniMax Code conversation: messages, reasoning, tool calls and their results, Token usage,
and per-turn timing, grouped by turn_id.

Why it exists

A Mini App cannot tell which conversation opened its page — the runtime context has no
session id, the client bridge exposes only miniapp.message.append, and the page URL carries
no parameter. This app resolves the active conversation from the local runtime database,
always states in the page footer how it decided, and reports ambiguity instead of silently
picking when more than one conversation holds a live turn lease.

Data access

Files read <dataDir>/v2/sessions/**/messages.jsonl, snapshots/*.jsonl, manifest.json, history-catalog.json; <dataDir>/v2/sqlite/runtime-state.sqlite (read-only)
Files written none
Network none
Processes spawned none
State process memory only

This relies on an unspecified internal layout (v2/sessions structure and the runtime
database schema). A client update can break it. The README documents this, and there is a
capability request for a supported way to bind a Mini App to its session:
MiniApp capability wishlist #9.

Context generations

A compaction does not trim a session in place — the runtime rotates the previous
messages.jsonl into snapshots/, opens a new generation, and starts a fresh active file. So
the ledger shows the whole conversation, not just the tail. The app walks the chain the way
the runtime does: it opens the active file, reads the generation that file declares in its own
history_artifact, and steps back one generation at a time through the parent each file names.
A snapshot whose parent generation is not exactly one lower ends the chain instead of being
stitched on; a snapshot the chain never reaches is reported as an orphan rather than shown.

A 上下文代 picker appears when a session has more than one generation and narrows the
ledger to a single slice. Every record carries the generation it came from.

Filtering

Three controls, on two axes — and the distinction is the point.

Over kinds. The eight kind buttons, 全部 and 清除筛选 write one array. Nothing else touches
it. That is why the row of layer presets that used to sit above the list was removed: a preset a
reader outgrows turns into a button that contradicts the chips it was supposed to summarise.

Across kinds. Two controls narrow the ledger without rewriting that array:

  • 时间区间 — two wall-clock pickers, plus 15m / 1h / today / yesterday. A relative
    offset grammar was built first and then removed: an offset silently means a different interval
    as the session ages, and on a session that crosses midnight "the last hour" is ambiguous.

  • 异常 — rules that pick out records which are wrong, not records of a particular kind:

    rule what it catches
    工具失败 the record is a tool call that errored
    上下文压缩 a compaction checkpoint
    无答复 a turn that closed without ever answering — marked on the turn header, since one turn can hold hundreds of rows
    空答复 a reply that came back with no content

Every rule name lives in one table on the client. The buttons are generated from it, the row and
turn-header markers read their labels from it, and a guard fails if a rule name is ever written
as a literal instead.

The empty answer is worth calling out because it is invisible by construction: the record is an
ordinary reply whose content is empty, so nothing looks broken unless you count replies that
produced no text. It is deliberately both a reply and an anomaly — unticking 答复 hides it,
and the anomaly filter finds it.

Test environment

  • MiniMax Code 3.1.1.178 (desktop, Windows build)
  • Windows 10.0.26200 x64
  • Node in the Mini App runtime: v24.18.0

Verified manually in the client: publishing and startup, live polling during an active turn,
session switching, type filtering, time-range filtering (including across midnight), the anomaly
filter and its interaction with the type chips, on-demand record bodies, error states. The session
used for verification has been compacted twice and spans three generations — 2,000+ messages and
11.6 MB across the whole lineage, loaded without truncation. Session resolution was cross-checked
against the conversation title shown in the client and against the session's own manifest.

Not verified: macOS and Linux, the light theme, and compatibility with non-official or
source builds.

Notes for reviewers

  • No build step, no third-party dependency. Only node: built-ins; zero node_modules.
  • miniapp/node/miniapp-api.ts is a type declaration and is never imported at runtime.
  • Records that the session file does not record render as —; nothing is inferred or filled in.
  • Host-injected blocks recorded with role user are split off via the recorded
    canonicalTextRange and shown as 系统, so the ledger never presents injected context as
    something the user typed.
  • A turn total is broken down rather than shown raw: 对话 / 问答 / 压缩 / 其他. A turn that
    opened a context generation is a checkpoint, and a reply that arrived through a question card
    never became a typed message, so counting only what was typed would not reconcile with the
    total.
  • Unchanged sessions cost a 304 with no body; a validator is per generation, so a narrowed
    view cannot be satisfied by the stitched one.
  • The read cap is MAX_MESSAGE_BYTES, not the length history-catalog.json recorded: the active
    messages.jsonl keeps being appended after the catalog is written. The cap in force is sent
    with the payload and rendered from there, so the number is written once.
  • A session with no history-catalog.json — one that has never compacted — is read from its
    active file alone rather than refused.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@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.

感谢贡献!会话代链的拼接和"怎么判定当前会话"的说明都做得很用心,npm run check 和 CI 也都通过了。我用合成的会话目录和 runtime-state.sqlite 在本地把服务和页面跑了一遍(headless Chrome),下面几处是实际复现到的,具体位置见行内评论。

需要修改

  1. server.mjs:985:判断用的是 turn.records.length === 0,所以只要同一轮前面已经有用户消息,content: [] 的助手消息(比如 stopReason: 'error' 的失败回复)就不会生成任何行。复现时「消息 9」但「已显示 8 / 共 8 条记录」,那一轮的 Token 也找不到对应的行。
  2. PR 描述里说孤儿快照"reported as an orphan rather than shown",但它还是会出现在「上下文代」下拉里(例如「第 5 代(400 条)」),选中后是空页;messagesAll 也把它算了进去,于是显示成「全程 409 · 跨 4 代」,实际只拼了 3 代、9 条。
  3. 截断提示写的是「仅显示前 8 MB」,代码和 README 都是 64 MB。另外 truncated 的条件是 head.read < head.size,活跃文件比 catalog 记录的长度长时(也就是正在追加写入的时候)也会变成 true。复现时 688 字节的文件也显示了「文件过大」。
  4. README 说 /api/runtime"no absolute path appears in the response",但它原样返回了 argv(argv[0] 就是 node 可执行文件的绝对路径)、execArgv、pid、ppid。建议去掉或者只留 basename,或者至少在 README 里如实说明。
  5. openRuntimeStateDb() 每次请求都新开一个 DatabaseSync,用完没有 close(),dispose() 也不会关。我在本地数了一下,轮询过程中一直有 20–30 个 runtime-state.sqlite 句柄开着,dispose 之后也还在。AGENTS.md 要求 dispose 关闭所有打开的句柄,加个 try/finally db.close() 就行。

建议(可选)

  • 缺少 history-catalog.json 的会话会出现在会话列表里,但一打开就是 500(artifact_path_rejected)。不确定真实客户端里有没有这种会话,如果有,可以让活跃文件在没有 catalog 时也能读取。
  • history_relative_dir 直接 join 到 sessions 根目录,没有做 resolveArtifactPath 那样的包含检查,也没有比对目录名解码出来的 id;README 里"与 manifest、数据库交叉校验"的说法和代码对不上。数据来自 Host 数据库,风险不大,补一个检查或改一下措辞就好。
  • README 说 MMC_TRAJECTORY_ROOT"overrides the whole path",但代码把它当作数据根目录,后面还会拼 v2/sessions 和 v2/sqlite,措辞可以改一下。

Comment on lines +985 to +987
if (turn.records.length === 0) {
push({ kind: 'assistant', title: 'assistant', summary: '', text: null, tokens: carrierUsage() });
}

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.

这里判断的是整轮有没有记录,不是这条消息有没有记录。同一轮前面已经有用户消息时,content: [] 的助手消息(比如 stopReason: 'error')就一行都不会出,它的 usage 仍然会算进本轮 Token,但页面上找不到对应的行。改成 if (segmentOrder.length === 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.

你说得对,是我读错了层级。records.length 是整轮累积的,所以只要这一轮前面已经有记录,后面那条 content: [] 的助手消息就整条不落到台账里,而它的 usage 照样进了 Token 总量——页面上找不到对应行。

改成看这条消息自己的 segmentOrder:server.mjs:1073。

Comment on lines +1159 to +1161
const messagesAll = generations.length > 1
? generations.reduce((sum, entry) => sum + (Number.isInteger(entry.messageCount) ? entry.messageCount : 0), 0)
: rows.length;

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.

generations 里包含孤儿快照(没有被代链拼进来的),这里把它的 messageCount 也加进了「全程」。页面上的 generationOptions() 也会把它列成可选的一代,选中后是空的,和 PR 描述的"reported as an orphan rather than shown"不一致。可以在传给页面之前,按 lineage.chain 过滤一下。

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.

确认存在,按 lineage.chain 过滤了。

过滤放在 buildTrajectoryPayload 里面,按 orphans 的 fileName 匹配而不是在路由里先筛一遍再传进去(server.mjs:1283-1286)——这样调用方不管传进来的是 catalog 还是 chain 都不会再错,也不会出现两处筛选取交集不一致。

orphans 本身照旧返回,所以 PR 描述里「reported as an orphan rather than shown」这句仍然成立:被代链拒掉的那些不进 generations、不进选择器,但仍然列在 orphans 里报出来,不是静默丢掉。

<footer class="page-footer">
<p id="footer-source">数据来源:本机 MiniMax Code 会话记录,只读展示,不做任何写入。</p>
<p id="footer-binding"></p>
<p id="footer-truncated" hidden>文件过大,仅显示前 8 MB。</p>

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.

这里写的是 8 MB,MAX_MESSAGE_BYTES 和 README 都是 64 MB。

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.

确认是我写错了,MAX_MESSAGE_BYTES 是 64 MB。

没有直接把它改成 64——那样两个数字还是各写各的,下次改常量又会漂。改成由 server 把它实际用的上限随 payload 一起发出来,页面用 formatBytes(session.maxMessageBytes) 渲染(index.html:4856),markup 里的初始文案不含任何数字。

if (head.text === '') {
context.logger.warn('miniapp.trajectory.read_error', { reason: 'empty_messages' });
}
if (head.read < head.size) truncated = true;

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.

活跃文件的读取上限取的是 catalogBytes,文件正在追加写入、比 catalog 记录得长时,head.read < head.size 也成立,页面就会显示「文件过大」。复现时 688 字节的文件也出现了这个提示。建议只在真的触到 MAX_MESSAGE_BYTES 时才标记截断。

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.

复现得对,688 字节那个提示就是这么来的。两处一起改了:

  • 读取上限不再取 catalog 记录的大小(那是快照,活跃文件一直在往后追加),只受 MAX_MESSAGE_BYTES 约束,server.mjs:1837。readHead 本身会按 stat 出来的真实大小收口,所以这个上限只管内存,不会再把新追加的内容切掉。
  • 截断判定从 head.read < head.size 换成 truncatedByCap(head.size, limit)(server.mjs:218,调用点 server.mjs:1850),只有真的顶到上限才算截断。

抽成单独一个函数是因为它一开始只写在一行里,没有能直接测到的地方。

Comment on lines +1491 to +1499
sendJson(response, 200, {
nodeVersion: process.version,
pid: process.pid,
ppid: process.ppid,
execArgv: process.execArgv,
argv,
envNames,
envSessionLike,
argvSessionLike: argv.filter((entry) => SESSION_ID.test(entry)),

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.

README 写的是这个路由"no absolute path appears in the response",但 argv[0] 就是 node 可执行文件的绝对路径(Windows 上一般带用户名目录),execArgv、pid、ppid 也都原样返回了,README 里没有提到。页面也没有用到这个路由,建议删掉这几个字段,或者只保留 basename。

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 里的承诺。argv、execArgv、pid、ppid、envNames 全部删掉,连带那段扫环境变量找 session id 的死代码也一起删了(否则留着就是没人用的中间变量)。

现在只返回 nodeVersion、sqlite、cwdBasename、dataDirBasename、sessionRootExists。

整个路由没删:页面确实不用它,但它还能回答「运行时绑定活着吗」——sqlite 能不能打开、当前会话能不能解析出来。觉得该删我再删。

Comment on lines +512 to +521
async function openRuntimeStateDb(home) {
try {
const { DatabaseSync } = await import('node:sqlite');
const dbPath = join(home, 'v2', 'sqlite', 'runtime-state.sqlite');
if (!existsSync(dbPath)) return null;
return new DatabaseSync(dbPath, { readOnly: true });
} catch {
return null;
}
}

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.

每次调用都会新开一个连接,resolveCurrentConversation 和 readSessionTitles 用完都没有 close(),dispose() 也不会关。本地轮询时一直有 20–30 个句柄等 GC 回收,dispose 之后也还在。加个 try { ... } finally { db.close(); } 就可以。

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.

确认漏了。加了 closeQuietly(db)(server.mjs:620),两个函数都在 finally 里关(调用点 server.mjs:649、server.mjs:660)。

readSessionTitles 里 sessionIds.length === 0 的早退原本在 try 外面,也一并挪进去了,否则那条路径还是会漏。

The turn and call toolbar buttons were fold controls: they replaced rows with a
collapsed placeholder carrying a one-line summary. They are now layer switches
that remove rows from the render outright, leaving no placeholder behind and
making the button the only way to bring them back.

- turn governs the conversation layer (user, assistant)
- call governs the tool layer (toolCall, toolResult)
- the two layers do not overlap, so switching off one never takes part of the
  other with it
- turn headers recompute from the rows that survive, so every count under a
  filter is a real post-filter count

This drops the whole fold apparatus: collapsed/collapsedCalls state sets,
reapplyFoldIntent, isCollapsed, isTurnFoldable, contentEntries, summarizeTurn,
summarizeCalls, callBlocks, buildTurnPlan and the fold-summary styles. Button
pressed state is now the visibility itself, so there is no derived state left
to drift.

Also in this change:

- window controls: a sticky window bar offers load 200 earlier, jump back to the
  latest 200, and show everything. Show-all is a mode, not a big number, so
  incoming records do not knock it back down to 200 on the next poll.
- scroll stability: the ledger restores a row anchor rather than an absolute
  pixel offset. The window shifts forward as records arrive, so a fixed pixel
  position drifts even though the scrollbar never moves.
- sticky fix: .panel used overflow: hidden, which made it a scroll container
  and silently killed every position: sticky inside it. Switched to
  overflow: clip, which also revives the previously dead .turn-head sticky.
- filtering: isFiltering and spanPassesFilter now agree entry for entry with
  visibleEntries. When they drifted, the overview bar drew spans for rows the
  list refused to render, which is what made filtering look incomplete.

READMEs document the layer semantics and the new window controls.
`raw` is the untouched session message, and on a real conversation it is around two
thirds of the payload. The page read it for exactly one record at a time — the
inspector's raw tab, one clipboard copy, one handoff — so every poll transferred the
weight of a session nobody was looking inside.

The bulk payload no longer carries message bodies. A new /api/trajectory/raw serves
one record out of the same cache entry that served the body, so opening a record costs
one record rather than a session. Measured on a 5,614-record conversation: 27.0 MB
before the split was even reachable, 10.8 MB after, and the session grew by 13 records
in between.

What did not move: `rawOmitted` stays on every row. Whether a message was too large to
carry is a fact the list itself has to be able to state, and it is one boolean per row.

The three readers are now strict. A fetch that fails says so in the panel or in the
status line instead of quietly putting the normalized row where 原文 promised the
original — those two are not distinguishable to a reader, which is exactly why the
fallback is gone. A record whose message was too large to carry still falls back, since
there is nothing better to show.

handleTrajectory's session resolution is extracted into resolveTrajectoryRequest so the
bulk payload and the raw fetch cannot disagree about which session a query string
means. A 503 from the raw route means the server no longer holds the session; the page
reloads it once and retries, capped at one retry so a permanently missing session cannot
loop.
Hiding 轮次 left every thinking record on screen. Thinking arrives in the same model
response as the answer it produced and shares its responseId, so it is part of what
the turn was — and a list showing several hundred rows of it after the conversation
was switched off reads as a filter that does not work rather than as one that does.

CONVERSATION_KINDS now covers user, thinking and assistant. The button's own label
said the old scope, so it reads 对话记录(用户、助手与思考)now.

Nothing else moves: CALL_KINDS is untouched, the two layers stay disjoint, and a
record whose message was too large to carry still reports rawOmitted on its row.

verify-overview gains a direct assertion that no thinking record survives 轮次 being
switched off; the fixture expectations for 轮次 off and for both off were derived by
hand and corrected (both-off now leaves only the system row, since a3 was a thinking
row that no longer survives).
Folding thinking into 轮次 answered the wrong question. Reading a conversation to find
what was said, and reading a session to find out how the model got somewhere, want
opposite settings for the same rows — so thinking is a layer of its own and the
toolbar now reads 时长 / 轮次 / 思考 / 调用.

CONVERSATION_KINDS goes back to user + assistant, THINKING_KINDS takes thinking, and a
third flag, button, dom binding and click handler follow. All three layers stay
disjoint and visibleEntries, spanPassesFilter and isFiltering all gained the same third
check in the same place, so the overview strip and the list still agree span for span.

The static aria-labels in the markup still said 点击折叠全部 — dead text that the first
render overwrites, but it was the only place in the file still describing these as
folds. They now state what the buttons actually do.

verify-overview: 101 assertions, up from 96. The layer section was re-derived by hand —
轮次 off leaves ['a1','a3','a5','a6'] again, all three off leaves ['a1'], and the lit/out
table now covers a third button, including a case that hides thinking while both other
layers stay exactly where they were.
The toolbar's 轮次 / 思考 / 调用 and the 类型 chips were two independent filters over the
same records. Five of the six chips mapped onto a button, so the page was asking one
question twice and could answer it twice — which is what made the row look redundant.

There is only one thing being filtered: which record kinds are visible. state.kinds is
now the only place that lives, the buttons are presets over it (turn = user+assistant,
thinking = thinking, call = toolCall+toolResult), and a button reads as lit only when
every kind it covers is checked. Unchecking one chip puts its button out rather than
leaving it claiming a half-on state the control has no vocabulary for.

Gone: CONVERSATION_KINDS, THINKING_KINDS, CALL_KINDS, showConversation, showThinking,
showCalls, and the three layer checks in visibleEntries and spanPassesFilter — hasKind
was already doing exactly that work. isFiltering loses the three flags for the same
reason. Net: fewer moving parts than before the buttons existed.

The chips stay, because they reach subsets no button combination can: only user, only
system, assistant without user. verify-overview asserts those explicitly so the chips
cannot be "simplified" away later without something failing.

setLayer splits into a pure applyLayerToKinds and the part that renders, matching how
rePinWindow and turnTotalsFromEntries are already shaped here, so the rule a button
applies is assertable without a DOM.

Verified in the browser in both directions: unchecking the 思考 chip put that button at
aria-pressed=false while the other two stayed lit, and clicking the button put the chip
back. verify-overview 113 assertions, up from 101.
Unifying the buttons onto the chip set left one case the buttons could not say out loud.
Hiding 助手 in the chips while leaving 用户 checked puts 轮次 out — "lit" can only mean
"all of it is on" — but the reader did not hide the conversation, they narrowed it, and
the button's plain 已隐藏 claimed otherwise. It was also the one case where clicking the
button silently overrode a choice the reader had just made.

A button now has three states: lit when every kind it covers is checked, 当前已隐藏 when
none are, and 当前部分显示 · 点击全部显示 when some are. That wording says both that the
layer is narrowed rather than gone, and that clicking fills it in. 思考 covers a single
kind and so can never be partial.

layerPartial is pure and sits beside layerSelected, and verify-overview now pins the
behaviour: a partial layer is neither lit nor fully cleared, says the right thing, leaves
the other layers alone, and clicking it restores only the kinds that were missing.

Verified in the browser: unchecking only 助手 left 轮次 at aria-pressed=false reading
当前部分显示 · 点击全部显示 with the other two buttons untouched, and clicking it put
the 助手 chip back on. verify-overview 121 assertions, up from 113.
The three buttons above the list were presets over the type chips, which made
them a second way to ask the same question. Any preset a reader outgrows ends
up contradicting the chips it was meant to summarise: untick 助手 while leaving
用户 ticked and the turn button goes dark claiming the conversation is hidden,
when in fact it was narrowed. There was a third button state written purely to
explain that contradiction.

There is now one control and one answer. state.kinds is written from three
places - the chips, 全部, 清除筛选 - and client-check asserts that count plus
the absence of the layer machinery, so this cannot quietly come back.

时长 moves down into the filter bar next to the chips: it changes how the
overview draws the clock, not which records exist, so it reads as a view
control sitting beside the filter rather than as another filter above it.

The removed 32px toolbar row was carrying the sticky offsets, so .overview
now pins at 0 and .window-bar at 51px (50 plot + 1 border). Without that the
window bar would slide under the strip.
Two defects with the same shape: a control or a label that could not be acted on
the way it appeared to be.

A compactionSummary was filed as kind 'system' with title 'compaction', so the
client had to special-case the title in five places to keep calling it 压缩. The
filter reads kind alone, which made a context checkpoint impossible to keep or
drop on its own - unticking 系统 took every checkpoint with it. It is now a
seventh kind, which deletes all five special cases and gives it a chip.

The axis button was labelled 时长 and sat with the filters at the top of the
page. That reads as a filter on duration; it changes neither the rows nor any
count. It is renamed 总览轴, its label now names the strip and both axes, and it
moves into the window bar directly under the strip it redraws.

Putting it there meant the window bar could not keep rebuilding its nodes: a
render happens every poll, so a click landing between the clear and the
re-append went to a detached button. It is built once now and updated after.
The ledger builds a real element per row — there is no virtual scrolling — so
rendering the whole session at once is not an option to offer by default. On a
9,900-record session that is roughly 57,000 elements rebuilt every 2.5s poll,
which was measured to freeze the page. The page now opens on the newest 200 and
the window bar switches to everything on request.

  - The button names the action pressing it performs, not the state it is in:
    「显示全部」 at the default, 「显示最新 200 条」 once everything is showing.
    The count beside it reports what is on screen; the two answer different
    questions and so cannot contradict each other. `aria-pressed` carries
    whether the full view is on and `aria-label` restates it as a sentence.
  - 「向上加载更早的 200 条」 widens the window, and a poll no longer undoes it.
    `rePinWindow()` used to reset the limit unconditionally, so the button never
    actually worked — every 2.5s poll took it straight back to 200. It now
    leaves a widened window alone and only re-pins a window that is exactly the
    default size, which is what "follow the tail" means.
  - The button does not scroll the page away from the reader's place.
  - `.btn[hidden]` is now honoured. `display: inline-flex` was overriding the UA
    rule, so a hidden button stayed on screen with no label in it. Six sibling
    selectors already carried this patch; `.btn` was the one that did not.
  - `DEFAULT_WINDOW_MODE` is a single constant that every reset reads, rather
    than eight copies of the literal that could disagree with each other.

Six review comments:

  - An empty `records` array was read as "this whole turn is empty"; it is per
    message, so the test now looks at this message's `segmentOrder`.
  - `generations` listed the whole catalog, so a generation the lineage walk had
    rejected — a snapshot left behind by a fork — was counted into the
    session-wide total and offered in the picker, where selecting it resolved to
    an empty view. The payload now reports the chain, and still discloses the
    rejected ones through `orphans`.
  - The footer claimed a cut at 8 MB while the server reads 64 MB. The cap is
    sent with the payload and rendered from it, so the two cannot drift again.
  - The active file's read was bounded by the catalog's recorded size. That size
    is a snapshot and the file keeps being appended to afterwards, so an actively
    written session could stop the read short of data that was really there and
    then report itself as "file too large". The read is bounded by our own cap,
    which is the only thing that can actually truncate, and `truncatedByCap()`
    is now the single place that decides it.
  - `/api/runtime` returned `argv` (whose first element is the node path, and on
    Windows a username), `execArgv`, `pid` and `ppid`. The page never reads that
    route; it now reports only the four facts it can state safely.
  - `resolveCurrentConversation` and `readSessionTitles` opened a SQLite handle
    per session and never closed it, so a long session picker leaked a handle per
    entry. Both close in a `finally`, including on the early return.
A checkpoint was filed under "<turnId>:compaction" while every row after it carried the bare
id, so keying turns on the whole string opened a turn for the checkpoint alone. Turns join the
list the moment they are first seen, and the interrupted turn kept collecting records after the
checkpoint had been seen — so the checkpoint was emitted after the entire turn it belonged to.
On a 10,500-record session that showed up as 12 turns of exactly one row each, a numbering that
ran backwards (#1037 then #970), and timestamps stepping back up to 20 minutes.

Keying the checkpoint on the bare id puts it back where it happened. Measured on the live
session: turns with a hole in their index range 12 -> 0, timestamp inversions caused by a
checkpoint 12 -> 0, and the ones that remain (17, all a steer after the tool result it
interrupted) are left alone on purpose — a steer carries the time the reader typed, and sorting
it back into place would put it before the calls it was answering.

A turn that *contains* a checkpoint is still judged on what it did. "Contains one" used to be
the same as "is one" back when a checkpoint was a turn of its own; keeping that would relabel
every compacted turn and drop all of them out of the prompted count. Only a turn holding nothing
but its checkpoint is one.

The checkpoint is drawn as a full-width band rather than as another row. It is still one
filterable record — the 压缩 chip counts and hides it like any other — but the rows above and
below it are no longer the same conversation as far as the model is concerned, and one line
says so instead of leaving a gap to infer from. That band's number now comes from the record
count, because the turn count is zero for every session that was ever compacted.

Both counters were also being kept honest about what they count: a record's `turnId` now names
the turn it is actually in, since the file's suffixed id names no turn at all.
Reported as "#1085 appears more than once". No index was duplicated: the
payload holds 11030 records and 11030 distinct indexes, and the ledger
builds one row per entry. The number was being cut off on screen.

The first grid track was 42px, which holds "#" plus four digits — the whole
of every session until one passed 10,000 records. Past that the fifth
digit left the track, and a grid item is `overflow: visible` by default,
so it spilled into the 76px kind column and was painted over by the
opaque pill. #10850..#10859 all read #1085; ten consecutive rows showed
the reader one number. Nothing errored, and unlike a wrong number this
one cannot be checked — it looks exactly like a real index.

Three grids render a record index and all three were 42px; the
max-width: 767px override narrowed it to 28px, too small even for four
digits, which is what made the phone layout the worst case. They now
share one `--traj-index-col: 72px`.

72px is derived, not chosen: the old track held five characters and would
not hold six, so one character is at most 42 / 5 = 8.4px, and a label is
"#" plus at most seven digits — 8 x 8.4 = 67.2px. A session would have to
reach 10,000,000 records before this bites again.

.row-index also gets `text-overflow: ellipsis` so that if a column is ever
too narrow again the row says it could not show the number in full,
instead of quietly displaying a different valid one.

Verified in the browser at 72px: .row-index measures width 72 on both
the plain row (#11023, x 31-103) and the compaction band (#11034,
x 33-105), with the kind pill starting at x 109 and x 113 respectively,
and consecutive indexes #11049..#11069 render complete and distinct.
Adds a 时间区间 row under the search box — 起点 / 终点 boxes plus 更新到最新
and 清空区间. Empty by default, and the table, the window count and the KPI
strip all read the same one filter.

- Input is a session-relative offset (`1:30`, `1:30:00`, a bare number reads
  as minutes) because the ledger already prints `+893 分 21 秒`; wall-clock
  lines up with nothing else on the page. The hint always echoes the parsed
  value back, so what the filter is actually using is never a guess.
- KPI is recomputed client-side from the same `state.flat` records the table
  reads, and the probe proves it equals `payload.stats` field for field
  across all 15 fields on the real 11,812-record payload. Header and table
  cannot drift onto two definitions.
- A record matches on its own offset; a turn matches when any record inside
  it matches, and is then counted whole. `messagesAll` stays 全场 so the
  comparison label still means something.
- The range is a snapshot, not a subscription. `rangeCutoff` pins the highest
  record index when the range is set, so new records cannot append themselves,
  and 更新到最新 is what moves it. Measured: 共 11,679 → 11,685 while
  已显示 654 held still.
- Setting a range calls `resetWindowMode()` like every other filter. Forcing
  「全部」 was measured and dropped: a range starting at 20:00 covers the whole
  session, and 全部 then rendered ~11,000 rows and took the document to
  414,000px, where one query took 19s. 不自动追加 does not depend on it —
  the cutoff holds the range on its own.
- A box the page cannot read is never written into state. `NaN` fails the
  `isCount` test behind `hasTimeRange`, so storing it would switch the whole
  range off mid-keystroke and quietly hand back the entire session. The hint
  reads the boxes rather than state, which is what makes the
  「格式无法识别」 message reachable at all.
- 清空区间 hides itself while no range is set, and 清空 clears the range too.
… offset

The boxes took a session-relative offset, which is what the ledger prints on every row — and
which stops being answerable once a session runs past midnight. This one crosses 2026-10-11 at
offset 12:14:29: `12:00:00` is 2026-10-10 23:45:31 and `13:00:00` is 2026-10-11 00:45:31, so an
hour of offset covers three quarters of an hour of clock with midnight in the middle and nothing
on the page saying so. A reader asking for 「23:50 到 00:20」 had no way to ask it.

So a box may now hold either clock, and the text decides which.

- The two grammars cannot be confused because they share no separator: an offset is digits and
  colons (`1:30`, `1:30:00`), a date is digits and `-` or `/`. `hasRangeDate` reads that off
  the text before any value is computed, so the filter, the label and the hint cannot come out
  disagreeing about what was typed.
- The conversion is a subtraction and nothing more. Every record already carries `timestamp`
  and `relativeMs`, and `session.startedAt` is the origin those offsets were measured from, so
  no second clock is introduced anywhere. Checked against the live payload: a date range over
  23:50–00:20 selects exactly the 545 records whose `timestamp` falls in it, and the
  equivalent offset range `12:04:28 – 12:34:28` selects the same 545.
- The year is required rather than inferred. Guessing means deciding what `01-01` means in a
  session that starts in December, and a range that lands on the wrong year is worse than one
  the reader has to finish typing.
- The date is assembled from parts and round-tripped, never handed to `Date` as a string:
  `new Date('2026-10-10')` is UTC, and without the round trip `2026-02-30` rolls silently into
  March and returns a range nobody asked for.
- A bound before the session began stays negative instead of being clamped, and now prints its
  sign — `Math.abs` on its own made `-2:45:00` read as `2:45:00`, which in the one place the
  sign carries the whole message is the wrong answer rather than a formatting nit.
- Each end of the label comes back in the clock its own box was written in. An offset beside a
  date is a mismatch the reader has to decode before trusting it.
- A session whose records carry no timestamp says so, instead of reporting 「格式无法识别」 and
  sending the reader hunting for a typo that is not there.
- The boxes were a bare 88px, which held `2026-10-10` and scrolled the time out of sight. The
  value stayed correct and the hint echoed it back, but the field could not be checked by
  looking at it — the same defect as the 序号 column. Width is now 18ch, derived from the
  rendered box: 15ch came out at 113px and held 15 character slots, and sixteen glyphs plus
  the caret is 17.

Probes: 162 assertions in verify-time-range.mjs, and 55 + 12 mutations in the two runners, each
caught by the guard that owns the defect.
…et grammar

The boxes took a session-relative offset, on the argument that `+893 分 21 秒` is what every
ledger row prints. They are now two `datetime-local` pickers with four quick buttons beside
them, and the offset grammar is gone.

The argument did not survive contact with a session that crosses midnight. This one crosses
2026-10-11 at offset 12:14:29, so `12:00:00` is 2026-10-10 23:45:31 and `13:00:00` is
2026-10-11 00:45:31 — an hour of offset covering three quarters of an hour of clock, with the
midnight in between and nothing on the page saying so. It also did not survive switching
sessions, where the same number means a different moment. A picker is the honest control for a
moment in time, and one grammar per box is one less thing the reader has to work out.

The quick picks are the four windows this page can answer for any session:

- 最近 15 分钟 / 最近 1 小时 — the trailing history a reader wants when something just happened
- 今天 / 昨天 — the two wall-clock days a session that runs overnight is split across

清空区间 already means the whole session, so there is deliberately no 全程 button beside them.
Days are stepped through the `Date` constructor rather than by subtracting 86,400,000, which
lands on the wrong day across a daylight-saving boundary, and a pick whose window opens before
the session is clamped to the session start — "the last fifteen minutes" of a five-minute-old
session is its whole life, not a negative range.

Two things the picker could not do on its own:

- A window that is well formed and lies outside the session now says 「这段时间里没有记录」 and
  names where the session actually starts. That is exactly what 昨天 does on a session that
  began this morning, and without it an empty table reads as 「这个会话没有内容」.
- The pickers are bounded by the span the session covers, re-decided on every render because
  the session grows during the first poll.

The controls also had to stop being the odd one out: `datetime-local` was missing from the
shared input rule, so it arrived at its own intrinsic 22px height with the user agent's own
border. And it is now sized by its own content rather than by a number in the stylesheet — the
same defect as the 序号 column, committed twice: 18ch clipped the year off the text box that
preceded it, and 20ch clipped the minutes behind the calendar icon on the picker. The browser
lays a `datetime-local` out from the localized date it has to draw, so a width in the
stylesheet is a guess about something the user agent already knows, and it cannot go stale the
way a measured constant does.

Probes: 148 assertions in verify-time-range.mjs, and 66 + 11 mutations across the two runners,
each caught by the guard that owns the defect.
An interruption is the thing a reader of this page most wants to find, and until now nothing
pointed at one. 失败 was a pill on a row and 压缩 was a band, so those two were findable by
eye; a turn that simply took a prompt and never answered had no marking anywhere, which is
the shape an interrupted turn leaves behind.

So 异常 is a filter, not a kind. A kind is a label the runtime stamps on a row; 异常 is a
question asked across the whole ledger — 「这次运行在哪儿出的问题」 — and its answer lands on
three unrelated kinds at once. Filing it as a tenth kind would have put it in the type chips,
where it reads as one more category instead of the second axis it actually is.

Each rule is one predicate plus the label the reader sees when it fires:

- toolError 工具失败 — a tool result the runtime marked as an error
- compaction 上下文压缩 — the checkpoint written when the history underneath a turn is rewritten
- noReply 无答复 — a turn that took a prompt and never produced a reply

Adding a rule is adding an entry to that table. The buttons are built from it rather than
written into the markup, so a rule has exactly one name in exactly one place. The kind chips
already had this defect once: the label lived in the markup and the accessible name was
written beside it, so a rename left a button showing one word and announcing another, and
nothing in the guards could see it because both halves were individually correct.

The turn still running is excluded from 无答复. It is open because it is going, which is not
the same thing as broken, and a rule that re-flagged the newest thing on screen on every 2.5s
poll would be a filter nobody could leave on.

The counts on the buttons and the rows in the list come from one predicate. anomalyCounts()
walks the flat list to fill the buttons, and while that walk was its own copy a button could
promise 246 while the list showed 12 — the same 「筛选了但什么都没变」 this page keeps guarding
against, moved up one level. Both go through passesBaseFilter() now. The overview strip is
handed a bare record and 无答复 is a property of the turn, so the owning turn comes back out
of a turn index; without that lookup the strip and the ledger directly above it answer
different questions about the same row.

无答复 is marked on the turn head and not on each row. It is one thing that happened to a
whole turn, and turn 82 alone would have carried the tag 352 times — at which point it stops
being a signal and becomes wallpaper.

Measured on the live session: 94 turns, 13,132 records, 1,416 anomalous — 251 tool failures,
15 checkpoints, and 1,184 records spread across 10 turns that never answered.

Probes: 98 assertions in verify-anomaly.mjs against the running session, with the expected
counts taken straight off the payload by code that shares nothing with the page, and 27
mutations each caught by the guard that owns the defect. Two guards in verify-time-range.mjs
that pinned the old shape of visibleEntries() and clearFilters() were rewritten to pin the
invariants instead of the layout, and the reset-call inventory moved from ten to eleven for
the new filter.
Tracing an interruption back to the raw session file turned up a shape the 异常 filter could not
see, and the reason is worth writing down because the rule set was built on the assumption that
every anomaly is a *present* signal — a flag, a kind, a missing turn.

The turn the reader watched finish without an answer was closed like this:

    "role": "assistant", "content": [], "stopReason": "stop",
    "usage": { "output": 78, ... }, "responseId": "071a35d49ed..."

The call happened. There is a response id and an output token count. It stopped normally, not
aborted, not timed out. It just came back with nothing in it. The turn therefore has a reply
record, `closed` is true, and 无答复 — which tests `closed === false` — has nothing to fire on.
The 答复 chip counted it the whole time. From the ledger's side it was a reply; from the reader's
side nothing was ever displayed.

So the new rule tests an absence:

    record: function (record) {
      return !!record && record.kind === 'reply' && isNil(record.text);
    }

It keeps its kind on purpose. The record genuinely is a reply, the 答复 chip counts it and the
reply filter keeps it — it is an anomaly *as well as* a reply, not instead of one. `text` is the
field to read rather than `textTruncated`: a clipped reply still has a body, however short, and
whitespace is a body a reader can see.

The row needs to say so too, or the filter shows rows that look like ordinary answers. It takes
over the duration cell the way 失败 already does, so the five-column grid does not grow, and the
name comes from the rule table rather than a second copy written at the call site — the kind
chips already paid for having the visible word and the announced word in different places.

Measured on the live session: 99 turns, 13,358 records, 1,422 anomalous — 255 tool failures, 15
checkpoints, 1,184 records across 10 turns that never answered, and 2 replies that delivered
nothing. Both empty replies sit in closed turns, which is checked against the session rather
than assumed, because that overlap is the whole reason this rule exists.

Probes: 127 assertions in verify-anomaly.mjs and 37 mutations, each caught by the guard that
owns the defect. Two of the new assertions are about what the rule must NOT do — a checkpoint
and a bodyless system record are not empty answers, or the union would come out smaller than
its parts.

@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.

谢谢这么快就逐条改完!上一轮的 5 处我都用同一套合成数据和 headless Chrome 重新跑过:空内容的助手消息现在有对应的行,孤儿快照不再出现在选择器和「全程」里,截断提示改成 64 MB 而且追加写入时不再误报,/api/runtime 不再返回 argv/pid,SQLite 句柄在轮询和 dispose 之后都是 0。新加的时间区间、异常筛选、按需原文(包括缓存过期后自动重试)我也试了,都工作正常。

需要修改

  1. README 有几处没跟上代码:

    • Diagnostics 段落(README.md:94、96,zh 84、86)还在说 /api/runtime 会返回环境变量名,并检查 env/argv 里有没有会话 id,以及"reports 13 environment variable names",这些已经删掉了;
    • README.md:25(zh 25)说"The type chips are the only filter",但这一轮新加了「时间区间」和「异常」两组筛选,两份 README 里都还没提到;
    • README.md:72(zh 62)说用 catalog 长度给读取设上界,现在活跃文件只受 MAX_MESSAGE_BYTES 限制,不读半行靠的是 readHead 截到最后一个换行。

    麻烦改成和现在的行为一致;PR 描述如果能顺手补上这些新功能就更好了。

建议(可选)

  • /api/runtime 里的 probe(server.mjs:1642-1647)只在查询成功时 close()。表结构不匹配时会留下句柄等 GC,换成 finally { closeQuietly(probe) } 就行。
  • README.md:62 "…says so where it shows.One model call…" 两段连在了一起;README.md:113 "offers that choice backers four explicit controls" 这句读不通。
  • 上一轮的三条可选建议(缺 catalog 的会话打开会 500、history_relative_dir 的包含检查、MMC_TRAJECTORY_ROOT 的措辞)还在,看你方便。

The second review round confirmed the five code fixes from the first
(segmentOrder, orphan filtering, the 64 MB cap, the runtime route, the SQLite
handles) and re-ran them, but the READMEs had been left describing the app as
it was before the time range and anomaly filters landed. Bring them back in
line, and close the open suggestions while the review is still warm.

README, both languages:

- "/api/runtime" still advertised environment variable names and an argv scan.
  Both were removed in the previous round; say what the route returns now and
  why the scan is gone rather than quietly dropping the sentence.
- "The type chips are the only filter" was true when written and is not now.
  The time range and the anomaly filter cut across kinds rather than over
  them, so state that instead of leaving the reader with one axis in a UI that
  has three.
- The read was described as bounded by the length history-catalog.json
  recorded. It is bounded by MAX_MESSAGE_BYTES, and readHead clamps to the last
  complete line. The cap is sent with the payload and rendered from there, so
  the README should stop carrying its own copy of the number.
- MMC_TRAJECTORY_ROOT replaces the data directory; v2/sessions and v2/sqlite
  are still resolved beneath it.
- Two paragraphs had run together at README.md:62, and the sentence offering
  "four explicit controls" had lost its verb.

server.mjs, three suggestions that were still open:

- /api/runtime closed its probe handle inside the try. A schema mismatch is
  precisely the case this route exists to report, and it is precisely the case
  where prepare() throws -- so the handle waited on the GC every time.
- history_relative_dir was joined onto the sessions root without the
  containment check resolveArtifactPath gives catalog file names. It is a
  database column rather than a name this app chose; it gets the same check.
- A session with no history-catalog.json -- one that has never compacted --
  used to answer artifact_path_rejected and a 500, because artifactByFile was
  empty even though the active file was real. probe() already synthesised an
  artifact for exactly this case; reuse that shape instead.

npm run check passes (validate clean for this plugin, 55/55 tests) and the
existing probes are unchanged: verify-pr15-fixes 26, verify-generations 108,
verify-compaction-turn 22, verify-time-range, verify-anomaly 127,
verify-overview 108, client-check 168, plus the response-rail, window-widen,
call-fold, block-order and prompt-fix probes.

@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.

感谢更新,这一轮我又在本地对 871c5d5 逐项复现了一遍:

  • README 的 Diagnostics 部分(中英文)现在和 /api/runtime 实际返回的字段一致,筛选说明也补上了时间区间和异常筛选;读取上限的说法改为以 MAX_MESSAGE_BYTES 为准,和代码行为吻合。
  • /api/runtime 的 probe 放进了 finally:表结构不匹配时连续请求 20 次,SQLite 句柄数仍然是 0。
  • 没有 history-catalog.json 的会话现在能正常打开(HTTP 和页面上都验证过);history_relative_dir 越界时会被拒绝,并回落到按 mtime 判定;MMC_TRAJECTORY_ROOT 的说法也和实现对上了。
  • 之前的 5 个必改项都没有回归,Node 22 下 npm run check 全部通过。

一个小建议(可选):PR 描述里提到「如果规则名被写成字面量,会有 guard 报错」,代码注释里也提到 offline probe,但仓库里没找到对应的脚本。可以考虑把它提交进来(放在 miniapp/ 之外),或者在描述里注明是本地检查。

整体没有问题,LGTM。CI 还需要维护者批准后才会运行。

@MyPrototypeWhat
MyPrototypeWhat merged commit 2d4daa8 into MiniMax-AI:main Oct 11, 2026
@avatasia

Copy link
Copy Markdown
Contributor Author

Closing the loop on the last note in this thread — the rule-name guard was not
reproducible, because the whole guard set was scripts on one machine with absolute paths in them.
That is fixed in #16: fifteen guards now live in plugins/avatasia/mmc-trajectory/tests/ and run
with node tests/run.mjs on a clean checkout, needing only Node and Python.

Two notes on what is deliberately not in it, both recorded in tests/README.md:

  • Four mutation controls were left out. Two of them guard the layer buttons and the turn lens,
    which 91464cd removed — turns-btn, setLayer, LAYER_KINDS and applyLayerToKinds now occur
    zero times in index.html. The other two guard rules that are still live but whose mutation
    anchors no longer match the source. A suite that is red only teaches reviewers to ignore it, so
    the re-anchoring is a separate change.
  • The one-shot patch scripts are excluded and marked do-not-run. They write straight back to
    index.html / server.mjs / the READMEs with no dry-run and no backup. Running that directory
    unattended during development silently rewrote three files, which is why every one of them now
    carries an ONE-SHOT PATCH, ALREADY APPLIED header saying what it changed and which file it
    touches.

Thanks again for the three rounds — the six fixes in #15 are the reason those guards exist at all.

MyPrototypeWhat pushed a commit that referenced this pull request Oct 14, 2026
…e machine (#16)

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

The PR #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/.

* test(mmc-trajectory): make the session-store guards machine-independent

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.

* test(mmc-trajectory): guard the rest, with no service and no machine 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.

* Remove the commit-message scratch file that git add swept in

* Give 异常 its own axis, and make its counts mean what they say

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.

* Make 重置 a reset, and put the row above back when a rule is ticked

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 在文档里分阶段列出。

* Stage 1 冻结 spec:先写清四问与不变量,再动结构

按 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。

* Stage 1 spec 修订:跨家族查漏的 15 条结论,改了 6 条断言、加了 3 条后果

用 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 仍未冻结,需再查漏一轮。

* Make the inspector follow the table, and own the re-measure at one exit

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.

* Stop rebuilding the whole lineage on every poll

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.

* Build the ledger in batches, and stop the inspector widening the window

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 #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.

* Record the eight undone items, and one measured option that was rolled 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.

* docs(mmc-trajectory): 归因滚动跳转为既有缺陷 D14,解除 content-visibility 路的阻塞

上一轮回退逐组推导 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 节未做清单:#1 的阻塞标记为已解除,新增 #1b 记录 D14。

代码与测试逐字节未动(302,384 bytes),32/32 守卫全绿。

* fix(mmc-trajectory): 滚动意图跨建行转交,点行不再把读者留在第 1 轮(D14)

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。

* perf(mmc-trajectory): 逐轮组推导 contain-intrinsic-size,「显示全部」建行 3,437 → 817 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 字面量新增字段而失效的变异。

* test(mmc-trajectory): 声明 R6 的两处跨层配对,Stage 3 完成(未做清单 #5)

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(异常轴建模,#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=,#2)调研结论改变了收益估计:payload 只有 session /
  stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以
  ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。
  这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全,
  写侧只有 1 处。

产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。

* refactor(mmc-trajectory): 异常轴收成一个字段 state.anomaly {mode, rules, last}(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。

* refactor(mmc-trajectory): 可见性收成一个入口 recordVisible(record, turn, selection)(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,不会
告诉你它什么也没测。

* refactor(mmc-trajectory): 删掉 D2 那三句互相矛盾的设计声明,并补上它自己的变异控制(Stage 2-c 注释半)

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。

* feat(mmc-trajectory): 增量协议 ?since=,会话增长时只传尾部(Stage 4)

本轮唯一一处真口径变更: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,守卫直接读属性。…
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