Skip to content

backend: the permissive runtime boundary -- convert with PyMuPDF absent - #3

Closed
ebt55 wants to merge 1 commit into
claude/exactdoc-pr1-gatefrom
claude/exactdoc-pr2-backend-seam
Closed

backend: the permissive runtime boundary -- convert with PyMuPDF absent#3
ebt55 wants to merge 1 commit into
claude/exactdoc-pr1-gatefrom
claude/exactdoc-pr2-backend-seam

Conversation

@ebt55

@ebt55 ebt55 commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Draft — review work, not ready to merge. Stack 2 of 3, based on PR 1.

Makes every code path able to run without PyMuPDF. This is what the phrase
"mechanical licence flip" was hiding.

Why

The roadmap described the flip as a dependency change and a default. The built
wheel disagreed: fitz was on the default runtime path in five stages past the
parser
, so a wheel installed without PyMuPDF failed while importing the writer,
before any backend selection could happen.

Site Was Now
docxout module scope import fitz gone
docxout table fitting MuPDF base-14 shaping Para.src_widths — the width infer already recorded
docxout figures MuPDF pixmap of the clip Backend.render_clip
refine measurement get_text("dict") both sides Backend.page_lines
verify rasterisation MuPDF pixmap samples Backend.render_page
ladder MuPDF base-14 shaping the TextMetrics seam

Proof

tests/test_no_pymupdf.py does not trust the code to avoid an import — it installs
a sys.meta_path finder that makes fitz unimportable, evicts anything already
loaded, then converts a fixture per capability (text-only, tables, inline image,
vector figure clip, multi-page with refinement, multi-column, cover band, Google
Docs static profile) and runs the closed loop through the permissive path. That is
stricter than a clean virtualenv, which cannot catch an import some other module
already performed.

This does not mean the shipped artifact is permissive. pymupdf is still
PRODUCT.backend and still a hard runtime dependency. What changed is that the
flip is now a dependency-and-default change rather than a rewrite.

Cost

The writer's half cost nothing: both lanes re-measured, and not one of 224
values moved (2 lanes × 16 documents × 7 gated metrics, compared exactly, not
within tolerance).

The refine half cost two documents, and the gate from PR 1 is what said so
within-2pt 0.46 → 0.31 and 0.60 → 0.32. Cause is D2 in a second location: the loop
subtracts a source y from a rendered y over two differently-typeset documents, and
a line-box top carries a per-font metric convention that does not cancel.

Anchoring on baselines is the obvious fix and is measurably wrong: it costs the
incumbent mean within-2pt 0.511 → 0.478, because the space_before chain the
offsets feed is calibrated on box tops. Same outcome as the line-box escalation
already closed out in D2. refine.ANCHOR keeps the switch and the number beside it.

Also here

  • PDFium native handles were never closed — a parity run listed 16 documents, 18
    pages and 9 text pages still open at exit.
  • Every LibreOffice invocation shared one fixed temp profile, machine-wide, so two
    concurrent conversions reproduced the exact failure the profile existed to
    prevent.
  • The writer's target mode was a module global, so concurrent conversions with
    different targets could observe each other's line-height encoding.

GitHub Actions has not yet run these commits.

🤖 Generated with Claude Code

The roadmap called the licence flip mechanical: a dependency change and a default.
The built wheel disagreed. `fitz` was on the default runtime path in five stages
*past* the parser, so a wheel installed without PyMuPDF failed while importing the
writer, before any backend selection could happen.

  docxout module scope   `import fitz`                    -> gone
  docxout table fitting  MuPDF base-14 shaping            -> Para.src_widths
  docxout figures        MuPDF pixmap of the clip         -> Backend.render_clip
  refine measurement     get_text("dict") on both sides   -> Backend.page_lines
  verify rasterisation   MuPDF pixmap samples             -> Backend.render_page
  ladder                 MuPDF base-14 shaping            -> metrics.TextMetrics

tests/test_no_pymupdf.py is the proof, and it does not trust the code to avoid an
import: it installs a sys.meta_path finder that makes `fitz` *unimportable*, evicts
anything already loaded, and then converts a fixture per capability -- text-only,
tables, inline image, vector figure clip, multi-page with refinement, multi-column,
cover band, gdocs static profile -- and runs the closed loop through the permissive
path. That is stricter than a clean virtualenv, which cannot catch an import some
other module has already performed. All pass.

The writer's half cost nothing: both lanes re-measured, and not one of 224 values
moved (2 lanes x 16 documents x 7 gated metrics, compared exactly, not within
tolerance). Replacing base-14 shaping with the source's own recorded line widths is
not an approximation of the old answer -- for "is this column too narrow for
content that occupied one line in the source", it is a fact where there was a
prediction.

The refine half cost two documents, and the gate written last commit is what said
so: within-2pt 0.46 -> 0.31 on 03_tech_report_code and 0.60 -> 0.32 on
r1_reportlab_report. Cause is D2 in a second location. The loop measures
rendered_box_top - source_box_top over two differently-typeset documents; on the
core-14 source PDFium substitutes a generic ascent (0.905x size) for the real one
MuPDF reads from its base-14 table (1.075x), while on the Liberation render both
read the embedded metrics and agree. The subtraction keeps a ~0.17 x size bias,
about 1.7pt at 10pt, against a 2pt threshold.

Anchoring the loop on baselines cancels that perfectly and is measurably WRONG: the
incumbent's mean within-2pt goes 0.511 -> 0.478. It fixes 04_exec_brief
(0.22 -> 0.44) and breaks 05_memo (0.64 -> 0.48) and r1_reportlab_report
(0.60 -> 0.32) -- the same outcome as the line-box escalation already closed out in
D2, because the space_before chain the offsets feed is itself calibrated on box
tops. `refine.ANCHOR` keeps the switch and the measurement beside it so nobody
rediscovers this a third time.

So the two documents are ratified into parity_policy.json under D2, with floors,
joining the two already there. All four are core-14; every document that embeds its
fonts is untouched, which is the shape of the cause. **The accepted set grew from
two to four because the comparison got more honest, not because the backend got
worse**: until now refine.py read its measurement through PyMuPDF whichever backend
had parsed, so the candidate lane was pdfium parsing with MuPDF measuring -- a
configuration nobody could install. Parity is 0 regressions, 10 same, 2 expected
divergences, 4 accepted, and the end-to-end pdfium mean within-2pt is 0.4431 where
the hybrid reported 0.461. The honest number is the lower one.

Also here, both found by their own noise rather than by review:

  * PDFium native handles were never closed, in `parse_pdfium.parse_pdf` and in
    all three render paths. A parity run ended with pypdfium2 printing "The
    following objects are still open and will now be closed" for 16 documents, 18
    pages and 9 text pages. Interpreter exit collected them, which is not a
    resource policy -- a worker converting a queue would hold a native document per
    job until it died. Documents, pages and text pages now close in reverse order
    of acquisition, and the warning is gone across 32 parses. Golden IR still 7/7,
    so nothing was being read lazily off a handle that is now closed.
  * Every LibreOffice invocation shared one profile -- a fixed temp path, for every
    conversion in every process on the machine. Two concurrent conversions then
    contend for it and one exits 0 with no output, which is the exact failure that
    motivated using a dedicated profile at all. Default is now per-process.
  * evidence.merge skipped nothing, so the final `evidence.py --out` step passed the
    empty template's `parity: None` over the verdict just recorded, and a fully
    green run ended with an artifact that had forgotten its own parity result. None
    sections are now skipped, with a test.
  * The writer's target mode was a module global that write_docx set and restored,
    so two concurrent conversions with different targets could each observe the
    other's line-height encoding. It travels in a frozen WriteCtx now.
  * Changing the API default from 0 refine rounds to the product profile silently
    gave edge_cases.py and exp_sweep.py three rounds and an oracle dependency they
    did not want. Both now name RAW explicitly.

Superscript stays hardcoded False in the pdfium backend on purpose, and that is
measured rather than deferred: backend_superscript.py shows the writer never sees
the parser's flag.

What remains for the relicence is genuinely mechanical -- pypdfium2 as the runtime
dependency, pymupdf to `[mupdf]`, goldens re-frozen from pdfium, every gate number
re-recorded because the default parser changes -- plus one thing that is not an
engineering decision and is deliberately left alone here: a licensing review of the
Apache-2.0 distribution and the `[mupdf]` extra's wording.

Full CI-equivalent sequence green on the canonical Linux environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ebt55

ebt55 commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #4.

PR #4 has been retargeted onto claude/exactdoc-execution-plan-42d13a (PR #1's
branch) and now carries this work as commit 2 of 7, 5ee2651, unchanged and with
its SHA intact. Reviewing #4 reviews this.

Closing as a draft slice that has been folded into the combined
production-foundations PR. The branch claude/exactdoc-pr2-backend-seam is
kept
— no force-push, no deletion, no rewritten SHA. The pre-transition graph
and the seven-commit verification are recorded in
docs/evidence/pr-transition.md.

@ebt55 ebt55 closed this Jul 30, 2026
@ebt55
ebt55 deleted the claude/exactdoc-pr2-backend-seam branch August 1, 2026 21:47
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.

1 participant