病理報告 · 標本 #1

實測數字取自 docs/measurements/2026-07-26-reproducibility-4x-5.json (現行正典檔,docs/phase2-expected-results.md:1057-1062;下稱「本份 JSON」)。 檔內 records[...] 一律指本份 JSON 中 specimenId = "01-main-thread-block" 的那九筆; sweep.* 指其頂層欄位。其他任何數字都是登記值、已作廢值或另一 session 的值,逐處標明。

01 主執行緒阻塞(Main-thread Block)

病症一句話:在 17ms 絕對排程的十連發點擊下(機器節拍,非人手),click handler 同步排序 五萬筆訂單把主執行緒整段佔住,INP(n = 10,取 max)三輪 median 1028 / 896 / 796ms (4x throttle、本份 JSON);治療一(切 chunk + 讓出)把它壓到 24~32ms。

⚠️ 這句話刻意標了 session 與條件:同一支標本一個字沒改,同日上午另一 session 的 三輪 median 是 1472 / 1508 / 1672ms(docs/phase1-expected-results.md:400,另一 session, 不可與本份 JSON 的絕對值互比)。不標條件,這個數字就會被當成這個病的常數。 也刻意不寫「模擬使用者連打」:人手約 150ms 一下,大於單次排序成本,事件根本不排隊, 那是另一個實驗(src/specimens.ts:120-123specimens/01-main-thread-block.ts:26-28)。

凍結條件

同一組條件之間才能比較。任何一項改動,先前所有數字作廢。

CPU throttle 4x(宣告值),JS 偵測不到(src/protocol.ts:55-56)。兩半都做了:Emulation.setCPUThrottlingRate = 4 才是真的節流、外殼下拉只負責把 '4x' 寫進條件(tools/reproducibility.mjs:15-18, 29-30, 587-595)。本份 JSON:sweep.cpuThrottlingRate = 4、九筆 records[].cpuThrottle"4x"
驅動方式 CDP 機器驅動(Input.dispatchMouseEventsweep.driver),且為 realClickAbsolute() 絕對排程:第 k 發打在 t0 + k × I、不等 renderer 回應(tools/reproducibility.mjs:135-163SPECS[01].absoluteClick = true:275, 485-491
viewport 800 × 600(FROZEN_VIEWPORTsrc/specimens.ts:10, 135。CLS 與 LCP 都是 viewport 相對量)
refreshHz 60(實測,九筆 records[].refreshHz 皆 60;欄位定義 src/protocol.ts:68。掉幀門檻由它推導,不是寫死 16.7ms)
操作程序 click · 10 發 · 間隔 17ms 絕對排程src/specimens.ts:125-133)。名目跨距 (10−1)×17 = 153msspecimens/01-main-thread-block.ts:203-204);九筆實測 dispatchSpanMs 152.5~153.7ms(護欄,tools/reproducibility.mjs:659-667)—— 節拍真的交付了
buildId 0.1.0-ms191c71(九筆 records[].buildId 一致)
protocolVersion 本份 JSON 未記錄此欄位(頂層鍵只有 measuredAt / driver / cpuThrottle / cpuThrottlingRate / runsPerMode / specimensCovered / isFullSweep / records / problems / consoleErrors,records 內亦無)。凍結契約常數為 1src/protocol.ts:8)。依「未量測不寫成量到」的規矩,這格不填 1,填「JSON 未記錄」

這一格額外必須印出來的凍結條件:

動工前登記的預期

登記出處:docs/phase1-expected-results.md:82-138(標本 #1 段,2026-07-25 登記; 該段自我揭露「不是盲預測」,:84-86)。 修正出處:同檔 :267-390「修正紀錄 · 標本 #1 重新設計(2026-07-26)」。 這一節不准回頭改去迎合實測。 要修正就在登記檔的「修正紀錄」追加 —— 下面照登記照抄, 已被修正紀錄作廢的逐條標明。

實測

三輪,同一組凍結條件。離散度算在每輪的 median 上,不是 maxsrc/protocol.ts:309: 「抗離群。可重現性判定用這個,不用 max」)。每輪 median 直接引自 records[].stats.median, 相對離散度 =(三輪 median 之 max − min)÷ 三輪 median 的中位數。

mode 三輪 INP median(ms) 相對離散度 絕對全距 判定
broken 同步排序 1028 / 896 / 796 (1028−796)/896 = 25.9% 232ms ✅ 可重現(低於 30% 線,但只差 4 個百分點)
fixed-yield 切 chunk + 讓出 24 / 28 / 32 (32−24)/28 = 28.6% 8ms ✅ 可重現(絕對全距 8ms 在 INP 雜訊底線 16ms 內)
fixed-worker 丟 Web Worker 608 / 448 / 464 (608−448)/464 = 34.5% 160ms 不可重現(與 docs/phase2-expected-results.md:1071, 1137 的判定一致)

判準:相對離散度 ≤ 30%,絕對全距在該指標的雜訊底線內。 ⚠️ 只看相對離散度會把方向搞反:治療有效正是讓分母趨零,於是「治療越成功,越判它不可重現」。 底線取指標自己的量子(掉幀 5 幀 / INP 16ms=兩格 8ms 量化 / CLS 0.01 / LCP 50ms)。 fixed-yield 正是教科書案例:28.6% 逼近線,但三輪 median 全落在 24~32ms 的 8ms 網格上, 絕對全距只有一格量化。

主指標補充:登記主指標是 inp.presentationsrc/specimens.ts:116),但每輪只有 INP 代表樣本那一筆有分段拆解(一輪一個值,無 per-run median 可算),三輪值列在下節兇手歸因表; 本表依 phase1:398-402 同一格式報 INP median。

護欄全綠(九筆 records 逐筆檢查):

排隊的階梯(INP 結構上看不見,標本自報 inputLagMaxMs 顯形): broken 931.4 / 898.4 / 897.3ms、fixed-yield 6.1 / 6.5 / 6.5ms、 fixed-worker 536.3 / 383.0 / 395.4ms(records[].custom.inputLagMaxMs)。 登記斜率算式的端點檢核:(10−1) × (sortMs − 17) 以本份 JSON 的 broken sortMs (110.7 / 111.6 / 113.9ms)代入得 843.3 / 851.4 / 872.1ms,實測 lag 高它 3~10% —— 量級與趨勢相符;差值方向合理(算式的 S 只計排序,handler 還付 renderSummary 等成本)。 逐發驗證仍未做(只上報 max,見誠實揭露)。

兇手歸因

INP 代表樣本的分段拆解,取自 records[].inputDelay / processing / presentation(單位 ms):

broken fixed-yield fixed-worker
inputDelay 0.8 / 798.5 / 700.9 0.6 / 0.4 / 2.0 206.4 / 178.4 / 176.9
processing 135.9 / 113.3 / 116.5 7.9 / 7.9 / 2.1 94.9 / 55.7 / 57.6
presentation 951.3 / 120.2 / 238.6 23.5 / 23.7 / 27.9 386.7 / 293.9 / 309.5
LoAF forcedStyleAndLayout(僅標本 script) 無 entry ×3 無 entry ×3 無 entry ×3
sourceFunctionName (見下,欄位不可信) (同左) (同左)

三輪兇手段是否一致:

presentation 繼承 duration 的 8ms 量化,會落在 8ms 網格上。 對量級對照無影響,只在替兩個都已經很快的方案排名時咬人。 blockingDuration 是整幀的值,含外殼,規格上無法拆到單一 script; forcedStyleAndLayoutDuration 是逐 script 的,所以只有它能乾淨濾掉外殼貢獻。

LoAF 欄的說明:九筆 records[].forcedSamples 皆空陣列、forcedMedian 皆 null、 forcedPeak 皆 0 —— 本標本沒有強制版面,寫「無 entry」不寫 0。 九筆 loafPickedBy"specimenScriptDuration"=退回備援路徑選幀,所以 forcedFn(broken / fixed-worker 記 sortOrdersOnClick、fixed-yield 記空字串) 不構成任何強制版面證據;且該備援對「沒有強制版面的標本」有已知選幀缺陷 (docs/phase2-expected-results.md:547-551),specimenScript 欄(broken 1060.8 / 916.6 / 796.3ms 等)整欄不可信,本報告不引用它下任何結論。標本自己記過這條 trip-wire: 同一組條件三輪跑出 72.1 / 1340.9 / 1225.4(specimens/01-main-thread-block.ts:366-368)。

治療梯度

治療 INP median(三輪) 相對病變 代價
一:切 chunk + scheduler.yield(退路 MessageChannel) 24 / 28 / 32 ms 32.0×(896 ÷ 28,同一份 JSON 內的 median-of-three 相除) sortMs 變長:120.5 / 128.5 / 118.4ms vs broken 110.7 / 111.6 / 113.9ms(切段 + 合併的總 CPU 更多,R1 預期方向);「第一次點擊到看見第十份結果」被拉長 —— queueDrainMs 1336.3 / 1287.2 / 1304.5ms、peakQueueDepth 9 / 9 / 9;掉幀仍在(droppedFrames 53 / 51 / 52 vs broken 69 / 67 / 67)—— 工作沒有消失,只是拆開;scheduler.yield 非 Baseline(Safari 沒有,specimens/01-main-thread-block.ts:389),走到哪條退路本輪未上報
二:丟 Web Worker 608 / 448 / 464 ms 不作療效宣稱 —— 該臂本輪不可重現(34.5%)且背著對它有利的混淆變因(L1,本輪 threadSpeedRatio 3.0~3.1 坐實)。方向上仍低於病變(896 ÷ 464 = 1.9×,此數字僅作為「數值落空」的裁決證據引用,phase2:1137 序列化仍在主執行緒:workerSerializeMs 58.9 / 52.4 / 56.1ms 每發都付;佇列照樣堆(inputLagMaxMs 383~536ms —— 55ms 仍大於 17ms 節拍);workerBootMs 10.4 / 11.9 / 6.6ms;workerSortMs 32.2 / 31.9 / 32.0ms 不得當成 4x 條件下的結果發表(L4,ratio ≈ 3)

治療版落進雜訊底線時不報比值 —— 本輪 fixed-yield 的 24~32ms 高於 INP 底線 16ms, 32.0× 可報;fixed-worker 不在底線內,但被可重現性與混淆變因兩關擋下,一樣不報療效。

同口徑的誠實對照(L2 —— 治療二唯一能與病變直接相除的一對,皆在被節流的主執行緒上): workerSerializeMs median 56.1ms ÷ sortMs(broken) median 111.6ms = 50.3% —— 治療二把主執行緒的每發成本降到約一半(2.0×),不是降到 0。 (上午 session 同口徑是約 44%、2.3×,phase1:358-359,另一 session,僅供方向對照。)

兩段治療之間不排名(R2)。但 R2 的前提「兩者都會落進良好區間 < 200ms」本輪只有治療一 成立 —— 治療二沒進良好區間不是排名結果,是它未達自己的登記值(見下節)。 一個沒效到登記值的治療比三個有效的更值得寫:治療二的成本沒有消失, 它從「排序」換成「複製」,而複製的那一半還留在主執行緒上 (specimens/01-main-thread-block.ts:602-616 的設計說明)。

與登記的差異

逐條對照,三種結局分開寫:

三輪之間另一個不對稱值得記錄:broken 的輪內 spread(records[].stats.spread, 單輪十筆樣本的離散)從 run1 的 13.2% 逐輪升到 run3 的 42.2%,而 fixed-worker 三輪 輪內 spread 都在 73~76% —— 交界機制下代表樣本對時序敏感的直接痕跡, 與跨輪離散度(25.9% / 34.5%)是兩個不同的量,不可混讀。

誠實揭露

(一)這個標本沒有示範的東西

(二)已知會讓數字失真的因素

(三)換機器後必須重跑什麼才能沿用結論

  1. 先重跑校準錨點 A(00-calibration 按鈕 A:忙迴圈 300ms ⇒ processing 應為 300.x)。 不過這關,下面全部免談。
  2. 重新量 1x 的 S(單次同步排序成本)。綁住 17ms 節拍上界的是 1x 的 S ≈ 25ms, 不是 4x 的值(specimens/01-main-thread-block.ts:15-17phase1:319)。 1x 的 S 掉到 17ms 以下,節拍當場失效、事件不再排隊 —— 要改的是 ORDER_COUNT 或節拍,不是結論。
  3. 重新量 threadSpeedRatiocalibrationChecksumMatch 必須為 1)。它決定治療二的 數字能不能發表;換個 headless 設定就可能從 3 變成 1。
  4. 重新讀 refreshHz(掉幀門檻由它推導)。
  5. 確認 build 開著 keepNames(Vite 8 之後 output.keepNames + output.minify.mangle.keepNamesworker 是獨立 config 介面要另設一份CLAUDE.md)。
  6. 重跑 node tools/acceptance.mjs(13 條全綠),跑前先看 uptime
  7. 兩支工具寫死了不同瀏覽器路徑(tools/acceptance.mjs/usr/bin/brave-browsertools/reproducibility.mjs:23/opt/brave.com/brave/brave),換機器兩處都要改。
  8. 本標本特有:兇手欄是機器速度的函數(phase2:1078-1084)。換機器(或同機不同負載) 前先算 sortMs ÷ 17 —— 比值離 1 越近,兇手欄越會跳;上午 ≈ 8 時三輪一致、 本輪 ≈ 6.6 時已翻面。裁決前不要拿任何單一 session 的兇手欄當結論。

(四)本輪未做的事