Composition IR is the shared intermediate representation that document frontends produce and that editors, renderers, and inspectors consume. It owns one thing: an immutable published state per commit, and an exact description of how two published states differ.
It is optimized for three workloads that a batch-oriented IR handles badly:
- editing — a keystroke must cost work proportional to what it changed, not to document size;
- side-by-side — an editor must be able to say where the preview changed and map that back to source; and
- element inspection — a tool must be able to ask what one element is, what it came from, and what depends on it, without materializing everything.
Four contracts, all frozen against a green suite: the IR itself, the engine-internal derivation layer, what a frontend must supply, and what an output target reads. The last one reopened the first — building an SVG, a raster, and a paginated target found four things they needed that the IR did not carry — which is the working rule here: a claim about a consumer that no consumer has exercised is a hypothesis. A rule that cannot be checked by a gate is a rule that will be violated silently, so every rule cites the gate that checks it and a gate asserts those citations resolve. Extending a contract means adding the rule and its gate in the same change.
cargo test # the conformance gates
cargo clippy --all-targets
markdown-core ─┐
├─→ Composition IR ─→ layout / paint / export / inspection
tex-core ──┘
Frontends own source bytes, syntax, and language semantics. They keep their own
ASTs and their own repositories; Composition IR does not define, contain, or
version a frontend's AST. tex-core produces Composition IR directly and
depends on this crate; markdown-core is adapted into it.
Nothing here resolves an embed, import, or include. Composing several documents is the consumer's layer, for reasons stated in the contract: materializing an imported subtree breaks fresh-build equivalence, single-domain identity, and coordinate resolution at once.
Rust, for one decisive reason rather than taste. The load-bearing guarantee is that an unchanged subtree is the same instance in the next snapshot, and every published snapshot is immutable, self-contained, and safe for concurrent reads. That is an aliasing and lifetime guarantee across many simultaneously live revisions. In C it is hand-written reference counting at every path-copy site, where one missed decrement is a leak that only appears in long editing sessions — exactly the target workload. Here it is checked at compile time, and concurrent reads of a published snapshot are free.
Consumers are not required to be Rust. The public boundary is a C ABI, the same one the existing frontends and their Swift/Kotlin/ECMAScript bindings already speak.
Two trees, because a thing that ships and a thing that proves a contract are not the same kind of thing and should not be siblings.
packages/ what ships
composition-ir/ the IR: snapshots, deltas, placement
src/address.rs identity: domain, revision, address spaces
src/node.rs records and the closed projection parts
src/snapshot.rs published state and the commit builder
src/delta.rs the membership law
src/placement.rs absolute placement as a query
src/derive.rs memoization and invalidation (`derive` feature)
tests/gates.rs the contract, executable
tests/derive_gates.rs the derivation contract, executable
composition-ir-ffi/ the C ABI: opaque handles, zero-copy value types
include/composition_ir.h the C header, generated from the source
abi/layout.json the published field layout; bindings generate from it
src/layout.rs generates it from the compiled types
tests/layout_gates.rs fails when the ABI and that file disagree
tests/header_gates.rs compiles and links a C consumer against the header
conformance/ what proves the contracts, and never ships
frontend/ the frontend contract, executable
src/fixture.rs a stand-in frontend providing the required surface
src/adapter.rs the whole seam between a frontend and the IR
tests/gates.rs boundary and cost gates a real frontend must pass
backend/ three output targets and their gates
src/{svg,raster,paged}.rs retained, immediate-mode, and paginated
tests/gates.rs what a backend may read, and what it costs
workloads/ the whole chain, for trying a boundary before it is frozen
src/document.rs one document that stresses pages, overlap, and non-ASCII
src/candidate.rs proposed queries, written as a consumer would today
tests/poc.rs what running them through all three targets showed
docs/specs/ the contracts, normative
docs/direction.md what was decided and why, what is next
packages/ is where the language bindings will go — Swift, Kotlin
Multiplatform, and a WASM/TypeScript package, each publishing to its own
ecosystem from this repository rather than from its own. They live here for one
reason: the field layout of the C ABI is generated from the Rust build, and
every binding reads it. Split across repositories there is always a window in
which a binding's offsets disagree with the shipped library, which is the exact
failure the bindings exist to prevent. One commit changes the ABI and every
binding together, or the change does not land.
Directories are not created before the package inside them exists.
composition-ir is the only crate published to crates.io. The FFI shim is not:
crates.io distributes source for Rust dependents, and a C consumer links a
built library rather than adding a Cargo dependency.
A consumer therefore starts from two artifacts, not one. The header is what a
compiler reads; abi/layout.json is what a binding that reads fields out of
foreign memory by offset reads, and it states which ABI it describes. Both are
generated and committed, so a change to either is a diff on a reviewable file
rather than behaviour discovered in the field.
Each tagged release attaches one archive per target — the static and shared libraries, the header, and the manifest — for macOS, iOS, the iOS simulator, and Linux, all on arm64 and x86_64 where both exist. The manifest in an archive is the right one for the library beside it by construction: the crate refuses at compile time to build for an ABI it does not describe, so a target that would need its own manifest cannot be added to the release matrix without one.
Apache-2.0.