feat(core): retention and ownership protocol for render-plan publications - #110
Merged
Conversation
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
from
August 23, 2026 17:37
fe6d08b to
ebd111c
Compare
size-limit report 📦
|
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
from
August 24, 2026 04:43
0a70fba to
5d50d36
Compare
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
from
August 24, 2026 04:48
5d50d36 to
a697b89
Compare
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
2 times, most recently
from
August 24, 2026 06:34
a91550b to
d3c1e20
Compare
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
from
August 24, 2026 07:38
b5a9ca2 to
ed01852
Compare
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
3 times, most recently
from
August 24, 2026 10:38
425a3c7 to
bdeb190
Compare
…n-repinned animation tier
`Paragraph.measure()` and `.layout()` returned a result union, so every caller wrote `if (result.ok)` -- inside a flexbox measure callback, many times per layout -- to guard a branch that only means this package broke its own invariant. Measurement is synchronous and takes no resource that could be missing: the font is required at construction, the text and spans are validated there, and a constraint that is not finite and nonnegative already throws from the call. There was no failure left to return. They now return the metrics and the inspection directly. The revision is read from `paragraph.layoutRevision`, which already carried it. `ParagraphMeasureResult`, `ParagraphLayoutResult`, and `ParagraphQueryError` are gone from `/core`. This is the same mistake as the frame latch in a different place, and both came from believing rejections were caller-reachable. A throw is the caller's arithmetic; a persistent broken state is our defect. Neither is a return value. Codified as the `engine-call-contract` skill, together with the entry point rule the surface audit produced: a type an application can encounter lives at the root, a thing only an integrator constructs lives in `/core`, and a renderer type never enters the shared vocabulary to make an integration convenient.
The Three.js example imported `txt` from `/three`, which no longer re-exports it; authoring comes from the root. TypeGPU was described as future work when the Bitmap and Slug realizations ship today. Adds the two things a reader could not find at all. Where to import from, stated once as the rule the surface is built on: a type an application can encounter lives at the root, a thing only an integrator constructs lives in `/core`, and `/core` is additive to the root rather than parallel to it. And measuring before rendering, which is the question people actually arrive with -- `Paragraph` measures synchronously with no scene, renderer, world matrix, or committed frame, in paragraph-local values, with ink bounds beside the advance extent so the example shows which one centres text visually.
Conformance captures, contract goldens, the uikit fixture's resolved-box readback, and the kernel fixture all consume positioned columns, so they call layout(). The two stale survivors of the old names (inspectLayout on the contract runtime and the kernel fixture, measureLayout in the layout benchmark) are re-pointed too. Summary-only sites keep measure().
…t split The two-cache split moved +398 raw through the core subpath and the runtime graphs; ceilings and generated evidence follow the source. The README and the benchmarks package concept describe the settled query names.
… a foreign session
The rename landed in source and tests but six canonical documents still described the old split, and `uikit-integration.md` published an example whose declared return types (`ParagraphMeasureResult`, `ParagraphLayoutResult`) were deleted with the result union, so it no longer type-checked. `paragraph.ts` also kept the JSDoc for that deleted union, immediately above the comment that correctly states a query answers or throws. Historical entries in `docs/log.md` and D-272's account of the previous surface keep the old names: they describe what was true when written.
thejustinwalsh
force-pushed
the
feat/plan-retention
branch
from
August 24, 2026 11:52
bdeb190 to
7b50eb4
Compare
…s the rest The ~30 KB step across four runtime graphs is `unicode-segmenter`, entering through internal/graphemes.ts so caller-side span alignment agrees with the engine's cluster grid. One module, four importers. Both hosts measure identically, so the ceilings are now reviewed rather than raised around an open question. F10-F16 record what this stack's adversarial reviews found and could not absorb: the font-binding compiler naming every technique (which is what actually blocks Paragraph from the root entry), digests committed per commit, the false ascent+descent invariant, word ranges splitting astral characters, layoutRevision not advancing, mutable cached arrays published as results, and ParagraphOptions admitting two span authorities.
F10 described a bundling coupling. The real defect is a capability one: loadedFontBindingBytes is a closed branch over the three first-party techniques that throws on any other id, and defineRasterTechnique gives a technique no way to supply a compiler -- while the pieces to build one are all exported from /core. An integrator can build a correct binding and has nowhere to plug it in, which contradicts /core existing so a renderer can drive the engine itself. Recommends carrying the compiler on the technique rather than registering by import side effect, which would add order dependence and re-create the late-registration hazard F6 already records.
… is not RasterBakerModule carries behaviour -- descriptor() and bake() -- so a third party implements it and bakes. RasterTechnique carries only identity, so the consuming side must know every technique by name: two closed branches that both claim something is 'registered' when nothing is, plus a third in the plan target. A third-party technique can therefore bake an artifact and then neither bind a font nor resolve a Three resource from it. The technique already owns the data shape and the binding layout; only the compiler between them was split into core/. F10 now asks for the twins to have the same shape rather than for one more switch.
A third party can write a RasterBakerModule and bake, then neither bind a font nor render what they baked: RasterTechnique carries identity with no behaviour, so three closed branches supply it by name and throw on any other id. The pieces to build a binding are exported from /core, so an integrator can produce correct bytes and find nowhere to put them. Two seams, deliberately different. The portable binding compiler goes on the technique -- every call site already holds it, so a registry would add order dependence, a sideEffects declaration, and a late-registration hazard to buy nothing. The renderer resource resolver goes on the Three plan program, which is already registered per technique and already rejects late registration. Bakers need discovery rather than registration, since the caller passes the module. Acceptance is a technique defined outside the package completing bake, bind, and render in the example renderer.
…er half The first draft claimed a third-party technique cannot render. It can, through Text: three/engine-runtime.ts:201 checks the plan-program registry before falling back to the first-party branch, and glyph-example-raster registers exactly that way. The real defect is narrower and worse. ThreeRasterPlanProgram registers three things and only createMaterial is renderer-specific. PolicyProgram is entirely numbers and core/render-policy imports only textShaperAbi; compileFont's helpers all come from core. So two thirds of a technique's contract is portable by construction and is reachable only through a Three-scoped registry. paragraph.ts:689 calls loadedFontBindingBytes with no registry lookup, so the same third-party technique that works through Text throws through Paragraph -- the framework-neutral surface /core exists to provide -- and a non-Three engine can never register anything. Splitting the portable half into /core makes a technique cost once for every engine plus once per engine for materials, instead of once per engine for all of it.
…bjects ThreeTextEngineResource reads as renderer-owned because of where it lives, but every first-party variant is a technique data type -- BitmapStrikeData, MsdfData, SlugPageData -- and the DataArrayTexture is built from those bytes later, in the plan target. The baked payload carries the data, compileFont yields handles into it, and each engine binds them its own way. That makes compileFont portable after all, so the split does not have to answer for resources crossing a renderer boundary; it has to rename the type and leave the texture caches where they already are.
…tion ThreeTextEngineResource is a closed union whose third-party escape hatch is resource: unknown, so an external technique gets no safety and cannot add a member. RasterTechnique already carries a phantom type map, and Data is already an open third-party type recovered through RasterDataOf; Resource rides the same rail, so the union collapses to one generic shape and the type flows from the technique the caller already holds. Module augmentation is the wrong tool here: it is global and single-instance, so two versions of a technique package collide, and a site generic over techniques cannot say which technique a resource belongs to. Also corrects this document: RasterTechnique is not behaviourless. It has descriptor(), decode(), dispose(), and an optional runtimeBaker -- it decodes its own artifact and owns that data's lifetime. The gap is only the binding compiler.
The README tells a reader that /core is for implementing an integration -- render policy, render plan, frame wire -- and then never names the 692-line guide that teaches exactly that. docs/index.md lists a raster/baker plugin guide but not this one, so renderer-integration.md was reachable only from the planning index, which is where maintainers track work rather than where integrators learn. Links it from both entry points a reader actually uses.
The pipeline diagram showed mutations flowing to draws but never who owns each stage, so a reader could not tell what they write once and what they write again per engine. The policy is numbers, the font binding is Rust wire bytes, and plan resources are handles into the baked payload -- only buffer/texture binding and material realization are engine objects. Also sends a new engine integration to the renderer integration guide rather than to two planning documents; the guide walks the same five responsibilities with working code.
example-raster and example-renderer are disjoint proofs. The technique registers through /three, so it proves technique-on-Three; the engine imports only /core but consumes no technique at all -- its one reference is a comment. Third-party technique inside third-party engine is untested, and it is the only cell where neither side can be special-cased. Every other cell passes today while paragraph.ts:689 still resolves no registry, which is how this survived.
A third-party baker is 24 lines because RasterBakerModule asks only for what the author alone can supply. The same author needs 594 lines total, and two of the causes are ours: the GLB container writer exists in internal/compose-bake.ts and the validators but is exported nowhere, so artifact.ts re-derives chunk magic and 4-byte alignment; and three.ts conflates portable policy and font binding with Three material realization, so a second engine would rewrite code that is already renderer-free. Re-measure after the split. If a technique is not close to its baker in size, the consume-side contract is still asking for the wrong things.
… container The previous revision asserted the GLB writer should be exported. That skipped the question: BakeArtifact carries raw bytes with no stated container, and first-party bakers write no GLB -- compose-bake wraps for them. Either role 'raster' requires a container, and a third-party baker genuinely cannot produce one without private code, or the role accepts arbitrary bytes and example-raster chose GLB for realism. Only the first case justifies a new export, and this audit has deleted eight subpaths for having no consumer outside the package. Settle it first; if a seam is warranted it is integrator-only and belongs beside the bake contract, not at the root.
…dary Reading is already solved and costs nothing new: the package parses the font container once in loader.ts and a technique's decode reads bufferView references off RegisteredFont/RegisteredRaster, never chunks or magic numbers. Techniques are GLB so they can be packed, not because rendering needs it. Writing is the only gap, it is bake-time, and the tiers that keep it out of application bundles already exist -- the root runtime graph pulls no compose-bake, ./bake is bake-time, ./runtime-bake is deliberate. The writer belongs behind ./bake, which is already public, so no new seam is opened.
…ce the core ceiling The regression test added a layer below calls measure(), which this layer renames to layout(), so the runtime gate failed with 'paragraph.measure is not a function'. It was written after the rename and never swept. core-subpath-js measured 299,673 against a 300,000 ceiling, which the foreign host cleared by less than the host gap, so CI failed on 327 bytes of headroom rather than on growth. Re-priced with room, and the three remaining fields evened up so the next small change does not trip a different one.
Five claims did not survive verification. Policy numbers live in a host-specific namespace -- Three owns system buffer 15, the non-Three example defines its own 20 -- so the descriptor cannot move to /core unchanged; the portable half is the schema plus a policy-body factory each engine finishes. compileFont names a Three-owned parameter and the fourth ThreeTextEngineResource variant carries a program exposing createMaterial, so that union is not renderer-free. The matrix marked a cell covered that is not: example-renderer registers no binding, publishes empty draws, and has no device. A non-Three engine is not blocked today, only made to re-author. Baker discovery already exists through package.json#pmndrs.glyph and runtimeBaker, so that step is removed. Resource realization joins the renderer half, Resource infers at registerRasterPlanProgram where retain() gives an inference site, and the core registry is scoped to id lookup and binding composition.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit item 11. A host that wants the render plan rather than our
Object3Ds can now hold it safely, and the example renderer proves it against a real engine.The problem
TextEnginePublicationis borrowed only until the next Wasm call, andTextEngineRenderPlanView.bytes()returns a view straight into Wasm memory. Nothing said so, nothing helped, and a retained host that kept one read freed or reallocated bytes on its next frame — silently.The protocol
No new draw-batch API. The plan already models what a host needs; what was missing was ownership, so this lives on the existing session.
session.retain()returnsRetainedTextEnginePublicationcarrying a unique symbol, so an API that retains demands it in its parameter type rather than in proseisExpired()is two integer compares: thepublicationGenerationepoch and Wasm buffer identity.assertLive()throws naming both generations, and a publication the session never issued is rejected outrightretain()andacknowledge()advancesession.acknowledgedGeneration, which frame requests carry. This is load-bearing rather than bookkeeping: Rust retirements carryafterPublicationGenerationand the engine already rejects a backwards acknowledgement with a revision conflict — now pinned by testreadTextEnginePatch,readTextEngineResource,readTextEngineBuffer,readTextEngineRetirement, keyed by(id, generation)The brand is the point. A host cannot accidentally pass a borrowed publication where a retained one is required, because the type will not allow it.
The example renderer stops being a stub
It authors its own technique schema and policy through
/core's compilers, then drives a realTextEngineHostover the published Wasm artifact through real frames. Its tests hold a retained plan across three frames and a capacity growth, watch a stale borrow die loudly, replay an old acknowledgement at the wire and observe status 12, and decode patch ranges.The boundary test now scans
tests/as well assrc/, and rejects every@pmndrs/glyphsubpath except/coreand the published.wasm.A hole it found, recorded rather than worked around
A real font cannot be driven through real text frames from the published surface.
RuntimeShaper.registerFontdemands loader-registered state, Rust refusesregisterFontBindingwithfontMissingwithout it, andcreateTextRuntimeis exported only from the root entry. That is audit item 12, the host font path, now confirmed with evidence instead of suspicion — and it is exactly the class of gap this package exists to surface. It was recorded as a finding rather than solved with a private import.Verification
mise exec -- pnpm checkat the repository root: exit 0.