カテゴリー: プログラミング

  • Rebuilding Microsoft Word’s Layout Engine in Rust+WASM: The Oxi Project at SSIM 0.89 After Six Weeks

    TL;DR

    Oxi is a Rust + WebAssembly OSS suite that parses, renders, and edits .docx / .xlsx / .pptx / PDF entirely in the browser — no server, no proprietary fonts at runtime, no DLL disassembly. The premise is simple: “LibreOffice breaks Word layouts, Google Docs requires a server, there is an empty seat between them.” Six weeks into the empirical Word-compatibility loop, the engine sits at SSIM mean = 0.8932 over 235 real-world .docx documents (410 pages). This post is the engineering postmortem of how we got here — including the methodology axioms that were empirically falsified along the way.

    • Clean-room Word compatibility via COM API black-box measurement (no disassembly)
    • Why the “single SSIM gate” methodology broke and got redesigned into phase-based gates
    • The GDI integer-rounding cascade: a 0.18pt/char drift becomes 10.8pt over 60 characters
    • Founding axioms that survived vs. those that were falsified (R30 measurement bug, R33 41-page regression)

    Why “Word in the browser”

    As of 2026, EU public-sector de-Microsoft-365 has moved from aspiration to policy. The French DINUM directive (2026-04-08) mandates that 2.5M public-sector PCs migrate to free-software stacks by 2027. The Swiss Federal Chancellery officially announced phased M365 reduction on 2026-04-18. Germany’s ZenDiS OpenDesk is already in production at Schleswig-Holstein, Thüringen, Baden-Württemberg, and — after US sanctions blocked Microsoft access — the International Criminal Court.

    Every one of these transitions is missing the same piece: a rendering engine that opens existing .docx files indistinguishably from Microsoft Word. Without that, “migration” stops being “switch the app” and becomes “audit every document for visual divergence” — which is what no organization can staff. LibreOffice’s 20-year struggle in public-sector rollouts isn’t a feature problem; it’s a per-document visual-divergence problem that forces every site into a per-file audit.

    Oxi’s goal is not “a better migration tool.” It’s the dissolution of the migration problem, via a mechanical convergence loop toward SSIM = 1.0 against Microsoft Word. Internally we call this loop Ra.

    Architecture — why Rust + WASM

    crates/
      oxi-common/      Shared OOXML utilities (ZIP, XML, relationships)
      oxidocs-core/    .docx engine — parser, IR, layout, font metrics, editor
      oxicells-core/   .xlsx engine — parser, IR, editor
      oxislides-core/  .pptx engine — parser, IR, editor
      oxipdf-core/     PDF 1.7 engine — parser, text extraction, generator
      oxihanko/        Japanese digital stamp (hanko) generator + PAdES signer
      oxi-wasm/        WebAssembly bindings (wasm-bindgen)
    web/               Web demo (vanilla JS + Canvas)

    The IR (Intermediate Representation) is deliberately format-agnostic:

    Document → Page → Block (Paragraph | Table | Image) → Run

    LibreOffice treats ODF as native and OOXML as an import, so OOXML degrades on round-trip. Word does the inverse to ODF. Oxi was built from day one so that neither format owns the IR — a prerequisite for the v2 dual-format goal (.docx + .odt as equally first-class citizens).

    Why WASM, practically:

    • Zero server cost — all processing runs client-side. Viable without a SaaS business model
    • Privacy by construction — documents never leave the device. Essential for legal / medical / government
    • Binary size — the whole suite is ~1.4 MB compiled .wasm
    • Memory safety — Word documents can be adversarial inputs (zip-bombs in public-sector .xlsx are a thing). Rust’s safety is a practical, not theoretical, benefit

    “100% clean-room” — building compatibility without reading the DLL

    Every layout specification in Oxi is derived from exactly two sources:

    • Published standards — OOXML (ISO/IEC 29500 / ECMA-376), PDF (ISO 32000)
    • Black-box measurement — observed via the Microsoft Office COM API

    A typical measurement script looks like this (Word VBA or pywin32):

    y1 = doc.Paragraphs(1).Range.Information(6)  # wdVerticalPositionRelativeToPage
    y2 = doc.Paragraphs(2).Range.Information(6)
    gap = y2 - y1  # = line_height + spacing (in twips)

    The critical rule: never use Format.LineSpacing. It returns the configured value, not the rendered line height. Word composes the actual line height from font metrics, docGrid settings, table-cell context, etc., and the result rarely equals the configured setting. The right answer is always subtract two rendered positions.

    Microsoft’s Open Specification Promise covers OOXML implementations against patent assertion. Observing only the COM outputs (never the DLL) keeps the project legally and technically clean-room.

    Findings I actually enjoyed

    1. GDI’s integer-pixel rounding accumulates to 10.8pt over 60 characters

    Word’s text engine is built on GDI — a 30-year-old API that rounds advance widths to integer pixels and computes line height by rounding ascent and descent separately before summing. At Calibri 11pt, a 0.18pt-per-character drift accumulates to 10.8pt over 60 characters. That’s enough to change where lines wrap and where pages break.

    The implication: a Word-compatible renderer cannot use modern floating-point metrics. It must reproduce GDI’s integer rounding bug-for-bug. Oxi uses a dual font engine — GDI for .docx, DirectWrite for .odt and PDF export — behind a shared FontEngine trait:

    FontEngine trait
    ├── GdiEngine     — Word-compatible (integer px rounding)
    └── DWriteEngine  — Cross-platform (floating-point precision)

    2. Word rounds character widths to 10 twips (0.5pt)

    Not in the OOXML spec, but COM-confirmed across 13 font/size combinations and 181 characters: Word rounds the computed advance to 10 twips (0.5pt):

    width_twips = round(advance × fontSize × 20 / UPM)
    width_10tw  = round(width_twips / 10) × 10

    Implementing this triggered a cascade of improvements (mid-paragraph page break: +0.041 SSIM; table-cell line_height reset: +0.66) that took the average from 0.7884 to 0.8584 in three weeks.

    3. Multiple line spacing accumulates with CEIL, not ROUND

    Also undocumented. MS Mincho 10.5pt × 1.15 line spacing nominally computes to 310.5 twips, but Word actually uses 320 twips (16.0pt) — ceiling, not round. And the cumulative position math inherits CEIL through all subsequent lines. COM-confirmed across 8 of 9 measured positions before we shipped the fix.

    4. is_fullwidth is broader than OOXML says

    Pulling Unicode East_Asian_Width=F/W isn’t enough — Word treats 7 additional blocks (Arrows, Math Operators, Letterlike Symbols, etc.) as fullwidth. Without this fix, “→” gets Western advance width and visually overlaps the adjacent CJK glyph.

    5. Information(6) does not return paragraph-start Y

    This one was a measurement-side bug and is the core of the R30 incident below. doc.Paragraphs(N).Range.Information(6) returns the active-end position. For paragraphs that span pages, you get “Y on the next page” and the per-paragraph page index is silently off by one. The fix is to collapse the range to zero width first:

    y = doc.Range(rng.Start, rng.Start).Information(6)

    Founding axioms that got falsified

    The Ra loop was originally built on a strong “No Excuses by Design” premise:

    • No layout difference has a valid excuse
    • Every value is measurable via COM API
    • The spec space is finite; measurement results are permanent assets
    • Fixing one spec gap improves multiple documents simultaneously (convergent structure)
    • Not “cannot do,” only “not yet done”

    Sessions 38-45 (about a month of empirical pressure) falsified three of these.

    R21 plateau — the bottom-5 gate stops moving

    The initial merge gate required the bottom-5 documents’ SSIM sum to strictly increase. Reasonable on its face, but the bottom-5 each carried different structural problems (table charGrid, in-textbox wrap, vertical writing, …), each of which needed a multi-week refactor before any single document moved. The gate locked, and no PRs could land.

    R30 — the measurement API itself had a bug

    The Information(6) issue above. The axiom “every value is measurable via COM” assumed the API returns what you think it returns. It does not — for multi-page paragraphs it returns the active-end position. Months of measurement data carried a silent one-page drift. The corollary: “measurement results are permanent assets” is wrong; measurement methodology has to be re-validated periodically, not just the values.

    R33 — a “minimal-case spec-correct” fix regressed 41 pages

    A spec was derived from a minimal repro, the implementation matched the repro exactly, and the production baseline got worse by 41 pages. The cause: the spec was derived in a single context (a standalone paragraph), but Word actually composes the rendering from font-cascade × szCs × per-context wrap rules. What’s correct in the minimal repro can be wrong in other contexts.

    The rule that survived: no EXCEPTION stacking. If a “confirmed” spec needs per-document, per-font, or per-context carve-outs, the spec isn’t incomplete — it’s wrong. Re-derive from a richer input space rather than stacking exceptions, because every exception is a future R33 in the making.

    Redesign: phase-based gate

    On 2026-04-28 we redesigned the methodology. The core admission: SSIM = 1.0 is an outcome, not a signal. Gating on the outcome directly produces R21-style plateaus, because outcomes don’t decompose into per-PR work. Instead, gate on cause-attributable signals staged by phase:

    PhasePrimary gateWhy this signal
    1 (current)Pagination correctness — does Word’s paragraph N land on Oxi’s page N?One page-break bug can cascade into 47 low-SSIM pages. Fixing the root needs a gate that won’t punish you for it
    2Element IoU mean ≥ 0.99 — bbox IoU per text block / image / cellPosition accuracy (not pixel). The dominant structural error left after pagination is correct
    3SSIM mean ≥ 0.99 + bottom-N floorOnce positions match, residual pixel diff = font hinting / sub-pixel rendering

    SSIM is tracked at every phase but is the gate only at Phase 3. During Phase 1 it acts as a regression sentinel (any drop > 0.005 requires review). This unblocks fixes whose payoff is “47 low-SSIM pages all shift simultaneously after the root page-break bug is fixed” — exactly the shape of fix the old gate refused.

    Five weeks after the redesign:

    • Phase 1 pass rate: 25/55 → 46/55 (83.6%)
    • SSIM mean: 0.8699 → 0.8932 (+0.0233)
    • Individual PRs are merge-eligible when pagination or IoU moves — SSIM doesn’t have to budge

    In this window, root-cause fixes that were unmergeable under the old gate landed as one- or two-line changes: vMerge cell height exclusion, fixed table column width preservation, widow control inheritance, grid-snapped lines extending into the bottom margin.

    The verify pipeline (stale-binary incidents)

    We made the same mistake on 2026-04-26 and again on 2026-05-07. Oxi has three render paths:

    • WASM (crates/oxi-wasm/pkg/) — used by the browser editor
    • Native GDI renderer (tools/oxi-gdi-renderer/) — used by the pagination measurer (Phase 1 gate)
    • Native DWrite renderer (tools/oxi-dwrite-renderer/) — the default for pipeline.verify

    Running cargo build in the gdi-renderer directory does not rebuild the dwrite-renderer crate. We forgot this, ran verify against a stale DWrite binary, and got a false-positive “0 improved / 0 regressed” report — because pre-patch baseline was being compared to pre-patch output. The actual delta turned out to be -0.0911 and we had to revert.

    The hygiene is now hard-coded in CLAUDE.md:

    • Rebuild both native renderers before every verify, and delete oxi_png/ + pagination_oxi/ caches for affected docs
    • WASM rebuild alone is insufficient — pipeline.verify does not use WASM
    • “0 improved, 0 regressed” is a red flag. Stale binaries return identical numbers, so 0 deltas are a stale-output signature

    Methodology bugs are nastier than spec bugs: if undetected for a few days, they invalidate the entire measurement corpus accumulated during that window. The only working defense is to write project-specific hygiene rules somewhere enforcement can actually reach them — for an AI-driven loop, that’s the CLAUDE.md the agent reads on every session start.

    Where this sits in the landscape

    SolutionApproachLimitation
    LibreOffice / CollaboraC++ server-side renderingBreaks Word layouts. Requires server. No pixel-fidelity goal
    ZetaOfficeLibreOffice compiled to WASM100MB+ download. Accuracy = LibreOffice. A port, not a rewrite
    ONLYOFFICEJS canvas renderingClosest architecture, but AGPL and no COM-measured Word compat
    Apryse (PDFTron)C++ → WASMProprietary. Converts to internal format — not native OOXML render
    docMentisRust+WASM viewerWASM engine proprietary. Telemetry on by default
    Google DocsServer-renderedProprietary. Requires server. Intentionally diverges from Word
    docx-rs / rdocxRust DOCX librariesR/W only. No browser layout engine

    Oxi’s intersection is “OSS (MIT) + Rust/WASM client-side + dual-format first-class + COM-measured pixel-perfect + zero server cost.” No other project occupies this seat.

    Current numbers and roadmap

    • Parse success: 100% across 504 documents (Japanese government .docx/.xlsx/.pptx + generated). LibreOffice: 99.2% (4 large .xlsx files timed out > 45s)
    • SSIM mean: 0.8932 (235 docs, 410 pages, GDI baseline)
    • Phase 1 pass rate: 46/55 (83.6%)
    • .wasm size: ~1.4 MB for the full suite

    Next milestones:

    • v1.x — .docx SSIM 0.95+, IME (Japanese/CJK input), .xlsx/.pptx layout engines, vertical writing + ruby
    • v2 — .odt parity. The Ra loop transfers to ODF; the reference renderer changes (LibreOffice headless) but the methodology does not
    • v2.x — oxi-hyde (TPM 2.0 + ML-KEM outer envelope). .docx.hyde / .odt.hyde are PGP-encrypted-PDF style: encryption is an outer wrapper, decryption restores a plain .docx / .odt openable in any client

    Try it

    • Live demo: https://ryujiyasu.gitlab.io/oxi/
    • License: MIT
    • Contributing: every merged PR must improve pixel accuracy of at least one document. That’s the entire acceptance criterion

    The methodologically interesting part — and a topic for another post — is that the Ra loop runs autonomously with an AI agent (Claude) in the inner loop: root-cause analysis, COM measurement script generation, fix implementation, verification. The human role is phase-gate review and direction. The falsified axioms above (spec space open-ended, single-context derivation is a trap) are not just layout-engine lessons; they are also a record of where human review must remain when running an AI-driven engineering loop on a hard convergence problem.

    If you work on EU public-sector migration, ODF parity, or hardware-anchored document encryption — I’d particularly like to hear from you.

  • ブラウザで Microsoft Word を pixel 単位再現する Oxi プロジェクト — 方法論の罠と SSIM 0.89 までの記録

    要約

    Oxi は、Microsoft Word / Excel / PowerPoint / PDF をブラウザ上で サーバーレスに パース・レンダリング・編集する OSS スイートを Rust + WebAssembly で書いている個人プロジェクトだ。「LibreOffice はレイアウトを壊す、Google Docs はサーバーが要る、その間に空席がある」という素直な問題意識から始めて、現在は実 .docx 235 ドキュメント・410 ページに対し Word との pixel-level SSIM 平均 0.8932 まで到達している。本記事は、そこに至るまでに踏んだ方法論上の罠と、技術的に面白かった発見をエンジニア向けに記録するためのものだ。

    • DLL 逆アセンブルなしで Word 互換を作るとはどういうことか (clean-room + COM API 黒箱測定)
    • 「SSIM 単一ゲート」が破綻し Phase-based 方法論に再設計した経緯
    • GDI の整数ピクセル丸めが 60 文字で 10.8pt 累積する、という古典的な罠
    • “全て測れる” というプロジェクト初期の axiom が経験的に falsify された (R30, R33 事件)

    なぜ “ブラウザで Word” なのか

    2026 年現在、EU 公共セクターの脱 Microsoft 365 が 制度として 動き出している。フランス DINUM 指令 (2026-04-08) は 250 万台の公共 PC を 2027 年までに自由ソフトウェアスタックに移行することを義務化、スイス連邦官房は 2026-04-18 に Microsoft 365 依存削減を公式表明、ドイツの ZenDiS OpenDesk は Schleswig-Holstein / Thüringen / Baden-Württemberg、そして米国制裁で MS アクセスを止められた国際刑事裁判所で既に本番稼働している。

    これら全ての移行に共通する 欠けているピース は同じだ:「既存の .docx を Word と 見分けがつかないように 開けるレンダリングエンジン」。これが無いと、移行は「アプリ切り替え」ではなく「全文書の見え方監査」になり、組織は人員を充てられない。LibreOffice が 20 年公共セクター展開で苦戦している理由は、機能が弱いからではなく、per-document の視覚差分がプロジェクト化を強制する からだ。

    Oxi のゴールは「より良い移行ツール」ではなく 移行問題そのものの溶解 で、その手段が SSIM = 1.0 への機械的収束ループ (内部では Ra と呼んでいる) だ。

    アーキテクチャ — なぜ Rust + WASM か

    crates/
      oxi-common/      Shared OOXML utilities (ZIP, XML, relationships)
      oxidocs-core/    .docx engine — parser, IR, layout, font metrics, editor
      oxicells-core/   .xlsx engine — parser, IR, editor
      oxislides-core/  .pptx engine — parser, IR, editor
      oxipdf-core/     PDF 1.7 engine — parser, text extraction, generator
      oxihanko/        Japanese digital stamp (hanko) generator + PAdES signer
      oxi-wasm/        WebAssembly bindings (wasm-bindgen)
    web/               Web demo (vanilla JS + Canvas)

    IR (Intermediate Representation) は意図的に format-agnostic に設計してある:

    Document → Page → Block (Paragraph | Table | Image) → Run

    LibreOffice は ODF をネイティブ、OOXML を import として扱うから round-trip で OOXML が壊れる。Word は逆の理由で ODF を壊す。Oxi は最初から「どちらの format も IR を所有しない」設計にしてあり、これは v2 で計画している .odt 並列対応の前提でもある。

    WASM を選んだ実利的な理由:

    • サーバー費用ゼロ: 全処理が client-side。SaaS でなく成立する
    • プライバシー: 文書がデバイスから出ない。法務 / 医療 / 政府文書で本質的
    • バイナリサイズ: Suite 全体で約 1.4 MB の .wasm
    • メモリ安全: Word ドキュメントは敵対的入力になりうる (公官庁の zip-bomb 級 .xlsx 等)。Rust の safety は実利

    “100% Clean-Room” — DLL を読まずに互換を作る方法

    Oxi の仕様は 2 つのソースだけ から導出している:

    • 公開標準 — OOXML (ISO/IEC 29500 / ECMA-376), PDF (ISO 32000)
    • 黒箱測定 — Microsoft Office COM API で出力値を直接観測

    典型的な測定スクリプトはこんな形 (Word VBA / pywin32 で COM 経由):

    y1 = doc.Paragraphs(1).Range.Information(6)  # wdVerticalPositionRelativeToPage
    y2 = doc.Paragraphs(2).Range.Information(6)
    gap = y2 - y1  # = line_height + spacing (twips 単位で)

    大事なのは 「Format.LineSpacing は使うな」 という点。これは「設定値」を返すだけで、Word が実際にレンダリングした line height ではない。Word はフォントメトリクス・docGrid・table cell 文脈などを合成した結果として「実 line height」を決めており、設定値とは一致しない。実描画位置の差分 から逆算するのが正しい方法だ。

    Microsoft の Open Specification Promise により OOXML 実装に対する特許不行使が宣言されている。DLL を触らずに COM の出力だけを見るアプローチは、法的にも技術的にも clean-room を維持できる。

    面白かった技術的発見

    1. GDI の整数ピクセル丸めは 60 文字で 10.8pt 累積する

    Word のテキストエンジンは GDI ベースで、文字幅を整数ピクセルに丸め、line height は ascent と descent を 別々に丸めてから加算する。Calibri 11pt で 1 文字あたり 0.18pt の差が、60 文字で 10.8pt の累積誤差になる。これは「行が折り返す位置」「ページが切れる位置」を変える程度の大きさだ。

    つまり Word 互換レンダラを作るには、現代的な float メトリクスで真面目に組版した結果を返すのは 不正解 で、GDI と同じ整数丸めをそのまま再現する必要がある。Oxi は .docx 用に GDI engine、.odt と PDF 出力用に DirectWrite engine の dual font engine を持ち、同じ FontEngine trait で差し替えている。

    FontEngine trait
    ├── GdiEngine     — Word-compatible (integer px rounding)
    └── DWriteEngine  — Cross-platform (floating-point precision)

    2. Word は文字幅を 10 twips (0.5pt) 単位に丸める

    OOXML 仕様には書いていないが、COM 測定で確認した経験則: Word は文字幅を計算したあと 10 twips (= 0.5pt) 単位に丸めている。13 のフォント / サイズ組合せ・181 文字で確認済み。式は:

    width_twips = round(advance × fontSize × 20 / UPM)
    width_10tw  = round(width_twips / 10) × 10

    これを実装した瞬間に net +0.041 SSIM (mid-paragraph page break) や net +0.66 (table cell line_height reset) のような 段差的改善 が連鎖して、3 週間で 0.7884 → 0.8584 に上がった。

    3. 複数行間隔は CEIL で累積する

    これも仕様には無い。MS Mincho 10.5pt × 1.15 倍を line spacing に指定したとき、計算上は 310.5 twips になるが Word は 切り上げで 320 twips (16.0pt) として扱う。ROUND ではなく CEIL。さらに複数行にわたって累積する位置計算も CEIL を継承する。これも 8/9 位置で COM 確認した上で実装した。

    4. is_fullwidth は OOXML が言うより広い

    Unicode の East_Asian_Width=F/W だけ拾うと不足で、Word は実際には Arrows / Math Operators / Letterlike Symbols 等 7 つの追加ブロックを fullwidth 扱いする。これを直さないと「→」が西欧字幅で扱われて隣の CJK 文字に 重なる という症状が出る。Oxi は判定テーブルに 7 ブロックを追加して直した。

    5. Information(6) は段落開始 Y を返さない

    これは方法論側の罠で、後述の R30 事件のコア。doc.Paragraphs(N).Range.Information(6) は段落の active end 位置 を返す。複数ページにまたがる段落だと「次のページの Y」が返ってきて、per-paragraph page index が 1 ページずれる。正解は doc.Range(rng.Start, rng.Start).Information(N) で範囲を 0 幅に collapse してから問い合わせること。

    プロジェクト初期の axiom が経験で falsify された話

    Oxi の Ra loop は当初「No Excuses by Design」という強い前提で設計されていた:

    • レイアウト差分に正当な excuse は存在しない
    • 全ての値は COM API で測定できる
    • 仕様空間は有限、測定結果は永続資産になる
    • 1 つの仕様修正は複数文書を同時に改善する (収束的構造)
    • 「できない」のではなく「まだやっていないだけ」

    これは Session 38-45 (約 1 ヶ月) で 3 つが経験的に偽だと判った

    R21 plateau — bottom-5 ゲートが動かなくなる

    初期の merge gate は「文書ごとの SSIM 下位 5 文書の合計が単調増加すること」だった。これは合理的に見えるが、bottom-5 の各文書がそれぞれ別の構造的問題 (table charGrid、textbox 内 wrap、vertical writing、…) を抱えていて、マルチ週の refactor が無いと 1 文書も動かない。結果として gate がブロックし、PR が積めない期間が続いた。

    R30 — 測定 API そのものにバグがあった

    前述の Information(6) 問題。COM API は「全て測れる」前提だったが、API そのものが multi-page 段落に対して active end を返す挙動だったため、月単位の測定データに 1 ページ ずれが混入していた。「測定は永続資産」も 測定方法ごと再検査される 必要があることが判った。

    R33 — 「minimal-case 仕様正しい修正」が 41 ページ regress

    仕様を minimal repro で確認して仕様通りに直したのに、本番 baseline で 41 ページが 劣化 した。原因は「仕様が単一 context (例: 単独段落) でしか derive されていなかった」こと。実際には Word は font-cascade × szCs × per-context wrap rule の 合成 でレンダリングしており、minimal-case の正解が他 context の不正解になる。

    この教訓から、Oxi の Critical Rules に追加されたのが「EXCEPTION stacking 禁止」: 仕様に per-doc / per-font の carve-out が必要になったら、その仕様は 不完全 ではなく 間違っている ので、richer input space から re-derive する。例外を積むと R33 が再生産される。

    Phase-based gate への再設計

    2026-04-28 に方法論を再設計した。SSIM = 1.0 は 結果指標 (outcome) であって 信号 (signal) ではない、と認めた。結果指標を直接 gate にすると上記 R21 のように動けなくなる。代わりに cause-attributable な信号 を段階的に積む:

    PhasePrimary gateWhy this signal
    1 (現在)Pagination correctness — Word の段落 N が Oxi で同じ page N に乗るかページ割れ 1 個で 47 ページ低 SSIM になる cascade を root から切る
    2Element IoU mean ≥ 0.99 — 要素 bbox の IoU位置 (≠ pixel) の精度。pagination 後の唯一の構造誤差
    3SSIM mean ≥ 0.99 + bottom-N floor位置が合った後、pixel diff が残る = font hinting / sub-pixel

    SSIM は 全 phase で常に追跡 するが、gate になるのは Phase 3。Phase 1 中は SSIM の > 0.005 劣化 だけ regression sentinel として効かせる。これで「root の page-break バグを直す = 47 ページが同時に SSIM 動く」のような複合的改善が gate を通せるようになる。

    進捗を見ると、再設計後 5 週間で:

    • Phase 1 pass_rate: 開始時 25/55 → 46/55 (83.6%)
    • SSIM mean: 0.8699 → 0.8932 (+0.0233)
    • 個別 PR は「pagination が動いた」「IoU が増えた」で merge 可能、SSIM が必ずしも動かなくても良い

    この期間に Word の vMerge セル高さ計算・fixed table 幅保持・widow control 継承・grid-snapped 行が下マージンに食い込む挙動など、SSIM gate なら絶対に着手できなかった root cause を 1〜2 行で 直せた PR が連続している。

    verify pipeline で踏んだ罠 (stale binary 事件)

    2026-04-26 と 2026-05-07 に同じパターンの事故を起こした。Oxi は 3 つの render path を持っている:

    • WASM (crates/oxi-wasm/pkg/) — browser editor 用
    • Native GDI renderer (tools/oxi-gdi-renderer/) — Phase 1 gate (pagination 測定) 用
    • Native DWrite renderer (tools/oxi-dwrite-renderer/) — pipeline.verify の default

    cargo build を gdi-renderer dir で走らせても dwrite-renderer は 再ビルドされない。これに気付かず古い DWrite binary で verify を回し、pre-patch baseline と pre-patch output を比較して「0 改善 / 0 劣化」という偽陽性レポートを出した。実際の差分は -0.0911 で、後で revert する羽目になった。

    教訓は単純で、CLAUDE.md にハードコード:

    • verify の前は 両方の renderer を rebuild、cache (oxi_png/, pagination_oxi/) を 消す
    • WASM rebuild だけでは不十分 (verify pipeline は WASM を使わない)
    • “0 改善” は疑え。stale binary は同じ値を返すから 0 になる