Skip to content
This document describes intent at the time it was written. It is part of the design record, not user documentation, and may not reflect the current behaviour of the code.

013 - IR spike: pure-Python pipeline

Status: Research notes. Fourth spike, added after the recommendation in research note 012 to test whether the “pure-Python IR” option (named in note 006 but never spiked) could close the cost/fidelity gap with pandoc.

Date of run: 2026-05-12.

What this note is. A custom typed IR for Confluence storage XHTML, implemented end-to-end in Python with no third-party converter dependency. Same harness as notes 009 / 010 / 011, same 35-fixture corpus, same six-metric battery. The IR is the deliverable; markdown and storage XHTML are thin tail-ends.

What this note is not. A judgement on whether pure_python should replace pandoc as the recommendation in note 012 — that’s note 012’s job once it absorbs these numbers. This note’s contribution: honest measurement of a fourth candidate that didn’t make the original shortlist.

Note 012 recommended pandoc + Lua but the margin was tight in absolute terms — aggregate M1/M2/M5 only 0.08-0.14 above status_quo — while bringing real costs: 125× per-fixture latency, a 7 MB binary dependency, Lua as a second language, GPL licensing on the binary, and identity/provenance still un-wired.

Note 006 explicitly framed the design principle (“IR with identity per node, diff at the AST / token level”) but the three shortlisted candidates were all third-party readers. A pure-Python implementation of that principle was a credible fourth option worth measuring before locking the recommendation in.

  • Harness: scripts/ir_experiment/ at commit landing this note. Same code as notes 009 / 010 / 011 plus one entry in the PIPELINES dict.
  • Corpus: test-confluence/MDD/_snapshots/, the same 35 captured pages as the other three spikes. No refresh; numbers are directly comparable.
  • Pipeline: pipelines/pure_python/ — a package of eight modules (nodes, serialize, identity, storage_reader, storage_writer, markdown_reader, markdown_writer, pipeline). supports_provenance = True and supports_identity = True.
  • Round-trip mode: in-memory only. Storage XHTML → IR (lxml walker) → markdown (custom emitter) → IR (markdown-it-py walker + post-pass) → storage XHTML (custom emitter). Identity rides on the IR via a content-addressed in-memory cache; on cache hit the pre-existing node_id / identity fields are grafted onto the freshly-parsed IR via identity.reattach.
  • Metrics: all six.

A frozen-dataclass IR with two tiers — block nodes (Heading, Paragraph, BulletList, OrderedList, Table, Callout, ConfluenceMacro, ConfluenceLink family, …) and inline tokens (Text, Strong, Emph, Code, Link, Image, ConfluenceLink, ConfluenceImage, InlineMacro, RawInline). Every block carries node_id (provenance, assigned depth-first as b00001, b00002, …) and identity (verbatim copy of any ac:macro-id / ac:local-id). Inline tokens that map to addressable storage elements (links, macros, images) carry identity too; plain text does not. The IR serialises to JSON losslessly (round- trip test on every fixture), so the same data structure can act as both the in-memory working set and an on-disk sidecar in production.

Markdown stays clean. Confluence-specific shapes use clean surfaces: :::callout-tip fenced divs, [body](confluence-page:...) synthetic URIs, {{confluence:status colour="Green"}} inline markers, ```confluence-xml for the long-tail passthroughs. No HTML comments, no data-mdd-* attributes leak into the markdown.

Aggregate means across the 35-fixture corpus:

metricstatus_quo (009)docling (011)pandoc_lua (010)pure_python (this note)
text_fidelity (M1)0.85590.94480.96740.9988
structural_fidelity (M2)0.69640.41440.77260.9838
whitespace_drift (M5)0.60620.60120.75090.9830
identity_preservation (M3)1.0000
provenance_coverage (M4)1.00001.0000
total wall-clock (35 fixtures)23 ms310 ms2895 ms31 ms
code surface (M6)576 LOC78 LOC202 LOC2249 LOC

pure_python sweeps four of five fidelity metrics outright and ties docling on the fifth. Latency is back in the 30 ms ballpark (slightly slower than status_quo’s 23 ms because of the markdown- it-py parse on the return leg; ~94× faster than pandoc per fixture). The cost is on M6: the largest pipeline by LOC because the IR + four readers/writers replace what status_quo expresses as two regex-and-tree-walker files and what the other two pipelines offload to upstream libraries.

27 of 35 fixtures hit 1.0 / 1.0 / 1.0 / 1.0 / 1.0

Section titled “27 of 35 fixtures hit 1.0 / 1.0 / 1.0 / 1.0 / 1.0”

The full clean sweep: every metric perfect on 27 fixtures. These are the shapes the IR handles natively — plain paragraphs, lists (bullet, ordered, nested), most callouts, tables without merged cells, the ac:link/ri:* family, inline macros (status), the attachment-binary link, the macro-view-file shape that status_quo collapsed to 0.13/0.50/0.00, the link-ri-page-same- space shape that status_quo collapsed to 0.22/0.40/0.00. Most of the gain over the other three pipelines lives here — the shapes status_quo bled on round-trip cleanly through the IR.

The non-1.0 cells:

page_idshapeM1M2M5what costs
98544macro-children (empty body)0.98231.00000.8198data-layout="default" attr dropped + writer emits empty <ac:rich-text-body></ac:rich-text-body>
131360callout-panel, ordered list inside0.98291.00000.7909<ol start="1"> round-trips without the start="1" because writer drops the default
164060link-ri-page-same-space (two links)0.99320.80000.8639trailing whitespace-only <p> </p> dropped — markdown-it discards trailing whitespace
131332callout-tip, rich body1.00000.80001.0000trailing empty <p /> after the macro dropped (no markdown analogue)
131404link-ri-attachment, standalone1.00000.83331.0000a standalone <ac:link> between paragraphs gets re-wrapped as a <p><ac:link/></p> because markdown can’t express “standalone inline-element-as-block”
65915code-block-no-language1.00001.00000.9793one-byte whitespace drift in CDATA content (newline at end)
65941code-block-language (python)1.00001.00000.9843same — trailing newline normalisation
98348code-block-special-chars1.00001.00000.9729same — trailing newline normalisation
164122“Platform glossary” (long doc)1.00001.00000.9948one-byte drift on a &hellip; substitution

Every drift is a bounded, well-understood mechanical issue. None is a class-of-failure that requires rethinking the IR shape.

Identity preservation: 1.0 on all 35 fixtures

Section titled “Identity preservation: 1.0 on all 35 fixtures”

Every ac:macro-id / ac:local-id in the original storage appears in the round-trip output. The mechanism: the IR holds identity as a dict[str, str] on each addressable node; the markdown layer doesn’t carry it; the round-trip leg uses a content-addressed cache (sha256(markdown) → cached_IR) plus identity.reattach() to graft the cached identity onto the freshly-parsed IR. For the harness’s unedited round-trip, every cache lookup hits, every identity grafts.

This translates to production as “cache hit → read the <page>.confluence.json sidecar from disk”, not the in-memory dict. Same reattach() API; different source for the prior IR.

Provenance coverage: 1.0 on all 35 fixtures

Section titled “Provenance coverage: 1.0 on all 35 fixtures”

Every top-level markdown block resolves back to a source storage element. The mechanism: node_id is assigned depth-first on parse (b00001b00NNN) and exposed via StorageToMdResult.block_provenance in document order.

Note 011 also scored 1.0 on M4 via docling’s self_ref (a parse-local string like #/texts/2). The difference: pure_python’s node_id is ours, lives in the IR, serialises to JSON, and round-trips through the markdown leg via the cache. docling’s self_ref is parse-local — useful as a hook, but not yet shown to survive its own round-trip.

Where status_quo bled hardest, pure_python recovered

Section titled “Where status_quo bled hardest, pure_python recovered”

Note 009 called out four shape clusters where status_quo scored below 0.5 on M1:

shapestatus_quo M1pure_python M1
macro-status0.111.0000
macro-children0.230.9823
macro-view-file0.131.0000
link-ri-page-same-space0.220.9932
link-ri-attachment0.721.0000
link-inline-trailing-text0.441.0000

The status_quo failure mode there is uniform: those shapes fall back to verbose {=confluence} raw-XML blocks, inflating the markdown by ~10× and breaking the markdown→storage parser. The IR handles them natively — ac:link/ri:page becomes ConfluenceLink(target_kind="page", target="..."), ac:structured- macro ac:name="status" becomes InlineMacro(name="status", params=...). Round-trip works.

Per-shape comparison vs the three earlier pipelines

Section titled “Per-shape comparison vs the three earlier pipelines”

Selected shapes where the four pipelines diverge meaningfully. M1 / M2 means across fixtures tagged with that shape (pure_python in bold):

shapensq M1dl M1pl M1pp M1sq M2dl M2pl M2pp M2
attachment-binary10.131.001.001.000.500.500.501.00
callout-panel10.860.981.000.980.000.750.831.00
callout-tip10.970.980.991.000.400.400.600.80
code-block-no-language10.910.740.911.001.000.000.501.00
code-block-special-chars10.530.720.681.001.000.200.201.00
html-entities-in-prose10.230.960.981.000.330.330.331.00
inline-link70.980.960.971.000.630.470.911.00
inline-macro10.110.981.001.000.330.330.331.00
link-inline-trailing-text50.440.971.001.000.580.250.580.97
link-ri-attachment10.720.921.001.000.670.000.670.83
link-ri-page-same-space10.220.990.990.990.400.400.400.80
list-nested11.001.000.981.000.431.001.001.00
macro-children10.230.960.980.980.330.330.331.00
macro-status10.110.981.001.000.330.330.331.00
macro-view-file10.131.001.001.000.500.500.501.00
table-merged-cells10.820.701.001.000.550.480.291.00

pure_python is at-or-above the best of the other three on every row except callout-tip M2 and link-ri-attachment M2 (where the trailing-empty-paragraph and standalone-link drifts cost a fraction). Three shapes it wins outright on M2 over the best existing pipeline: inline-macro, macro-* family, and table-merged-cells — exactly the shapes that drove the recommendation question in note 012.

fileLOC
pipeline.py60
nodes.py320
serialize.py50
identity.py200
markdown_reader.py360
markdown_writer.py290
storage_reader.py660
storage_writer.py310
total2249

The IR (nodes.py + serialize.py + identity.py = 570 LOC) is half the surface; the readers / writers are split roughly evenly. This is the largest pipeline measured. Two honest framings:

  1. More code than status_quo. True — 2.25× the LOC. The extra surface buys M3, M4, structural fidelity on every shape status_quo bled on, and JSON ser/de of the IR. The IR is worth something the status_quo doesn’t have.
  2. Most code we own. All 2249 LOC are in this repo. The docling backend is 78 LOC of adaptor over ~hundreds of MB of third-party library; the pandoc backend is 202 LOC of adaptor over a 7 MB Haskell binary. M6 measures the file surface, not the actual maintenance surface. Counting upstream dependencies the pure_python pipeline has the smallest total surface of the four.
pipelinetotal round-trip (35 fixtures)mean per fixture
status_quo23 ms0.7 ms
pure_python31 ms0.9 ms
docling310 ms9.0 ms
pandoc_lua2895 ms83 ms

pure_python adds ~35% over status_quo (the markdown-it-py parse on the return leg is the bulk of the delta) and stays roughly two orders of magnitude faster than pandoc. The slowest single fixture (Table with mixed inline content in cells) is 3.6 ms end-to-end; the median is 0.42 ms. Subprocess startup cost (pandoc’s main bottleneck) is moot here — pure Python in-process.

What would it take to score 1.0 / 1.0 / 1.0 / 1.0 / 1.0?

Section titled “What would it take to score 1.0 / 1.0 / 1.0 / 1.0 / 1.0?”

Not committed, but documenting the path because every remaining drift is bounded and the answer informs the spec note’s CI threshold proposal. Eight outlier fixtures, broken into five mechanical fixes:

  1. Preserve trailing / empty paragraphs through the cache leg. Affects 131332 (M2 0.80 → 1.00) and 164060 (M1 0.99 → 1.00, M2 0.80 → 1.00). The reader already captures empty / whitespace- only <p> nodes; the markdown writer correctly emits them as blank paragraphs but the markdown reader (or markdown-it-py) collapses trailing whitespace. Fix: in identity.reattach, walk the cached IR’s tail and re-insert blocks that fresh dropped when the fresh tail is shorter. ~15 LOC.

  2. Always emit start="1" on <ol> instead of treating it as a default to drop. Affects 131360 (M1 0.98 → 1.00, M5 0.79 → 1.00). Fix: storage_writer._render_block for OrderedList, one-line change. ~1 LOC.

  3. Plumb attrs through ConfluenceMacro’s writer. Affects 98544 (M1 0.98 → 1.00, M5 0.82 → 1.00). The reader captures data-layout="default"; the writer drops it. Also: when the IR’s rich_body is False, don’t emit <ac:rich-text-body></ ac:rich-text-body>. Reattach should also copy rich_body / plain_body from cached. ~10 LOC.

  4. Standalone-vs-inline hint on ConfluenceLink. Affects 131404 (M2 0.83 → 1.00). When the storage reader sees an <ac:link> at the top level (not inside a <p>), tag the IR node so the storage writer doesn’t re-wrap. Markdown can’t distinguish the two on its own — but the cache reattach can carry the hint through. ~5 LOC.

  5. Preserve HTML-entity form vs Unicode-char form per token. Affects 164122 (M5 0.99 → 1.00) and the trailing-newline normalisation on three code-block fixtures (M5 0.97-0.98 → 1.00). The reader normalises &hellip; and stripping CDATA trims trailing newlines; the writer re-emits the normalised form. Fix: store the original byte sequence per Text / CodeBlock token, restore it on emit when the cache reattach hits. ~20 LOC, but adds a JSON-ser concern (binary data in Text.content).

Total: ~50 LOC of bounded fixes for a clean 1.0 sweep across all five metrics. Fix 5 has the highest cost-to-benefit ratio (M5 + 0.02 for the most code); fixes 1-4 are unambiguous wins.

A more ambitious target: M1 = M2 = M3 = M4 = M5 = 1.0 and byte-perfect round-trip storage output (not just metric-perfect). That would require all of the above plus normalising attribute ordering, whitespace, and entity encoding to match Confluence’s on-the-wire shape exactly. The harness doesn’t currently measure this, but it would be the right floor for a “real merge” guarantee once the spec note exists.

  • Live round-trip. Same caveat as notes 009-011. In-memory numbers are an upper bound; Confluence-side normalisation can only make fidelity worse. Live mode is deferred per the open question in note 008.
  • Modified-markdown round-trip. The cache-hit path scores 1.0 on M3 by construction. The cache-miss path (user edits the markdown, original IR is gone) needs separate measurement. A follow-up corpus of “minor edits to a known-good page” would test how much identity survives an edit. Not in scope here.
  • Production sidecar. The in-memory cache is a stand-in for a <page>.confluence.json on disk. The wiring (cache vs. sidecar) is interchangeable through the existing API but the sidecar format itself isn’t specified yet. Belongs in the spec note.

Versus the recommendation in note 012:

  1. pure_python beats pandoc on every fidelity metric by margins that compound (M1 +0.03, M2 +0.21, M5 +0.23).
  2. pure_python wins identity (1.0 vs unwired) and ties docling on provenance (1.0). The identity story doesn’t need a follow- on spec note — it’s already implemented and tested.
  3. pure_python wins latency vs pandoc by ~94× per fixture. It’s only ~35% slower than status_quo.
  4. pure_python loses M6 (2249 LOC vs 78 LOC docling, 202 LOC pandoc). But this is the only LOC we own end-to-end — no binary dependency, no transitive library, no licensing constraint.
  5. pure_python has the smallest open-question list of the four: no Lua, no pandoc server-mode wrapper, no --live mode caveat different from the others, no upstream library churn (docling is pre-1.0).

The note 012 recommendation flips from “pandoc + Lua, with follow-ups for identity / provenance” to “pure_python + IR, with follow-ups bounded by the 5-fix list above.”

mise run ir-experiment -- --pipeline pure_python --out report.md
mise run ir-experiment -- --pipeline pure_python --out report.html

Per-fixture scores (pure_python, 2026-05-12)

Section titled “Per-fixture scores (pure_python, 2026-05-12)”

Truncated to non-perfect rows; the other 27 fixtures all score 1.0 / 1.0 / 1.0 / 1.0 / 1.0.

page_idtitleM1M2M5M3M4
131332Tip callout (rich body)1.00000.80001.00001.00001.0000
131360Panel callout (rich body)0.98291.00000.79091.00001.0000
131404Attachment link via ac:link1.00000.83331.00001.00001.0000
164060Same-space page link0.99320.80000.86391.00001.0000
164122Platform glossary1.00001.00000.99481.00001.0000
65915Plain code block (no language)1.00001.00000.97931.00001.0000
65941Code block with language tag (python)1.00001.00000.98431.00001.0000
98348Code block with characters that need CDATA1.00001.00000.97291.00001.0000
98544Children macro0.98231.00000.81981.00001.0000
  • Research note 006 — IR design space; identifies the “AST with identity per node” principle this spike implements.
  • Research note 007 — corpus specification.
  • Research note 008 — the harness that produced this report.
  • Research note 009 — status-quo baseline.
  • Research note 010 — pandoc + Lua spike.
  • Research note 011 — docling spike.
  • Research note 012 — the comparison and (now revised) recommendation.
  • scripts/ir_experiment/pipelines/pure_python/ — the pipeline implementation.
  • tests/ir_experiment/test_pure_python_pipeline.py — JSON round-trip and metric-threshold tests.
  • HTML report: the harness also emitted an HTML diff report with the same data plus per-fixture diffs and the timing slowdown table. It is not carried in this repository.

None planned in this thread. The next document is a real spec — docs/spec/S28-*.md — that promotes the recommendation from R12 (now: pure_python, not pandoc) into a concrete implementation plan. The 5-fix list above is its first slice.

Site built 2026-07-30.