From c8f743214ec17ab8fd326d54b851c4be72feebac Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 15:54:34 -0400 Subject: [PATCH 1/7] test(text): pin golden bytes for every Three render-policy variant The upcoming policy DSL must be a pure authoring-layer change; these sha256 digests over all four transform/allocation variants make byte drift in the compiled wire records a loud failure. --- .../integration/render-policy-golden.test.mjs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/text/tests/integration/render-policy-golden.test.mjs diff --git a/packages/text/tests/integration/render-policy-golden.test.mjs b/packages/text/tests/integration/render-policy-golden.test.mjs new file mode 100644 index 00000000..64adb2c3 --- /dev/null +++ b/packages/text/tests/integration/render-policy-golden.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { threeRenderPolicyBytes } from '../../dist/three/render-policy.js'; + +/** + * The policy DSL must be a pure authoring-layer change: every variant of the Three + * render policy compiles to byte-identical wire records before and after. These + * digests were captured from the hand-numbered programContext programs; a digest + * change here means the wire encoding changed, not just its authoring. + */ +const GOLDEN = new Map([ + ['direct/ordered', '974cfbfcb258a6fb064a65a9b74106482ca6ae58956bfb246058b7fbb0635b90'], + ['direct/stable', 'ad1030dd5c3218b7335a6c35fe665cc85b14dab3ed32fb0f028261111823963b'], + ['indexed/ordered', '7a234623f9935d21068801fb29f8e26c8dcdf1346e92a9e3c36617b18f837705'], + ['indexed/stable', '7611048f41341bbf7962fd29ffe2b9a318e5e97cbece394043265df11c91cb6d'], +]); + +test('the Three render policy compiles to its golden bytes for every variant', () => { + for (const transform of ['direct', 'indexed']) { + for (const allocation of ['ordered', 'stable']) { + const bytes = threeRenderPolicyBytes(undefined, transform, [], allocation); + const digest = createHash('sha256').update(bytes).digest('hex'); + assert.equal( + digest, + GOLDEN.get(`${transform}/${allocation}`), + `policy bytes changed for ${transform}/${allocation}`, + ); + } + } +}); From 011d1cc12851b7888f52a46678ff25a34245805b Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 16:25:25 -0400 Subject: [PATCH 2/7] feat(text): author policy programs through a typed expression DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy programs are written against named values instead of register numbers (D-250): policyProgram() exposes semantic handles — including color, which replaces the foreground fiction at the authoring layer; the engine has no background — and declared binding fields by name, while addF32/subtractF32/multiplyF32/u32ToF32 and typed constants build an expression graph that compile() lowers to the same forward-only PolicyOperation records, allocating registers automatically with use-before-write and exhaustion as errors and deduplicating reused values. The four Three programs port to the DSL with per-technique named buffer ids. The wire format, validator, and interpreter are untouched, and value types remain a wire-level property of each operation and buffer schema; the DSL brands exist only at authoring time. A decoded-bytes equivalence test proves the port preserves the input tables, buffer schemas, program metadata, and per-lane store dataflow against the hand-numbered fixtures; the byte goldens re-pin once over that proof. --- docs/log.md | 4 + docs/packages/text.md | 2 +- docs/planning/decision-register.md | 1 + packages/text/src/core.ts | 16 ++ packages/text/src/core/policy-program.ts | 265 ++++++++++++++++++ packages/text/src/core/render-policy.ts | 9 +- packages/text/src/three/render-policy.ts | 229 +++++++++------ .../hand-numbered-policy-bytes.json | 6 + .../render-policy-equivalence.test.mjs | 148 ++++++++++ .../integration/render-policy-golden.test.mjs | 17 +- .../tests/types/policy-program-dsl.test.ts | 47 ++++ 11 files changed, 641 insertions(+), 103 deletions(-) create mode 100644 packages/text/src/core/policy-program.ts create mode 100644 packages/text/tests/fixtures/render-policy/hand-numbered-policy-bytes.json create mode 100644 packages/text/tests/integration/render-policy-equivalence.test.mjs create mode 100644 packages/text/tests/types/policy-program-dsl.test.ts diff --git a/docs/log.md b/docs/log.md index c1db9e35..bd6a68cf 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,10 @@ ## 2026-08-11 +- **Policy authoring DSL (D-250)** — Policy programs are written against named semantic and binding handles with + automatic register allocation; the four Three programs ported with a decoded-bytes equivalence proof against the + hand-numbered fixtures and re-pinned goldens. Wire format and interpreter unchanged. + - **Core API surface (D-249)** — The renderer-neutral engine publishes as `@pmndrs/text/core` and the technique shader library as `@pmndrs/text/tsl`. Three's first-party policy and the Slug shader tree leave core internals, and a scoped import lint holds the first-party integrations to the same public surface a third party gets. diff --git a/docs/packages/text.md b/docs/packages/text.md index b556f920..6a047427 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:7b5325df6f52d248eb6a85076e26c9caa245b61f0f0c45e3565adf54fdd7ee64' +source_digest: 'sha256:bc2e4fb6f41396dcff3ab8130eb7c1e2e0bed6f2d927f1368230a3f887462ab9' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index d1a3afa4..03664f59 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -327,6 +327,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-239 | A known local font is directly expressible through `text bake --input --output` without authoring a discovery module or custom baking script. The package exposes one `text` executable with command-specific help and version output. First-party `--bitmap`, `--msdf`, and `--slug` flags select embedded raster resources; `--unicodes` invokes the package-owned Fontations/Skera baker Wasm before the shared `bakeFont` path so one prepared source feeds the shaping font and every raster; and `--check` performs a temporary byte-exact rebuild. `text glyphs` uses the same Wasm and Skrifa to surface Unicode mappings, exact glyph IDs, and retained `post`/CFF names as JSON or a bake-ready Unicode set without inventing semantic names. Product baking has no HarfBuzz executable dependency; pinned HarfBuzz remains an internal correctness oracle only. Runtime and R3F loading accept one nonempty tuple of raster requests for one input and return a position-preserving typed tuple of `LoadedFont` values. The font artifact is fetched and registered once while each declared raster still performs its required independent decode. Required per-technique options remain compile-time enforced. | Accepted | | D-240 | CLI, Node, and runtime Worker baking share one prepare-once pipeline. A runtime request carries normalized Unicode ranges and the complete ordered raster plan; the Worker feeds the exact prepared source to the shaping bake and every selected first-party raster, composes one canonical GLB, validates it, and transfers one artifact. Only that final artifact is eligible for Worker-owned `CacheStorage`, keyed by source, face, ranges, exact raster descriptors/keys, and contract versions. Persistence inherits the source response's reusable freshness (`max-age` or `Expires`); `no-store`, `no-cache`, missing freshness, and expired responses remain memory-only. Browser quota eviction owns storage pressure, and storage failures remain transparent misses. Every GLB producer records `asset.generator` as the publishing package identity `@pmndrs/text`. | Accepted | | D-241 | The package exposes its React integration as `@pmndrs/text/react`, matching the original public API, roadmap, and ecosystem convention. React Three Fiber remains the internal reconciler and a peer dependency, but is not encoded into the public subpath name. The stale `/r3f` export and generated entry are removed rather than retained as a second alias before publication. | Accepted | +| D-250 | Policy programs are authored through a compile-time expression DSL in `@pmndrs/text/core` (`policyProgram`, `addF32`/`subtractF32`/`multiplyF32`/`u32ToF32`, typed constants) instead of hand-numbered registers. Authors reference named semantic handles (`inlineOrigin`, `fontSize`, `color.red`) and declared binding fields (`bearingX`, `uvOriginX`, `page`); `compile()` lowers the expression graph to the same forward-only `PolicyOperation` records, allocating registers automatically with use-before-write and exhaustion as errors and deduplicating reused values. The wire format, Rust validator, and interpreter are untouched, and the u32/f32 distinction remains wire-level per operation and buffer schema — the DSL's branded value types exist only at authoring time. The four Three programs are ported with per-technique named buffer ids; a semantic-equivalence test decodes old and new bytes and proves identical input tables, buffer schemas, metadata, and per-lane store dataflow against the hand-numbered fixtures, and the byte goldens are re-pinned once over that proof. | Accepted | | D-249 | The renderer-neutral core publishes as `@pmndrs/text/core` and the technique shader library as `@pmndrs/text/tsl`. Core carries runtime shaper creation, the engine host and sessions, frame-wire serialization, render-plan and layout views, font-binding compilation, the versioned ABI, and the policy-authoring toolkit; the runtime-to-shaper bridge is public. Three-specific policy — per-technique programs, capability set, first-party buffer ids — moves from core internals to `three/render-policy.ts` and is built with the same public toolkit a third party uses. The four technique TSL node graphs, including the Slug shader tree formerly in core internals, move to `src/tsl/` under Tsl-prefixed names; the Three entry stops re-exporting shader symbols. First-party integration rigor is enforced by a scoped `no-restricted-imports` lint denying the three, tsl, and react surfaces any import from `internal/` or `generated/`. Wire contracts and behavior are unchanged; the moves are type-level, pinned by subpath type tests. | Accepted | | D-248 | Text decoration rendering lands as the first 11.18 slice, pulled forward for visual proof. Spans declare `decoration` (underline, overline, line-through; solid only — other line styles are rejected at the boundary rather than silently rendered solid). The engine cascade stamps the CSS decorating box: the declaring span's resolved font size rides the resolved decoration group, positioning derives one continuous line per decorating box from the baked `post`/`OS/2` metrics (top-of-stroke semantics), and adjacent runs with one decoration identity merge across nested font-size changes. Decoration records flow as a reserved resource-free `pmndrs.decoration` technique: plan programs declare a primitive kind in the former reserved wire field, planners admit resource-free rows and emit `PRIMITIVE_DECORATION` with zero-resource draws, underline/overline rows append before the paragraph's glyphs and line-through after so draw order matches CSS paint order, and Three realizes one shared flat-quad TSL material with no texture, decoding the packed sRGB paint through the sRGB EOTF so a text-colored line is byte-identical to its glyph ink at the framebuffer. Decorated sessions rebuild their gather output; the undecorated fast path is unchanged, verified by a same-window interleaved A/B against the pre-decoration checkpoint (−0.3%/+0.6%/−0.7%/+1.6% on cold/font-size/suffix-edit/splice; earlier apparent regressions reproduced on the checkpoint under ambient load). Retained decoration diffing and patterned line styles remain 11.18 work. | Accepted | | D-247 | The shaping and layout contract represents a break-inserted hyphen glyph without a source cluster, proven by an exact positioning test rather than a contract change. Any flow fragment — not only an ellipsized final line — may reference a boundary-shape record whose source span is empty and whose inserted span holds independently shaped glyphs: the inserted glyph positions after the fragment's retained clusters, carries its own stable glyph identity from the boundary arena, publishes the boundary text position as its semantic cluster (`SemanticGlyph.cluster = text_end`, the "no source cluster" representation), inherits style and paint from the neighbor cluster, participates in alignment through the composed line advance, and receives a content revision; the following line is unaffected. Nothing in the path is ellipsis-specific, so 11.14's hyphenation-adjacent work composes onto this record shape. Language patterns, break selection, and justification quality controls remain later work. | Accepted | diff --git a/packages/text/src/core.ts b/packages/text/src/core.ts index 56a46fd4..6ecde0b0 100644 --- a/packages/text/src/core.ts +++ b/packages/text/src/core.ts @@ -74,4 +74,20 @@ export { type PolicyTransformMode, type ProgramContext, } from './core/render-policy.js'; +export { + addF32, + constantF32, + constantU32, + multiplyF32, + policyProgram, + subtractF32, + u32ToF32, + type CompiledPolicyProgramBody, + type PolicyColorChannels, + type PolicyF32Value, + type PolicyProgramBuilder, + type PolicyProgramOptions, + type PolicyProgramSemantics, + type PolicyU32Value, +} from './core/policy-program.js'; export { textShaperAbi } from './generated/text-shaper-abi.js'; diff --git a/packages/text/src/core/policy-program.ts b/packages/text/src/core/policy-program.ts new file mode 100644 index 00000000..185f1089 --- /dev/null +++ b/packages/text/src/core/policy-program.ts @@ -0,0 +1,265 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { PolicyInput, PolicyInputScope, PolicyOperation } from './render-policy.js'; + +/** + * Expression DSL over the policy-program register machine. Authors reference named + * values instead of register numbers; `compile()` lowers the expression graph to the + * same forward-only `PolicyOperation` records the hand-numbered form produced, + * allocating registers automatically and failing loudly on exhaustion. The wire + * format, validator, and interpreter are untouched — this is authoring only. + */ + +const MAX_REGISTERS = 32; + +type Node = + | { readonly kind: 'loadF32'; readonly input: number; readonly label: string } + | { readonly kind: 'loadU32'; readonly input: number; readonly label: string } + | { + readonly kind: 'binary'; + readonly op: 'addF32' | 'subtractF32' | 'multiplyF32'; + readonly left: Node; + readonly right: Node; + } + | { readonly kind: 'constantF32'; readonly value: number } + | { readonly kind: 'constantU32'; readonly value: number } + | { readonly kind: 'convertU32ToF32'; readonly source: Node }; + +declare const f32Brand: unique symbol; +declare const u32Brand: unique symbol; + +/** A named or derived f32 value inside one policy program. */ +export interface PolicyF32Value { + readonly [f32Brand]: true; +} + +/** A named or derived u32 value inside one policy program. */ +export interface PolicyU32Value { + readonly [u32Brand]: true; +} + +const nodes = new WeakMap(); + +function f32Value(node: Node): PolicyF32Value { + const value = {} as PolicyF32Value; + nodes.set(value, node); + return value; +} + +function u32Value(node: Node): PolicyU32Value { + const value = {} as PolicyU32Value; + nodes.set(value, node); + return value; +} + +function nodeOf(value: PolicyF32Value | PolicyU32Value): Node { + const node = nodes.get(value); + if (node === undefined) throw new TypeError('policy value does not belong to this authoring session'); + return node; +} + +export function addF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { + return f32Value({ kind: 'binary', op: 'addF32', left: nodeOf(left), right: nodeOf(right) }); +} + +export function subtractF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { + return f32Value({ kind: 'binary', op: 'subtractF32', left: nodeOf(left), right: nodeOf(right) }); +} + +export function multiplyF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { + return f32Value({ kind: 'binary', op: 'multiplyF32', left: nodeOf(left), right: nodeOf(right) }); +} + +export function u32ToF32(source: PolicyU32Value): PolicyF32Value { + return f32Value({ kind: 'convertU32ToF32', source: nodeOf(source) }); +} + +export function constantF32(value: number): PolicyF32Value { + if (!Number.isFinite(value)) throw new RangeError('policy f32 constants must be finite'); + return f32Value({ kind: 'constantF32', value }); +} + +export function constantU32(value: number): PolicyU32Value { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError('policy u32 constants must be u32'); + } + return u32Value({ kind: 'constantU32', value }); +} + +/** The glyph color channels — the resolved paint; the engine has no background. */ +export interface PolicyColorChannels { + readonly red: PolicyF32Value; + readonly green: PolicyF32Value; + readonly blue: PolicyF32Value; + readonly alpha: PolicyF32Value; +} + +export interface PolicyProgramSemantics { + readonly inlineOrigin: PolicyF32Value; + readonly blockOrigin: PolicyF32Value; + readonly fontSize: PolicyF32Value; + readonly color: PolicyColorChannels; + readonly inverseFontSize: PolicyF32Value | undefined; + readonly transformIndex: PolicyU32Value; + readonly stableGlyphId: PolicyU32Value; +} + +export interface PolicyProgramOptions< + F32 extends readonly string[] = readonly string[], + U32 extends readonly string[] = readonly string[], +> { + readonly scope: PolicyInputScope; + readonly bindingF32?: F32; + readonly bindingU32?: U32; + readonly inverseFontSize?: boolean; +} + +export interface CompiledPolicyProgramBody { + readonly inputs: PolicyInput[]; + readonly operations: PolicyOperation[]; + readonly f32InputCount: number; + readonly u32InputCount: number; +} + +export interface PolicyProgramBuilder { + readonly semantics: PolicyProgramSemantics; + readonly binding: Readonly & Record>; + storeF32(buffer: number, lanes: readonly PolicyF32Value[]): void; + storeU32(buffer: number, lanes: readonly PolicyU32Value[]): void; + compile(): CompiledPolicyProgramBody; +} + +interface StoreRecord { + readonly opcode: number; + readonly buffer: number; + readonly lane: number; + readonly node: Node; +} + +export function policyProgram< + const F32 extends readonly string[] = readonly [], + const U32 extends readonly string[] = readonly [], +>(options: PolicyProgramOptions): PolicyProgramBuilder { + const semanticF32 = textShaperAbi.engine.semanticF32Fields; + const semanticU32 = textShaperAbi.engine.semanticU32Fields; + const bindingF32Names = options.bindingF32 ?? []; + const bindingU32Names = options.bindingU32 ?? []; + const uniqueNames = new Set([...bindingF32Names, ...bindingU32Names]); + if (uniqueNames.size !== bindingF32Names.length + bindingU32Names.length) { + throw new TypeError('policy binding field names must be unique'); + } + + // The input table mirrors the canonical order the engine validated all along: + // seven semantic f32 fields, optional inverseFontSize, then the binding's f32 + // fields; transformIndex and stableGlyphId, then the binding's u32 fields. + const inputs: PolicyInput[] = [ + { scope: 'semantic', field: semanticF32.inlineOrigin }, + { scope: 'semantic', field: semanticF32.blockOrigin }, + { scope: 'semantic', field: semanticF32.fontSize }, + { scope: 'semantic', field: semanticF32.foregroundRed }, + { scope: 'semantic', field: semanticF32.foregroundGreen }, + { scope: 'semantic', field: semanticF32.foregroundBlue }, + { scope: 'semantic', field: semanticF32.foregroundAlpha }, + ...(options.inverseFontSize === true ? [{ scope: 'semantic' as const, field: semanticF32.inverseFontSize }] : []), + ...bindingF32Names.map((_, field) => ({ scope: options.scope, field })), + { scope: 'semantic', field: semanticU32.transformIndex }, + { scope: 'semantic', field: semanticU32.stableGlyphId }, + ...bindingU32Names.map((_, field) => ({ scope: options.scope, field })), + ]; + const f32InputCount = 7 + (options.inverseFontSize === true ? 1 : 0) + bindingF32Names.length; + const u32InputCount = 2 + bindingU32Names.length; + + let nextF32 = 0; + const loadF32 = (label: string): PolicyF32Value => f32Value({ kind: 'loadF32', input: nextF32++, label }); + const semantics: PolicyProgramSemantics = { + inlineOrigin: loadF32('inlineOrigin'), + blockOrigin: loadF32('blockOrigin'), + fontSize: loadF32('fontSize'), + color: { + red: loadF32('color.red'), + green: loadF32('color.green'), + blue: loadF32('color.blue'), + alpha: loadF32('color.alpha'), + }, + inverseFontSize: options.inverseFontSize === true ? loadF32('inverseFontSize') : undefined, + transformIndex: u32Value({ kind: 'loadU32', input: 0, label: 'transformIndex' }), + stableGlyphId: u32Value({ kind: 'loadU32', input: 1, label: 'stableGlyphId' }), + }; + const binding: Record = {}; + for (const name of bindingF32Names) binding[name] = loadF32(name); + for (const [index, name] of bindingU32Names.entries()) { + binding[name] = u32Value({ kind: 'loadU32', input: 2 + index, label: name }); + } + + const stores: StoreRecord[] = []; + const opcodes = textShaperAbi.policy.opcodes; + + return { + semantics, + binding: binding as PolicyProgramBuilder['binding'], + storeF32(buffer, lanes) { + for (const [lane, value] of lanes.entries()) { + stores.push({ opcode: opcodes.storeF32, buffer, lane, node: nodeOf(value) }); + } + }, + storeU32(buffer, lanes) { + for (const [lane, value] of lanes.entries()) { + stores.push({ opcode: opcodes.storeU32, buffer, lane, node: nodeOf(value) }); + } + }, + compile() { + const operations: PolicyOperation[] = []; + const registers = new Map(); + const emit = (node: Node): number => { + const assigned = registers.get(node); + if (assigned !== undefined) return assigned; + let operation: PolicyOperation; + switch (node.kind) { + case 'loadF32': + operation = { opcode: opcodes.loadF32, target: 0, operand0: node.input }; + break; + case 'loadU32': + operation = { opcode: opcodes.loadU32, target: 0, operand0: node.input }; + break; + case 'binary': { + const left = emit(node.left); + const right = emit(node.right); + operation = { opcode: opcodes[node.op], target: 0, operand0: left, operand1: right }; + break; + } + case 'constantF32': + operation = { opcode: opcodes.constantF32, target: 0, immediate0: f32Bits(node.value) }; + break; + case 'constantU32': + operation = { opcode: opcodes.constantU32, target: 0, immediate0: node.value }; + break; + case 'convertU32ToF32': { + const source = emit(node.source); + operation = { opcode: opcodes.convertU32ToF32, target: 0, operand0: source }; + break; + } + } + const register = registers.size; + if (register >= MAX_REGISTERS) { + throw new RangeError( + `policy program needs more than ${MAX_REGISTERS} registers; name intermediate values and reuse them`, + ); + } + registers.set(node, register); + operations.push({ ...operation, target: register }); + return register; + }; + for (const store of stores) { + const register = emit(store.node); + operations.push({ opcode: store.opcode, operand0: register, operand1: store.lane, immediate0: store.buffer }); + } + return { inputs, operations, f32InputCount, u32InputCount }; + }, + }; +} + +function f32Bits(value: number): number { + const bytes = new ArrayBuffer(4); + const view = new DataView(bytes); + view.setFloat32(0, value, true); + return view.getUint32(0, true); +} diff --git a/packages/text/src/core/render-policy.ts b/packages/text/src/core/render-policy.ts index b44662be..dd496d0e 100644 --- a/packages/text/src/core/render-policy.ts +++ b/packages/text/src/core/render-policy.ts @@ -195,10 +195,17 @@ export function programContext( }; } +export interface ProgramBody { + readonly inputs: PolicyInput[]; + readonly operations: PolicyOperation[]; + readonly f32InputCount: number; + readonly u32InputCount: number; +} + export function createProgram( techniqueId: number, programId: number, - context: ProgramContext, + context: ProgramBody, buffers: readonly PolicyBuffer[], transformMode: PolicyTransformMode, allocationMode: PolicyAllocationMode, diff --git a/packages/text/src/three/render-policy.ts b/packages/text/src/three/render-policy.ts index 2a60e4f2..c52e3a0a 100644 --- a/packages/text/src/three/render-policy.ts +++ b/packages/text/src/three/render-policy.ts @@ -1,15 +1,20 @@ import { + addF32, compileRenderPolicy, + constantF32, + constantU32, createProgram, floatBuffers, - programContext, - stores, + multiplyF32, + policyProgram, + RenderWireIdentityRegistry, + subtractF32, u32Buffers, + u32ToF32, + type PolicyAllocationMode, type PolicyBuffer, type PolicyCapabilitySet, type PolicyProgram, - RenderWireIdentityRegistry, - type PolicyAllocationMode, type PolicyTransformMode, } from '../core.js'; import { textShaperAbi } from '../core.js'; @@ -73,38 +78,59 @@ function threeCapabilitySet(): PolicyCapabilitySet { }; } +// Buffer ids are wire integers; these names map each technique's physical buffers +// to what its shader reads from them. +const BITMAP_ORIGIN = 1; +const BITMAP_SIZE = 2; +const BITMAP_UV_ORIGIN = 3; +const BITMAP_UV_SIZE = 4; +const BITMAP_COLOR = 5; +const BITMAP_PAGE = 6; +const MSDF_RECT = 1; +const MSDF_UV_RECT = 2; +const MSDF_UV_BOUNDS = 3; +const MSDF_COLOR = 4; +const MSDF_EFFECT_A = 5; +const MSDF_EFFECT_B = 6; +const MSDF_PAGE = 7; +const SLUG_RECT = 1; +const SLUG_PLANE_RECT = 2; +const SLUG_BAND_TRANSFORM = 3; +const SLUG_COLOR = 4; +const SLUG_INVERSE_FONT_SIZE = 5; +const SLUG_TABLE_STARTS = 6; +const SLUG_BAND_COUNTS = 7; +const DECORATION_RECT = 1; +const DECORATION_PACKED = 2; + function bitmapProgram( techniqueId: number, programId: number, transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const context = programContext('strike', 8, 1); - const { loadF32, loadU32, binary, storeF32, storeU32 } = context; - loadF32(15); - loadU32(31, 0); - loadU32(30, 1); - loadU32(29, 2); - binary('multiplyF32', 15, 7, 2); - binary('addF32', 16, 0, 15); - binary('multiplyF32', 17, 8, 2); - binary('subtractF32', 18, 1, 17); - binary('multiplyF32', 19, 9, 2); - binary('multiplyF32', 20, 10, 2); - stores(storeF32, [ - [1, [16, 18]], - [2, [19, 20]], - [3, [11, 12]], - [4, [13, 14]], - [5, [3, 4, 5, 6]], + const p = policyProgram({ + scope: 'strike', + bindingF32: ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY'], + bindingU32: ['page'], + }); + const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; + const { bearingX, bearingY, width, height, uvOriginX, uvOriginY, uvSizeX, uvSizeY, page } = p.binding; + p.storeF32(BITMAP_ORIGIN, [ + addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), + subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), ]); - if (transformMode === 'indexed') storeU32(TRANSFORM_BUFFER_ID, 0, 31); - storeU32(STABLE_GLYPH_BUFFER_ID, 0, 30); - storeU32(6, 0, 29); + p.storeF32(BITMAP_SIZE, [multiplyF32(width, fontSize), multiplyF32(height, fontSize)]); + p.storeF32(BITMAP_UV_ORIGIN, [uvOriginX, uvOriginY]); + p.storeF32(BITMAP_UV_SIZE, [uvSizeX, uvSizeY]); + p.storeF32(BITMAP_COLOR, [color.red, color.green, color.blue, color.alpha]); + if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); + p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); + p.storeU32(BITMAP_PAGE, [page]); return createProgram( techniqueId, programId, - context, + p.compile(), transformMode === 'indexed' ? [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer(), transformIndexBuffer()] : [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer()], @@ -119,35 +145,43 @@ function msdfProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const context = programContext('glyph', 10, 1); - const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; - loadF32(17); - loadU32(17, 2); - loadU32(31, 0); - loadU32(30, 1); - binary('multiplyF32', 18, 7, 2); - binary('addF32', 19, 0, 18); - binary('multiplyF32', 20, 8, 2); - binary('subtractF32', 21, 1, 20); - binary('multiplyF32', 22, 9, 2); - binary('multiplyF32', 23, 10, 2); - operations.push({ opcode: textShaperAbi.policy.opcodes.convertU32ToF32, target: 24, operand0: 17 }); - constantF32(25, 0); - stores(storeF32, [ - [1, [19, 21, 22, 23]], - [2, [11, 12, 13, 14]], - [3, [11, 12, 15, 16]], - [4, [3, 4, 5, 6]], - [5, [25, 25, 25, 25]], - [6, [25, 25, 25, 25]], - [7, [25, 25, 25, 24]], + const p = policyProgram({ + scope: 'glyph', + bindingF32: [ + 'bearingX', + 'bearingY', + 'width', + 'height', + 'uvOriginX', + 'uvOriginY', + 'uvSizeX', + 'uvSizeY', + 'uvMaxX', + 'uvMaxY', + ], + bindingU32: ['page'], + }); + const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; + const { bearingX, bearingY, width, height, uvOriginX, uvOriginY, uvSizeX, uvSizeY, uvMaxX, uvMaxY, page } = p.binding; + const zero = constantF32(0); + p.storeF32(MSDF_RECT, [ + addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), + subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), + multiplyF32(width, fontSize), + multiplyF32(height, fontSize), ]); - if (transformMode === 'indexed') storeU32(TRANSFORM_BUFFER_ID, 0, 31); - storeU32(STABLE_GLYPH_BUFFER_ID, 0, 30); + p.storeF32(MSDF_UV_RECT, [uvOriginX, uvOriginY, uvSizeX, uvSizeY]); + p.storeF32(MSDF_UV_BOUNDS, [uvOriginX, uvOriginY, uvMaxX, uvMaxY]); + p.storeF32(MSDF_COLOR, [color.red, color.green, color.blue, color.alpha]); + p.storeF32(MSDF_EFFECT_A, [zero, zero, zero, zero]); + p.storeF32(MSDF_EFFECT_B, [zero, zero, zero, zero]); + p.storeF32(MSDF_PAGE, [zero, zero, zero, u32ToF32(page)]); + if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); + p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); return createProgram( techniqueId, programId, - context, + p.compile(), [ ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), stableGlyphIdBuffer(), @@ -164,37 +198,51 @@ function slugProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const context = programContext('glyph', 8, 6, true); - const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; - loadF32(16); - loadU32(31, 0); - loadU32(30, 1); - for (let field = 0; field < 6; field += 1) loadU32(21 + field, field + 2); - binary('multiplyF32', 16, 8, 2); - binary('addF32', 17, 0, 16); - binary('multiplyF32', 18, 9, 2); - binary('subtractF32', 19, 1, 18); - binary('multiplyF32', 20, 10, 2); - binary('multiplyF32', 27, 11, 2); - constantF32(28, 0); - constantU32(29, 0); - stores(storeF32, [ - [1, [17, 19, 20, 27]], - [2, [8, 9, 10, 11]], - [3, [12, 13, 14, 15]], - [4, [3, 4, 5, 6]], - [5, [7, 28, 28, 28]], - ]); - stores(storeU32, [ - [6, [21, 22, 23, 24]], - [7, [25, 26, 29, 29]], + const p = policyProgram({ + scope: 'glyph', + inverseFontSize: true, + bindingF32: ['bearingX', 'bearingY', 'width', 'height', 'bandScaleX', 'bandScaleY', 'bandOffsetX', 'bandOffsetY'], + bindingU32: ['curveStart', 'headerStart', 'referenceStart', 'bandStart', 'horizontalBands', 'verticalBands'], + }); + const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; + const inverseFontSize = p.semantics.inverseFontSize; + if (inverseFontSize === undefined) throw new TypeError('the Slug program declares inverseFontSize'); + const { + bearingX, + bearingY, + width, + height, + bandScaleX, + bandScaleY, + bandOffsetX, + bandOffsetY, + curveStart, + headerStart, + referenceStart, + bandStart, + horizontalBands, + verticalBands, + } = p.binding; + const zeroF32 = constantF32(0); + const zeroU32 = constantU32(0); + p.storeF32(SLUG_RECT, [ + addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), + subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), + multiplyF32(width, fontSize), + multiplyF32(height, fontSize), ]); - if (transformMode === 'indexed') storeU32(TRANSFORM_BUFFER_ID, 0, 31); - storeU32(STABLE_GLYPH_BUFFER_ID, 0, 30); + p.storeF32(SLUG_PLANE_RECT, [bearingX, bearingY, width, height]); + p.storeF32(SLUG_BAND_TRANSFORM, [bandScaleX, bandScaleY, bandOffsetX, bandOffsetY]); + p.storeF32(SLUG_COLOR, [color.red, color.green, color.blue, color.alpha]); + p.storeF32(SLUG_INVERSE_FONT_SIZE, [inverseFontSize, zeroF32, zeroF32, zeroF32]); + p.storeU32(SLUG_TABLE_STARTS, [curveStart, headerStart, referenceStart, bandStart]); + p.storeU32(SLUG_BAND_COUNTS, [horizontalBands, verticalBands, zeroU32, zeroU32]); + if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); + p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); return createProgram( techniqueId, programId, - context, + p.compile(), [ ...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6), @@ -209,8 +257,9 @@ function slugProgram( /** * Resource-free decoration quads. Decoration rows fill the gather lanes directly — * f32 lanes 0-3 carry the rectangle and u32 lanes carry transform, stable identity, - * color, then flags — so the loads below read lanes by index; the semantic input - * declarations exist to satisfy policy validation and are not sourced per glyph. + * color, then flags — so the semantic handles below address gather lanes by + * position, not per-glyph meaning: the "inlineOrigin" lane is the rect's inline + * start, and the paint arrives through the binding's packed u32 pair. */ function decorationProgram( techniqueId: number, @@ -218,23 +267,17 @@ function decorationProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const context = programContext('glyph', 0, 2); - const { loadF32, loadU32, storeF32, storeU32 } = context; - loadF32(4); - loadU32(28, 0); - loadU32(29, 1); - loadU32(30, 2); - loadU32(31, 3); - stores(storeF32, [[1, [0, 1, 2, 3]]]); - storeU32(2, 0, 30); - storeU32(2, 1, 31); - if (transformMode === 'indexed') storeU32(TRANSFORM_BUFFER_ID, 0, 28); - storeU32(STABLE_GLYPH_BUFFER_ID, 0, 29); + const p = policyProgram({ scope: 'glyph', bindingU32: ['color', 'flags'] }); + const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; + p.storeF32(DECORATION_RECT, [inlineOrigin, blockOrigin, fontSize, color.red]); + p.storeU32(DECORATION_PACKED, [p.binding.color, p.binding.flags]); + if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); + p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); return { ...createProgram( techniqueId, programId, - context, + p.compile(), transformMode === 'indexed' ? [...floatBuffers([4]), ...u32Buffers([2], 2), stableGlyphIdBuffer(), transformIndexBuffer()] : [...floatBuffers([4]), ...u32Buffers([2], 2), stableGlyphIdBuffer()], diff --git a/packages/text/tests/fixtures/render-policy/hand-numbered-policy-bytes.json b/packages/text/tests/fixtures/render-policy/hand-numbered-policy-bytes.json new file mode 100644 index 00000000..d13f6990 --- /dev/null +++ b/packages/text/tests/fixtures/render-policy/hand-numbered-policy-bytes.json @@ -0,0 +1,6 @@ +{ + "direct/ordered": "yA4AACwAAAABAAAAVAAAAAQAAABUAQAAGgAAAPQCAACrAAAApA0AAEkAAAABAAAAPQAAAAAAAAQEAAAAgAAAAAABAAAQABAAAAAIAEwdAAAAAAAAjDt1FwEAAAAAAAAAAQAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAmAAEADwMBAP8AAAAAAAAAEgAAAP3kp/kCAAAAAAAAAAEAAAAAAAAABwAAAAAAAAAAAAAABwAAACYAAAAAAAgAOQABABEDAQD/AAAAEgAAABQAAAAIeSzyAwAAAAAAAAABAAAAAAAAAAcAAAAAAAAAAAAAAA8AAABfAAAAAAAIAD0AAQAQCAEA/wAAACYAAAAYAAAAgfpVNAQAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAXAAAAnAAAAAAAAwAPAAEABwQCAP8AAAA+AAAACwAAAAEAAQIEAAgABgAAAAEAAAACAAECBAAIAAYAAAABAAAAAwABAgQACAAGAAAAAQAAAAQAAQIEAAgABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACAQQABAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAABAAEEBAAQAAYAAAABAAAAAgABBAQAEAAGAAAAAQAAAAMAAQQEABAABgAAAAEAAAAEAAEEBAAQAAYAAAABAAAABQABBAQAEAAGAAAAAQAAAAYAAQQEABAABgAAAAEAAAAHAAEEBAAQAAYAAAABAAAADgACAQQABAAGAAAAAQAAAAEAAQQEABAABgAAAAEAAAACAAEEBAAQAAYAAAABAAAAAwABBAQAEAAGAAAAAQAAAAQAAQQEABAABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACBAQAEAAGAAAAAQAAAAcAAgQEABAABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAgIEAAgABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAEBAQAAAAAAAAAAAAAAAAABAgIAAAAAAAAAAAAAAAAAAQMDAAAAAAAAAAAAAAAAAAEEBAAAAAAAAAAAAAAAAAABBQUAAAAAAAAAAAAAAAAAAQYGAAAAAAAAAAAAAAAAAAEHBwAAAAAAAAAAAAAAAAABCAgAAAAAAAAAAAAAAAAAAQkJAAAAAAAAAAAAAAAAAAEKCgAAAAAAAAAAAAAAAAABCwsAAAAAAAAAAAAAAAAAAQwMAAAAAAAAAAAAAAAAAAENDQAAAAAAAAAAAAAAAAABDg4AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACHQIAAAAAAAAAAAAAAAAABw8HAgAAAAAAAAAAAAAAAAUQAA8AAAAAAAAAAAAAAAAHEQgCAAAAAAAAAAAAAAAABhIBEQAAAAAAAAAAAAAAAAcTCQIAAAAAAAAAAAAAAAAHFAoCAAAAAAAAAAAAAAAACwAQAAEAAAAAAAAAAAAAAAsAEgEBAAAAAAAAAAAAAAALABMAAgAAAAAAAAAAAAAACwAUAQIAAAAAAAAAAAAAAAsACwADAAAAAAAAAAAAAAALAAwBAwAAAAAAAAAAAAAACwANAAQAAAAAAAAAAAAAAAsADgEEAAAAAAAAAAAAAAALAAMABQAAAAAAAAAAAAAACwAEAQUAAAAAAAAAAAAAAAsABQIFAAAAAAAAAAAAAAALAAYDBQAAAAAAAAAAAAAADAAeAA4AAAAAAAAAAAAAAAwAHQAGAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAIRAgAAAAAAAAAAAAAAAAACHwAAAAAAAAAAAAAAAAAAAh4BAAAAAAAAAAAAAAAAAAcSBwIAAAAAAAAAAAAAAAAFEwASAAAAAAAAAAAAAAAABxQIAgAAAAAAAAAAAAAAAAYVARQAAAAAAAAAAAAAAAAHFgkCAAAAAAAAAAAAAAAABxcKAgAAAAAAAAAAAAAAAAoYEQAAAAAAAAAAAAAAAAADGQAAAAAAAAAAAAAAAAAACwATAAEAAAAAAAAAAAAAAAsAFQEBAAAAAAAAAAAAAAALABYCAQAAAAAAAAAAAAAACwAXAwEAAAAAAAAAAAAAAAsACwACAAAAAAAAAAAAAAALAAwBAgAAAAAAAAAAAAAACwANAgIAAAAAAAAAAAAAAAsADgMCAAAAAAAAAAAAAAALAAsAAwAAAAAAAAAAAAAACwAMAQMAAAAAAAAAAAAAAAsADwIDAAAAAAAAAAAAAAALABADAwAAAAAAAAAAAAAACwADAAQAAAAAAAAAAAAAAAsABAEEAAAAAAAAAAAAAAALAAUCBAAAAAAAAAAAAAAACwAGAwQAAAAAAAAAAAAAAAsAGQAFAAAAAAAAAAAAAAALABkBBQAAAAAAAAAAAAAACwAZAgUAAAAAAAAAAAAAAAsAGQMFAAAAAAAAAAAAAAALABkABgAAAAAAAAAAAAAACwAZAQYAAAAAAAAAAAAAAAsAGQIGAAAAAAAAAAAAAAALABkDBgAAAAAAAAAAAAAACwAZAAcAAAAAAAAAAAAAAAsAGQEHAAAAAAAAAAAAAAALABkCBwAAAAAAAAAAAAAACwAYAwcAAAAAAAAAAAAAAAwAHgAOAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACFQIAAAAAAAAAAAAAAAAAAhYDAAAAAAAAAAAAAAAAAAIXBAAAAAAAAAAAAAAAAAACGAUAAAAAAAAAAAAAAAAAAhkGAAAAAAAAAAAAAAAAAAIaBwAAAAAAAAAAAAAAAAAHEAgCAAAAAAAAAAAAAAAABREAEAAAAAAAAAAAAAAAAAcSCQIAAAAAAAAAAAAAAAAGEwESAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAcbCwIAAAAAAAAAAAAAAAADHAAAAAAAAAAAAAAAAAAABB0AAAAAAAAAAAAAAAAAAAsAEQABAAAAAAAAAAAAAAALABMBAQAAAAAAAAAAAAAACwAUAgEAAAAAAAAAAAAAAAsAGwMBAAAAAAAAAAAAAAALAAgAAgAAAAAAAAAAAAAACwAJAQIAAAAAAAAAAAAAAAsACgICAAAAAAAAAAAAAAALAAsDAgAAAAAAAAAAAAAACwAMAAMAAAAAAAAAAAAAAAsADQEDAAAAAAAAAAAAAAALAA4CAwAAAAAAAAAAAAAACwAPAwMAAAAAAAAAAAAAAAsAAwAEAAAAAAAAAAAAAAALAAQBBAAAAAAAAAAAAAAACwAFAgQAAAAAAAAAAAAAAAsABgMEAAAAAAAAAAAAAAALAAcABQAAAAAAAAAAAAAACwAcAQUAAAAAAAAAAAAAAAsAHAIFAAAAAAAAAAAAAAALABwDBQAAAAAAAAAAAAAADAAVAAYAAAAAAAAAAAAAAAwAFgEGAAAAAAAAAAAAAAAMABcCBgAAAAAAAAAAAAAADAAYAwYAAAAAAAAAAAAAAAwAGQAHAAAAAAAAAAAAAAAMABoBBwAAAAAAAAAAAAAADAAdAgcAAAAAAAAAAAAAAAwAHQMHAAAAAAAAAAAAAAAMAB4ADgAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEBAQAAAAAAAAAAAAAAAAABAgIAAAAAAAAAAAAAAAAAAQMDAAAAAAAAAAAAAAAAAAIcAAAAAAAAAAAAAAAAAAACHQEAAAAAAAAAAAAAAAAAAh4CAAAAAAAAAAAAAAAAAAIfAwAAAAAAAAAAAAAAAAALAAAAAQAAAAAAAAAAAAAACwABAQEAAAAAAAAAAAAAAAsAAgIBAAAAAAAAAAAAAAALAAMDAQAAAAAAAAAAAAAADAAeAAIAAAAAAAAAAAAAAAwAHwECAAAAAAAAAAAAAAAMAB0ADgAAAAAAAAAAAAAAAQYAAAEHAAABBAAAAQgAAAEJAAABCgAAAQsAAAQAAAAEAQAABAIAAAQDAAAEBAAABAUAAAQGAAAEBwAAAQQAAAEFAAAEAAAAAQYAAAEHAAABBAAAAQgAAAEJAAABCgAAAQsAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAIGAAACBwAAAggAAAIJAAABBAAAAQUAAAIAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAQwAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAIGAAACBwAAAQQAAAEFAAACAAAAAgEAAAICAAACAwAAAgQAAAIFAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAQQAAAEFAAACAAAAAgEAAA==", + "direct/stable": "yA4AACwAAAABAAAAVAAAAAQAAABUAQAAGgAAAPQCAACrAAAApA0AAEkAAAABAAAAPQAAAAAAAAQEAAAAgAAAAAABAAAQABAAAAAIAEwdAAAAAAAAjDt1FwEAAAAAAAAAAQAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAmAAIADwMBAP8AAAAAAAAAEgAAAP3kp/kCAAAAAAAAAAEAAAAAAAAABwAAAAAAAAAAAAAABwAAACYAAAAAAAgAOQACABEDAQD/AAAAEgAAABQAAAAIeSzyAwAAAAAAAAABAAAAAAAAAAcAAAAAAAAAAAAAAA8AAABfAAAAAAAIAD0AAgAQCAEA/wAAACYAAAAYAAAAgfpVNAQAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAXAAAAnAAAAAAAAwAPAAIABwQCAP8AAAA+AAAACwAAAAEAAQIEAAgABgAAAAEAAAACAAECBAAIAAYAAAABAAAAAwABAgQACAAGAAAAAQAAAAQAAQIEAAgABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACAQQABAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAABAAEEBAAQAAYAAAABAAAAAgABBAQAEAAGAAAAAQAAAAMAAQQEABAABgAAAAEAAAAEAAEEBAAQAAYAAAABAAAABQABBAQAEAAGAAAAAQAAAAYAAQQEABAABgAAAAEAAAAHAAEEBAAQAAYAAAABAAAADgACAQQABAAGAAAAAQAAAAEAAQQEABAABgAAAAEAAAACAAEEBAAQAAYAAAABAAAAAwABBAQAEAAGAAAAAQAAAAQAAQQEABAABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACBAQAEAAGAAAAAQAAAAcAAgQEABAABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAgIEAAgABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAEBAQAAAAAAAAAAAAAAAAABAgIAAAAAAAAAAAAAAAAAAQMDAAAAAAAAAAAAAAAAAAEEBAAAAAAAAAAAAAAAAAABBQUAAAAAAAAAAAAAAAAAAQYGAAAAAAAAAAAAAAAAAAEHBwAAAAAAAAAAAAAAAAABCAgAAAAAAAAAAAAAAAAAAQkJAAAAAAAAAAAAAAAAAAEKCgAAAAAAAAAAAAAAAAABCwsAAAAAAAAAAAAAAAAAAQwMAAAAAAAAAAAAAAAAAAENDQAAAAAAAAAAAAAAAAABDg4AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACHQIAAAAAAAAAAAAAAAAABw8HAgAAAAAAAAAAAAAAAAUQAA8AAAAAAAAAAAAAAAAHEQgCAAAAAAAAAAAAAAAABhIBEQAAAAAAAAAAAAAAAAcTCQIAAAAAAAAAAAAAAAAHFAoCAAAAAAAAAAAAAAAACwAQAAEAAAAAAAAAAAAAAAsAEgEBAAAAAAAAAAAAAAALABMAAgAAAAAAAAAAAAAACwAUAQIAAAAAAAAAAAAAAAsACwADAAAAAAAAAAAAAAALAAwBAwAAAAAAAAAAAAAACwANAAQAAAAAAAAAAAAAAAsADgEEAAAAAAAAAAAAAAALAAMABQAAAAAAAAAAAAAACwAEAQUAAAAAAAAAAAAAAAsABQIFAAAAAAAAAAAAAAALAAYDBQAAAAAAAAAAAAAADAAeAA4AAAAAAAAAAAAAAAwAHQAGAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAIRAgAAAAAAAAAAAAAAAAACHwAAAAAAAAAAAAAAAAAAAh4BAAAAAAAAAAAAAAAAAAcSBwIAAAAAAAAAAAAAAAAFEwASAAAAAAAAAAAAAAAABxQIAgAAAAAAAAAAAAAAAAYVARQAAAAAAAAAAAAAAAAHFgkCAAAAAAAAAAAAAAAABxcKAgAAAAAAAAAAAAAAAAoYEQAAAAAAAAAAAAAAAAADGQAAAAAAAAAAAAAAAAAACwATAAEAAAAAAAAAAAAAAAsAFQEBAAAAAAAAAAAAAAALABYCAQAAAAAAAAAAAAAACwAXAwEAAAAAAAAAAAAAAAsACwACAAAAAAAAAAAAAAALAAwBAgAAAAAAAAAAAAAACwANAgIAAAAAAAAAAAAAAAsADgMCAAAAAAAAAAAAAAALAAsAAwAAAAAAAAAAAAAACwAMAQMAAAAAAAAAAAAAAAsADwIDAAAAAAAAAAAAAAALABADAwAAAAAAAAAAAAAACwADAAQAAAAAAAAAAAAAAAsABAEEAAAAAAAAAAAAAAALAAUCBAAAAAAAAAAAAAAACwAGAwQAAAAAAAAAAAAAAAsAGQAFAAAAAAAAAAAAAAALABkBBQAAAAAAAAAAAAAACwAZAgUAAAAAAAAAAAAAAAsAGQMFAAAAAAAAAAAAAAALABkABgAAAAAAAAAAAAAACwAZAQYAAAAAAAAAAAAAAAsAGQIGAAAAAAAAAAAAAAALABkDBgAAAAAAAAAAAAAACwAZAAcAAAAAAAAAAAAAAAsAGQEHAAAAAAAAAAAAAAALABkCBwAAAAAAAAAAAAAACwAYAwcAAAAAAAAAAAAAAAwAHgAOAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACFQIAAAAAAAAAAAAAAAAAAhYDAAAAAAAAAAAAAAAAAAIXBAAAAAAAAAAAAAAAAAACGAUAAAAAAAAAAAAAAAAAAhkGAAAAAAAAAAAAAAAAAAIaBwAAAAAAAAAAAAAAAAAHEAgCAAAAAAAAAAAAAAAABREAEAAAAAAAAAAAAAAAAAcSCQIAAAAAAAAAAAAAAAAGEwESAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAcbCwIAAAAAAAAAAAAAAAADHAAAAAAAAAAAAAAAAAAABB0AAAAAAAAAAAAAAAAAAAsAEQABAAAAAAAAAAAAAAALABMBAQAAAAAAAAAAAAAACwAUAgEAAAAAAAAAAAAAAAsAGwMBAAAAAAAAAAAAAAALAAgAAgAAAAAAAAAAAAAACwAJAQIAAAAAAAAAAAAAAAsACgICAAAAAAAAAAAAAAALAAsDAgAAAAAAAAAAAAAACwAMAAMAAAAAAAAAAAAAAAsADQEDAAAAAAAAAAAAAAALAA4CAwAAAAAAAAAAAAAACwAPAwMAAAAAAAAAAAAAAAsAAwAEAAAAAAAAAAAAAAALAAQBBAAAAAAAAAAAAAAACwAFAgQAAAAAAAAAAAAAAAsABgMEAAAAAAAAAAAAAAALAAcABQAAAAAAAAAAAAAACwAcAQUAAAAAAAAAAAAAAAsAHAIFAAAAAAAAAAAAAAALABwDBQAAAAAAAAAAAAAADAAVAAYAAAAAAAAAAAAAAAwAFgEGAAAAAAAAAAAAAAAMABcCBgAAAAAAAAAAAAAADAAYAwYAAAAAAAAAAAAAAAwAGQAHAAAAAAAAAAAAAAAMABoBBwAAAAAAAAAAAAAADAAdAgcAAAAAAAAAAAAAAAwAHQMHAAAAAAAAAAAAAAAMAB4ADgAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEBAQAAAAAAAAAAAAAAAAABAgIAAAAAAAAAAAAAAAAAAQMDAAAAAAAAAAAAAAAAAAIcAAAAAAAAAAAAAAAAAAACHQEAAAAAAAAAAAAAAAAAAh4CAAAAAAAAAAAAAAAAAAIfAwAAAAAAAAAAAAAAAAALAAAAAQAAAAAAAAAAAAAACwABAQEAAAAAAAAAAAAAAAsAAgIBAAAAAAAAAAAAAAALAAMDAQAAAAAAAAAAAAAADAAeAAIAAAAAAAAAAAAAAAwAHwECAAAAAAAAAAAAAAAMAB0ADgAAAAAAAAAAAAAAAQYAAAEHAAABBAAAAQgAAAEJAAABCgAAAQsAAAQAAAAEAQAABAIAAAQDAAAEBAAABAUAAAQGAAAEBwAAAQQAAAEFAAAEAAAAAQYAAAEHAAABBAAAAQgAAAEJAAABCgAAAQsAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAIGAAACBwAAAggAAAIJAAABBAAAAQUAAAIAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAQwAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAIGAAACBwAAAQQAAAEFAAACAAAAAgEAAAICAAACAwAAAgQAAAIFAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAQQAAAEFAAACAAAAAgEAAA==", + "indexed/ordered": "SA8AACwAAAABAAAAVAAAAAQAAABUAQAAHgAAADQDAACvAAAAJA4AAEkAAAABAAAAPQAAAAAAAAQEAAAAgAAAAAABAAAQABAAAAAIAEwdAAAAAAAAjDt1FwEAAAAAAAAAAQAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAnAAEADwMBAH8AAAAAAAAAEgAAAP3kp/kCAAAAAAAAAAEAAAAAAAAABwAAAAAAAAAAAAAACAAAACcAAAAAAAkAOgABABEDAQB/AAAAEgAAABQAAAAIeSzyAwAAAAAAAAABAAAAAAAAAAcAAAAAAAAAAAAAABEAAABhAAAAAAAJAD4AAQAQCAEAfwAAACYAAAAYAAAAgfpVNAQAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAaAAAAnwAAAAAABAAQAAEABwQCAH8AAAA+AAAACwAAAAEAAQIEAAgABgAAAAEAAAACAAECBAAIAAYAAAABAAAAAwABAgQACAAGAAAAAQAAAAQAAQIEAAgABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACAQQABAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAQQEABAABgAAAAEAAAADAAEEBAAQAAYAAAABAAAABAABBAQAEAAGAAAAAQAAAAUAAQQEABAABgAAAAEAAAAGAAEEBAAQAAYAAAABAAAABwABBAQAEAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAQQEABAABgAAAAEAAAADAAEEBAAQAAYAAAABAAAABAABBAQAEAAGAAAAAQAAAAUAAQQEABAABgAAAAEAAAAGAAIEBAAQAAYAAAABAAAABwACBAQAEAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAgIEAAgABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAADwACAQQABAAGAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAABBAQAAAAAAAAAAAAAAAAAAQUFAAAAAAAAAAAAAAAAAAEGBgAAAAAAAAAAAAAAAAABBwcAAAAAAAAAAAAAAAAAAQgIAAAAAAAAAAAAAAAAAAEJCQAAAAAAAAAAAAAAAAABCgoAAAAAAAAAAAAAAAAAAQsLAAAAAAAAAAAAAAAAAAEMDAAAAAAAAAAAAAAAAAABDQ0AAAAAAAAAAAAAAAAAAQ4OAAAAAAAAAAAAAAAAAAIfAAAAAAAAAAAAAAAAAAACHgEAAAAAAAAAAAAAAAAAAh0CAAAAAAAAAAAAAAAAAAcPBwIAAAAAAAAAAAAAAAAFEAAPAAAAAAAAAAAAAAAABxEIAgAAAAAAAAAAAAAAAAYSAREAAAAAAAAAAAAAAAAHEwkCAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAsAEAABAAAAAAAAAAAAAAALABIBAQAAAAAAAAAAAAAACwATAAIAAAAAAAAAAAAAAAsAFAECAAAAAAAAAAAAAAALAAsAAwAAAAAAAAAAAAAACwAMAQMAAAAAAAAAAAAAAAsADQAEAAAAAAAAAAAAAAALAA4BBAAAAAAAAAAAAAAACwADAAUAAAAAAAAAAAAAAAsABAEFAAAAAAAAAAAAAAALAAUCBQAAAAAAAAAAAAAACwAGAwUAAAAAAAAAAAAAAAwAHwAPAAAAAAAAAAAAAAAMAB4ADgAAAAAAAAAAAAAADAAdAAYAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAABBAQAAAAAAAAAAAAAAAAAAQUFAAAAAAAAAAAAAAAAAAEGBgAAAAAAAAAAAAAAAAABBwcAAAAAAAAAAAAAAAAAAQgIAAAAAAAAAAAAAAAAAAEJCQAAAAAAAAAAAAAAAAABCgoAAAAAAAAAAAAAAAAAAQsLAAAAAAAAAAAAAAAAAAEMDAAAAAAAAAAAAAAAAAABDQ0AAAAAAAAAAAAAAAAAAQ4OAAAAAAAAAAAAAAAAAAEPDwAAAAAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAhECAAAAAAAAAAAAAAAAAAIfAAAAAAAAAAAAAAAAAAACHgEAAAAAAAAAAAAAAAAABxIHAgAAAAAAAAAAAAAAAAUTABIAAAAAAAAAAAAAAAAHFAgCAAAAAAAAAAAAAAAABhUBFAAAAAAAAAAAAAAAAAcWCQIAAAAAAAAAAAAAAAAHFwoCAAAAAAAAAAAAAAAAChgRAAAAAAAAAAAAAAAAAAMZAAAAAAAAAAAAAAAAAAALABMAAQAAAAAAAAAAAAAACwAVAQEAAAAAAAAAAAAAAAsAFgIBAAAAAAAAAAAAAAALABcDAQAAAAAAAAAAAAAACwALAAIAAAAAAAAAAAAAAAsADAECAAAAAAAAAAAAAAALAA0CAgAAAAAAAAAAAAAACwAOAwIAAAAAAAAAAAAAAAsACwADAAAAAAAAAAAAAAALAAwBAwAAAAAAAAAAAAAACwAPAgMAAAAAAAAAAAAAAAsAEAMDAAAAAAAAAAAAAAALAAMABAAAAAAAAAAAAAAACwAEAQQAAAAAAAAAAAAAAAsABQIEAAAAAAAAAAAAAAALAAYDBAAAAAAAAAAAAAAACwAZAAUAAAAAAAAAAAAAAAsAGQEFAAAAAAAAAAAAAAALABkCBQAAAAAAAAAAAAAACwAZAwUAAAAAAAAAAAAAAAsAGQAGAAAAAAAAAAAAAAALABkBBgAAAAAAAAAAAAAACwAZAgYAAAAAAAAAAAAAAAsAGQMGAAAAAAAAAAAAAAALABkABwAAAAAAAAAAAAAACwAZAQcAAAAAAAAAAAAAAAsAGQIHAAAAAAAAAAAAAAALABgDBwAAAAAAAAAAAAAADAAfAA8AAAAAAAAAAAAAAAwAHgAOAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACFQIAAAAAAAAAAAAAAAAAAhYDAAAAAAAAAAAAAAAAAAIXBAAAAAAAAAAAAAAAAAACGAUAAAAAAAAAAAAAAAAAAhkGAAAAAAAAAAAAAAAAAAIaBwAAAAAAAAAAAAAAAAAHEAgCAAAAAAAAAAAAAAAABREAEAAAAAAAAAAAAAAAAAcSCQIAAAAAAAAAAAAAAAAGEwESAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAcbCwIAAAAAAAAAAAAAAAADHAAAAAAAAAAAAAAAAAAABB0AAAAAAAAAAAAAAAAAAAsAEQABAAAAAAAAAAAAAAALABMBAQAAAAAAAAAAAAAACwAUAgEAAAAAAAAAAAAAAAsAGwMBAAAAAAAAAAAAAAALAAgAAgAAAAAAAAAAAAAACwAJAQIAAAAAAAAAAAAAAAsACgICAAAAAAAAAAAAAAALAAsDAgAAAAAAAAAAAAAACwAMAAMAAAAAAAAAAAAAAAsADQEDAAAAAAAAAAAAAAALAA4CAwAAAAAAAAAAAAAACwAPAwMAAAAAAAAAAAAAAAsAAwAEAAAAAAAAAAAAAAALAAQBBAAAAAAAAAAAAAAACwAFAgQAAAAAAAAAAAAAAAsABgMEAAAAAAAAAAAAAAALAAcABQAAAAAAAAAAAAAACwAcAQUAAAAAAAAAAAAAAAsAHAIFAAAAAAAAAAAAAAALABwDBQAAAAAAAAAAAAAADAAVAAYAAAAAAAAAAAAAAAwAFgEGAAAAAAAAAAAAAAAMABcCBgAAAAAAAAAAAAAADAAYAwYAAAAAAAAAAAAAAAwAGQAHAAAAAAAAAAAAAAAMABoBBwAAAAAAAAAAAAAADAAdAgcAAAAAAAAAAAAAAAwAHQMHAAAAAAAAAAAAAAAMAB8ADwAAAAAAAAAAAAAADAAeAA4AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAACHAAAAAAAAAAAAAAAAAAAAh0BAAAAAAAAAAAAAAAAAAIeAgAAAAAAAAAAAAAAAAACHwMAAAAAAAAAAAAAAAAACwAAAAEAAAAAAAAAAAAAAAsAAQEBAAAAAAAAAAAAAAALAAICAQAAAAAAAAAAAAAACwADAwEAAAAAAAAAAAAAAAwAHgACAAAAAAAAAAAAAAAMAB8BAgAAAAAAAAAAAAAADAAcAA8AAAAAAAAAAAAAAAwAHQAOAAAAAAAAAAAAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAABAAAAAQBAAAEAgAABAMAAAQEAAAEBQAABAYAAAQHAAABBAAAAQUAAAQAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAgAAAAIBAAACAgAAAgMAAAIEAAACBQAAAgYAAAIHAAACCAAAAgkAAAEEAAABBQAAAgAAAAEGAAABBwAAAQQAAAEIAAABCQAAAQoAAAELAAABDAAAAgAAAAIBAAACAgAAAgMAAAIEAAACBQAAAgYAAAIHAAABBAAAAQUAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAEGAAABBwAAAQQAAAEIAAABCQAAAQoAAAELAAABBAAAAQUAAAIAAAACAQAA", + "indexed/stable": "SA8AACwAAAABAAAAVAAAAAQAAABUAQAAHgAAADQDAACvAAAAJA4AAEkAAAABAAAAPQAAAAAAAAQEAAAAgAAAAAABAAAQABAAAAAIAEwdAAAAAAAAjDt1FwEAAAAAAAAAAQAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAnAAIADwMBAH8AAAAAAAAAEgAAAP3kp/kCAAAAAAAAAAEAAAAAAAAABwAAAAAAAAAAAAAACAAAACcAAAAAAAkAOgACABEDAQB/AAAAEgAAABQAAAAIeSzyAwAAAAAAAAABAAAAAAAAAAcAAAAAAAAAAAAAABEAAABhAAAAAAAJAD4AAgAQCAEAfwAAACYAAAAYAAAAgfpVNAQAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAaAAAAnwAAAAAABAAQAAIABwQCAH8AAAA+AAAACwAAAAEAAQIEAAgABgAAAAEAAAACAAECBAAIAAYAAAABAAAAAwABAgQACAAGAAAAAQAAAAQAAQIEAAgABgAAAAEAAAAFAAEEBAAQAAYAAAABAAAABgACAQQABAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAQQEABAABgAAAAEAAAADAAEEBAAQAAYAAAABAAAABAABBAQAEAAGAAAAAQAAAAUAAQQEABAABgAAAAEAAAAGAAEEBAAQAAYAAAABAAAABwABBAQAEAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAQQEABAABgAAAAEAAAADAAEEBAAQAAYAAAABAAAABAABBAQAEAAGAAAAAQAAAAUAAQQEABAABgAAAAEAAAAGAAIEBAAQAAYAAAABAAAABwACBAQAEAAGAAAAAQAAAA4AAgEEAAQABgAAAAEAAAAPAAIBBAAEAAYAAAABAAAAAQABBAQAEAAGAAAAAQAAAAIAAgIEAAgABgAAAAEAAAAOAAIBBAAEAAYAAAABAAAADwACAQQABAAGAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAABBAQAAAAAAAAAAAAAAAAAAQUFAAAAAAAAAAAAAAAAAAEGBgAAAAAAAAAAAAAAAAABBwcAAAAAAAAAAAAAAAAAAQgIAAAAAAAAAAAAAAAAAAEJCQAAAAAAAAAAAAAAAAABCgoAAAAAAAAAAAAAAAAAAQsLAAAAAAAAAAAAAAAAAAEMDAAAAAAAAAAAAAAAAAABDQ0AAAAAAAAAAAAAAAAAAQ4OAAAAAAAAAAAAAAAAAAIfAAAAAAAAAAAAAAAAAAACHgEAAAAAAAAAAAAAAAAAAh0CAAAAAAAAAAAAAAAAAAcPBwIAAAAAAAAAAAAAAAAFEAAPAAAAAAAAAAAAAAAABxEIAgAAAAAAAAAAAAAAAAYSAREAAAAAAAAAAAAAAAAHEwkCAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAsAEAABAAAAAAAAAAAAAAALABIBAQAAAAAAAAAAAAAACwATAAIAAAAAAAAAAAAAAAsAFAECAAAAAAAAAAAAAAALAAsAAwAAAAAAAAAAAAAACwAMAQMAAAAAAAAAAAAAAAsADQAEAAAAAAAAAAAAAAALAA4BBAAAAAAAAAAAAAAACwADAAUAAAAAAAAAAAAAAAsABAEFAAAAAAAAAAAAAAALAAUCBQAAAAAAAAAAAAAACwAGAwUAAAAAAAAAAAAAAAwAHwAPAAAAAAAAAAAAAAAMAB4ADgAAAAAAAAAAAAAADAAdAAYAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAABBAQAAAAAAAAAAAAAAAAAAQUFAAAAAAAAAAAAAAAAAAEGBgAAAAAAAAAAAAAAAAABBwcAAAAAAAAAAAAAAAAAAQgIAAAAAAAAAAAAAAAAAAEJCQAAAAAAAAAAAAAAAAABCgoAAAAAAAAAAAAAAAAAAQsLAAAAAAAAAAAAAAAAAAEMDAAAAAAAAAAAAAAAAAABDQ0AAAAAAAAAAAAAAAAAAQ4OAAAAAAAAAAAAAAAAAAEPDwAAAAAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAhECAAAAAAAAAAAAAAAAAAIfAAAAAAAAAAAAAAAAAAACHgEAAAAAAAAAAAAAAAAABxIHAgAAAAAAAAAAAAAAAAUTABIAAAAAAAAAAAAAAAAHFAgCAAAAAAAAAAAAAAAABhUBFAAAAAAAAAAAAAAAAAcWCQIAAAAAAAAAAAAAAAAHFwoCAAAAAAAAAAAAAAAAChgRAAAAAAAAAAAAAAAAAAMZAAAAAAAAAAAAAAAAAAALABMAAQAAAAAAAAAAAAAACwAVAQEAAAAAAAAAAAAAAAsAFgIBAAAAAAAAAAAAAAALABcDAQAAAAAAAAAAAAAACwALAAIAAAAAAAAAAAAAAAsADAECAAAAAAAAAAAAAAALAA0CAgAAAAAAAAAAAAAACwAOAwIAAAAAAAAAAAAAAAsACwADAAAAAAAAAAAAAAALAAwBAwAAAAAAAAAAAAAACwAPAgMAAAAAAAAAAAAAAAsAEAMDAAAAAAAAAAAAAAALAAMABAAAAAAAAAAAAAAACwAEAQQAAAAAAAAAAAAAAAsABQIEAAAAAAAAAAAAAAALAAYDBAAAAAAAAAAAAAAACwAZAAUAAAAAAAAAAAAAAAsAGQEFAAAAAAAAAAAAAAALABkCBQAAAAAAAAAAAAAACwAZAwUAAAAAAAAAAAAAAAsAGQAGAAAAAAAAAAAAAAALABkBBgAAAAAAAAAAAAAACwAZAgYAAAAAAAAAAAAAAAsAGQMGAAAAAAAAAAAAAAALABkABwAAAAAAAAAAAAAACwAZAQcAAAAAAAAAAAAAAAsAGQIHAAAAAAAAAAAAAAALABgDBwAAAAAAAAAAAAAADAAfAA8AAAAAAAAAAAAAAAwAHgAOAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAAAAAAECAgAAAAAAAAAAAAAAAAABAwMAAAAAAAAAAAAAAAAAAQQEAAAAAAAAAAAAAAAAAAEFBQAAAAAAAAAAAAAAAAABBgYAAAAAAAAAAAAAAAAAAQcHAAAAAAAAAAAAAAAAAAEICAAAAAAAAAAAAAAAAAABCQkAAAAAAAAAAAAAAAAAAQoKAAAAAAAAAAAAAAAAAAELCwAAAAAAAAAAAAAAAAABDAwAAAAAAAAAAAAAAAAAAQ0NAAAAAAAAAAAAAAAAAAEODgAAAAAAAAAAAAAAAAABDw8AAAAAAAAAAAAAAAAAAh8AAAAAAAAAAAAAAAAAAAIeAQAAAAAAAAAAAAAAAAACFQIAAAAAAAAAAAAAAAAAAhYDAAAAAAAAAAAAAAAAAAIXBAAAAAAAAAAAAAAAAAACGAUAAAAAAAAAAAAAAAAAAhkGAAAAAAAAAAAAAAAAAAIaBwAAAAAAAAAAAAAAAAAHEAgCAAAAAAAAAAAAAAAABREAEAAAAAAAAAAAAAAAAAcSCQIAAAAAAAAAAAAAAAAGEwESAAAAAAAAAAAAAAAABxQKAgAAAAAAAAAAAAAAAAcbCwIAAAAAAAAAAAAAAAADHAAAAAAAAAAAAAAAAAAABB0AAAAAAAAAAAAAAAAAAAsAEQABAAAAAAAAAAAAAAALABMBAQAAAAAAAAAAAAAACwAUAgEAAAAAAAAAAAAAAAsAGwMBAAAAAAAAAAAAAAALAAgAAgAAAAAAAAAAAAAACwAJAQIAAAAAAAAAAAAAAAsACgICAAAAAAAAAAAAAAALAAsDAgAAAAAAAAAAAAAACwAMAAMAAAAAAAAAAAAAAAsADQEDAAAAAAAAAAAAAAALAA4CAwAAAAAAAAAAAAAACwAPAwMAAAAAAAAAAAAAAAsAAwAEAAAAAAAAAAAAAAALAAQBBAAAAAAAAAAAAAAACwAFAgQAAAAAAAAAAAAAAAsABgMEAAAAAAAAAAAAAAALAAcABQAAAAAAAAAAAAAACwAcAQUAAAAAAAAAAAAAAAsAHAIFAAAAAAAAAAAAAAALABwDBQAAAAAAAAAAAAAADAAVAAYAAAAAAAAAAAAAAAwAFgEGAAAAAAAAAAAAAAAMABcCBgAAAAAAAAAAAAAADAAYAwYAAAAAAAAAAAAAAAwAGQAHAAAAAAAAAAAAAAAMABoBBwAAAAAAAAAAAAAADAAdAgcAAAAAAAAAAAAAAAwAHQMHAAAAAAAAAAAAAAAMAB8ADwAAAAAAAAAAAAAADAAeAA4AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAQEAAAAAAAAAAAAAAAAAAQICAAAAAAAAAAAAAAAAAAEDAwAAAAAAAAAAAAAAAAACHAAAAAAAAAAAAAAAAAAAAh0BAAAAAAAAAAAAAAAAAAIeAgAAAAAAAAAAAAAAAAACHwMAAAAAAAAAAAAAAAAACwAAAAEAAAAAAAAAAAAAAAsAAQEBAAAAAAAAAAAAAAALAAICAQAAAAAAAAAAAAAACwADAwEAAAAAAAAAAAAAAAwAHgACAAAAAAAAAAAAAAAMAB8BAgAAAAAAAAAAAAAADAAcAA8AAAAAAAAAAAAAAAwAHQAOAAAAAAAAAAAAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAABAAAAAQBAAAEAgAABAMAAAQEAAAEBQAABAYAAAQHAAABBAAAAQUAAAQAAAABBgAAAQcAAAEEAAABCAAAAQkAAAEKAAABCwAAAgAAAAIBAAACAgAAAgMAAAIEAAACBQAAAgYAAAIHAAACCAAAAgkAAAEEAAABBQAAAgAAAAEGAAABBwAAAQQAAAEIAAABCQAAAQoAAAELAAABDAAAAgAAAAIBAAACAgAAAgMAAAIEAAACBQAAAgYAAAIHAAABBAAAAQUAAAIAAAACAQAAAgIAAAIDAAACBAAAAgUAAAEGAAABBwAAAQQAAAEIAAABCQAAAQoAAAELAAABBAAAAQUAAAIAAAACAQAA" +} diff --git a/packages/text/tests/integration/render-policy-equivalence.test.mjs b/packages/text/tests/integration/render-policy-equivalence.test.mjs new file mode 100644 index 00000000..7dd8df6d --- /dev/null +++ b/packages/text/tests/integration/render-policy-equivalence.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; +import { threeRenderPolicyBytes } from '../../dist/three/render-policy.js'; + +const fixtureUrl = new URL('../fixtures/render-policy/hand-numbered-policy-bytes.json', import.meta.url); + +/** + * Semantic equivalence against the hand-numbered programs. Register numbers and + * operation order are private to a program's execution — the interpreter only + * requires forward-only writes — so the DSL port may renumber freely. What must + * never drift: the input tables, buffer schemas, capability sets, program + * metadata, and the expression each buffer lane receives. This test decodes both + * byte streams and compares exactly that. + */ +test('the Three render policy is semantically identical to the hand-numbered fixture', async () => { + const fixtures = JSON.parse(await readFile(fixtureUrl, 'utf8')); + for (const transform of ['direct', 'indexed']) { + for (const allocation of ['ordered', 'stable']) { + const key = `${transform}/${allocation}`; + const fixture = decodePolicy(Buffer.from(fixtures[key], 'base64')); + const current = decodePolicy(threeRenderPolicyBytes(undefined, transform, [], allocation)); + assert.equal(current.programs.length, fixture.programs.length, `${key}: program count`); + assert.deepEqual(current.capabilitySets, fixture.capabilitySets, `${key}: capability sets`); + for (const [index, expected] of fixture.programs.entries()) { + const actual = current.programs[index]; + assert.deepEqual(actual.metadata, expected.metadata, `${key}: program ${index} metadata`); + assert.deepEqual(actual.inputs, expected.inputs, `${key}: program ${index} input table`); + assert.deepEqual(actual.buffers, expected.buffers, `${key}: program ${index} buffers`); + assert.deepEqual(actual.stores, expected.stores, `${key}: program ${index} store dataflow`); + } + } + } +}); + +function decodePolicy(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const request = textShaperAbi.layouts.policyRequest; + const programLayout = textShaperAbi.layouts.policyProgram; + const operationLayout = textShaperAbi.layouts.policyOperation; + const inputLayout = textShaperAbi.layouts.policyInput; + const bufferLayout = textShaperAbi.layouts.policyBuffer; + const capabilityLayout = textShaperAbi.layouts.policyCapabilitySet; + const opcodes = textShaperAbi.policy.opcodes; + const opcodeNames = new Map(Object.entries(opcodes).map(([name, value]) => [value, name])); + + const capabilitySets = []; + const capabilityOffset = view.getUint32(request.capabilitySetsOffset, true); + for (let index = 0; index < view.getUint32(request.capabilitySetCount, true); index += 1) { + const at = capabilityOffset + index * capabilityLayout.size; + capabilitySets.push(Buffer.from(bytes.slice(at, at + capabilityLayout.size)).toString('hex')); + } + + const programs = []; + const programsOffset = view.getUint32(request.programsOffset, true); + const operationsOffset = view.getUint32(request.operationsOffset, true); + const inputsOffset = view.getUint32(request.inputsOffset, true); + const buffersOffset = view.getUint32(request.buffersOffset, true); + for (let index = 0; index < view.getUint32(request.programCount, true); index += 1) { + const at = programsOffset + index * programLayout.size; + const metadata = {}; + for (const field of [ + 'techniqueId', + 'programId', + 'capabilitySetId', + 'resourceKindMask', + 'semanticViewMask', + 'storageKeyMask', + 'paintCapabilities', + 'compositingCapabilities', + 'drawKeyMask', + 'variant', + 'allocationStrategy', + 'primitiveKind', + 'f32InputCount', + 'u32InputCount', + ]) { + const offset = at + programLayout[field]; + metadata[field] = + field === 'allocationStrategy' || field === 'f32InputCount' || field === 'u32InputCount' + ? view.getUint8(offset) + : field === 'primitiveKind' + ? view.getUint16(offset, true) + : view.getUint32(offset, true); + } + const inputStart = view.getUint32(at + programLayout.inputStart, true); + const inputCount = view.getUint16(at + programLayout.inputCount, true); + const inputs = []; + for (let input = 0; input < inputCount; input += 1) { + const inputAt = inputsOffset + (inputStart + input) * inputLayout.size; + inputs.push({ + scope: view.getUint8(inputAt + inputLayout.scope), + field: view.getUint8(inputAt + inputLayout.field), + }); + } + const bufferStart = view.getUint32(at + programLayout.bufferStart, true); + const bufferCount = view.getUint16(at + programLayout.bufferCount, true); + const buffers = []; + for (let buffer = 0; buffer < bufferCount; buffer += 1) { + const bufferAt = buffersOffset + (bufferStart + buffer) * bufferLayout.size; + buffers.push(Buffer.from(bytes.slice(bufferAt, bufferAt + bufferLayout.size)).toString('hex')); + } + + const operationStart = view.getUint32(at + programLayout.operationStart, true); + const operationCount = view.getUint16(at + programLayout.operationCount, true); + const registers = new Map(); + const stores = new Map(); + const commutative = new Set(['addF32', 'multiplyF32']); + for (let op = 0; op < operationCount; op += 1) { + const opAt = operationsOffset + (operationStart + op) * operationLayout.size; + const name = opcodeNames.get(view.getUint8(opAt + operationLayout.opcode)); + const target = view.getUint8(opAt + operationLayout.target); + const operand0 = view.getUint8(opAt + operationLayout.operand0); + const operand1 = view.getUint8(opAt + operationLayout.operand1); + const immediate0 = view.getUint32(opAt + operationLayout.immediate0, true); + if (name === 'loadF32') { + const input = inputs[operand0]; + registers.set(target, `f32(${input.scope}:${input.field})`); + } else if (name === 'loadU32') { + const input = inputs[metadata.f32InputCount + operand0]; + registers.set(target, `u32(${input.scope}:${input.field})`); + } else if (name === 'constantF32' || name === 'constantU32') { + registers.set(target, `${name}:${immediate0}`); + } else if (name === 'convertU32ToF32') { + registers.set(target, `u32ToF32(${required(registers, operand0)})`); + } else if (name === 'addF32' || name === 'subtractF32' || name === 'multiplyF32') { + let left = required(registers, operand0); + let right = required(registers, operand1); + if (commutative.has(name) && right < left) [left, right] = [right, left]; + registers.set(target, `${name}(${left}, ${right})`); + } else if (name === 'storeF32' || name === 'storeU32' || name === 'storeU16') { + stores.set(`${name}:buffer${immediate0}:lane${operand1}`, required(registers, operand0)); + } else { + throw new Error(`unexpected policy opcode ${String(name)}`); + } + } + programs.push({ metadata, inputs, buffers, stores: Object.fromEntries([...stores.entries()].sort()) }); + } + return { capabilitySets, programs }; +} + +function required(registers, register) { + const value = registers.get(register); + if (value === undefined) throw new Error(`register r${register} read before it was written`); + return value; +} diff --git a/packages/text/tests/integration/render-policy-golden.test.mjs b/packages/text/tests/integration/render-policy-golden.test.mjs index 64adb2c3..8c78b45f 100644 --- a/packages/text/tests/integration/render-policy-golden.test.mjs +++ b/packages/text/tests/integration/render-policy-golden.test.mjs @@ -5,16 +5,17 @@ import test from 'node:test'; import { threeRenderPolicyBytes } from '../../dist/three/render-policy.js'; /** - * The policy DSL must be a pure authoring-layer change: every variant of the Three - * render policy compiles to byte-identical wire records before and after. These - * digests were captured from the hand-numbered programContext programs; a digest - * change here means the wire encoding changed, not just its authoring. + * Byte identity for the DSL-authored Three render policy. Register numbering is + * program-private, so the DSL port re-pinned these digests once — with the + * semantic-equivalence test proving the input tables, buffer schemas, and per-lane + * store dataflow unchanged against the hand-numbered fixtures. From here, any + * digest drift means the compiled wire records changed and must be re-justified. */ const GOLDEN = new Map([ - ['direct/ordered', '974cfbfcb258a6fb064a65a9b74106482ca6ae58956bfb246058b7fbb0635b90'], - ['direct/stable', 'ad1030dd5c3218b7335a6c35fe665cc85b14dab3ed32fb0f028261111823963b'], - ['indexed/ordered', '7a234623f9935d21068801fb29f8e26c8dcdf1346e92a9e3c36617b18f837705'], - ['indexed/stable', '7611048f41341bbf7962fd29ffe2b9a318e5e97cbece394043265df11c91cb6d'], + ['direct/ordered', 'd8c0dc43246ec9b75a0ba38ee7d75bfd4ce38667f8f1a4a2b39fe46f6aa66964'], + ['direct/stable', '19bcd518b14608b123533eaebee66b5b6e8e509432f28b8f21d4aa16c68788c5'], + ['indexed/ordered', '2da9a64cffb939c3020dcece3368010713835e7573a29674aa2e2c9c18e717a0'], + ['indexed/stable', '9bada50f28f087b5f55df8ae502be6a059a144b0b6cb6c4f1e3bae57055fd088'], ]); test('the Three render policy compiles to its golden bytes for every variant', () => { diff --git a/packages/text/tests/types/policy-program-dsl.test.ts b/packages/text/tests/types/policy-program-dsl.test.ts new file mode 100644 index 00000000..b55681ee --- /dev/null +++ b/packages/text/tests/types/policy-program-dsl.test.ts @@ -0,0 +1,47 @@ +import { + addF32, + constantF32, + constantU32, + multiplyF32, + policyProgram, + subtractF32, + u32ToF32, + type PolicyF32Value, + type PolicyU32Value, +} from '@pmndrs/text/core'; + +// A program declares its named inputs once; every later reference is a handle, +// never a number. +const p = policyProgram({ + scope: 'strike', + bindingF32: ['bearingX', 'bearingY', 'width', 'height'] as const, + bindingU32: ['page'] as const, +}); + +const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; +const { bearingX, bearingY, width, height, page } = p.binding; + +const left: PolicyF32Value = addF32(inlineOrigin, multiplyF32(bearingX, fontSize)); +const top: PolicyF32Value = subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)); +p.storeF32(1, [left, top, multiplyF32(width, fontSize), multiplyF32(height, fontSize)]); +p.storeF32(2, [color.red, color.green, color.blue, color.alpha]); +p.storeF32(3, [u32ToF32(page), constantF32(0), constantF32(0), constantF32(0)]); +p.storeU32(14, [stableGlyphId]); +p.storeU32(15, [transformIndex]); + +const compiled = p.compile(); +void compiled.inputs; +void compiled.operations; +const f32Count: number = compiled.f32InputCount; +const u32Count: number = compiled.u32InputCount; +void f32Count; +void u32Count; + +declare const u32Value: PolicyU32Value; +// @ts-expect-error A u32 value cannot feed f32 arithmetic without an explicit conversion. +addF32(inlineOrigin, u32Value); +// @ts-expect-error An f32 value cannot be stored into a u32 buffer lane. +p.storeU32(14, [left]); +// @ts-expect-error Binding names are declared, not invented at use sites. +void p.binding.kerning; +void constantU32; From bdf96a69807d5d5986f7fe583979bccee8d54756 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 18:30:16 -0400 Subject: [PATCH 3/7] docs: record the measured policy-interpreter tail cost The scalar tail costs ~50 ns per record; overlap would save ~100 ns per tailed span. Below the D-245 admission bar at current draw-span distributions; recorded so the next profile that blames the packing pass starts from numbers. --- docs/log.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/log.md b/docs/log.md index bd6a68cf..7444dd7f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-11 +- **Policy interpreter tail measurement** — Driving the kernel-lab explicit artifact at controlled record counts: + a 4-record vector iteration costs ~50–75 ns and each scalar tail record ~50 ns, so a span of 7 records + (1 vector + 3 scalar, 282 ns) costs more than the 8-record two-vector shape (181 ns) that a tail-overlap + rewrite would produce. Overlap would therefore save roughly 100 ns per tailed draw-span — real but bounded: + a frame needs thousands of tailed spans before it reaches microseconds. Not wired per the D-245 admission + bar; revisit if a workload profile ever shows many small draw-spans dominating the packing pass. + - **Policy authoring DSL (D-250)** — Policy programs are written against named semantic and binding handles with automatic register allocation; the four Three programs ported with a decoded-bytes equivalence proof against the hand-numbered fixtures and re-pinned goldens. Wire format and interpreter unchanged. From c7658dd68c4b20bcb150f8d6566bea31f204772e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 19:07:34 -0400 Subject: [PATCH 4/7] test(benchmarks): price the policy DSL into the size budgets The DSL authoring layer rides the Three bundle and the core subpath; raw grew by comments and names while minified, gzip, and Brotli stayed inside their ceilings. Size evidence regenerates with the budgets. --- .../src/benchmark/package-size-budgets.ts | 7 ++- .../src/generated/package-sizes.json | 50 +++++++++---------- docs/packages/benchmarks.md | 2 +- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 759bd98d..caf40501 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -8,7 +8,7 @@ export const packageSizeBudgets = { // The renderer-neutral core subpath (D-249) must stay integration-free; the graph // assertion in measure-package-sizes.mts already rejects any three/tsl/react pull. 'core-subpath-js': { - rawBytes: 215_000, + rawBytes: 217_000, minifiedBytes: 148_000, gzipBytes: 38_000, brotliBytes: 32_500, @@ -47,8 +47,11 @@ export const packageSizeBudgets = { gzipBytes: 429_000, brotliBytes: 339_500, }, + // Raw rose for the policy-DSL authoring layer riding the Three bundle (D-250); the + // growth is comment- and name-dominated: minified, gzip, and Brotli stayed inside + // their existing ceilings. 'three-runtime-js': { - rawBytes: 350_000, + rawBytes: 356_000, minifiedBytes: 232_000, gzipBytes: 60_000, brotliBytes: 51_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 3ed05a57..f22b8dc4 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Renderer-neutral core JS", "status": "measured", "format": "javascript", - "sha256": "bdbdb8a9112a9c600cd9020f816c9cbe30296d3298f0ae9c1f8e0f1de55c6f6e", - "rawBytes": 207759, - "minifiedBytes": 142016, - "gzipBytes": 36140, - "brotliBytes": 30853 + "sha256": "e7c86ae4efd79d037df42116d725b7b57425e89abdf98e501f2aad7e2e1ee32a", + "rawBytes": 214611, + "minifiedBytes": 147043, + "gzipBytes": 37371, + "brotliBytes": 31793 }, { "id": "tsl-subpath-js", @@ -54,11 +54,11 @@ "label": "Three.js adapter JS", "status": "measured", "format": "javascript", - "sha256": "0a231f0b3b658eecc1ed7eb1765e66b5f1b4ce53b01fc4405540351af1830df4", - "rawBytes": 347795, - "minifiedBytes": 227757, - "gzipBytes": 59005, - "brotliBytes": 49764 + "sha256": "529ba1d2d6e2c88f6e1d8aa1844737e859dde88799c76dc6dfbeb0138d293d8d", + "rawBytes": 353908, + "minifiedBytes": 231724, + "gzipBytes": 59900, + "brotliBytes": 50581 }, { "id": "font-inter-bitmap-16-32", @@ -164,33 +164,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "8350a9c6113b00b0b3b6ad1acad12b88ffdeeff2f020075ee8a3f63f1a7f6421", - "rawBytes": 337047, - "minifiedBytes": 220443, - "gzipBytes": 57609, - "brotliBytes": 48186 + "sha256": "e369b769a1559ab697511b8df7e322aaffaf2cdbf28d7092dc32ca06b9c8940e", + "rawBytes": 343160, + "minifiedBytes": 224409, + "gzipBytes": 58409, + "brotliBytes": 48971 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "c05d0beaebc0d0aa81d79b9b7f7c5693b4d1d5c3e3757bfe8cf24ed76526b052", - "rawBytes": 337043, - "minifiedBytes": 220425, - "gzipBytes": 57590, - "brotliBytes": 48260 + "sha256": "2726614f19ef1fe3dde44f1209cb67b96c1497cff7f189e52d8c1858beaa6f5a", + "rawBytes": 343156, + "minifiedBytes": 224391, + "gzipBytes": 58394, + "brotliBytes": 48944 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "99d66c65a3c7c3a68ac5b8d0ec7dbfedc5d40bfc344ddc1b70f12d3495fbede2", - "rawBytes": 337045, - "minifiedBytes": 220509, - "gzipBytes": 57484, - "brotliBytes": 48145 + "sha256": "2655126afeabd4dc51df19973555fa53bb09057f82ec7aebbe19aa9f35b23ab0", + "rawBytes": 343158, + "minifiedBytes": 224475, + "gzipBytes": 58312, + "brotliBytes": 48989 }, { "id": "bitmap-baker-wasm", diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 70bbe6aa..1dd9a2ee 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:721158159578f963ce0e7daea5fc010667499ea17d1f1fadfb47ac07dd6e0635' +source_digest: 'sha256:e0d7642c483e1869a7838273cf9eaf0d4ef5246a0ec842227b50b993bb3451c9' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest From 681146d2bf72218db161e1616645121a46c4e6dd Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 20:53:25 -0400 Subject: [PATCH 5/7] feat(text): make technique schemas the single buffer authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each raster technique declares its physical shape once, colocated and exported: defineTechniqueSchema in core; bitmapSchema, msdfSchema, and slugSchema beside their techniques; decorationSchema and the Three policy's system buffers in the Three policy. Programs build with techniqueProgram(schema) and store through schema buffer handles, the plan executor looks buffers up by declared id — including two literal lookups the new repository gate caught in the decoration material path — and the gate forbids literal buffer identity anywhere outside the declaration sites from now on. The compiled policy bytes are byte-identical across the change: the goldens did not move, proving the authority layer is pure naming. The full contract plan (D-251) records the remaining layers: shader interfaces derived from schemas, the data-origin axis with the reserved pretext fallback technique, the external example rebuilt on the construct, and tsdown bundling with per-subpath size re-basing. --- .../src/benchmark/package-size-budgets.ts | 19 +- .../src/generated/package-sizes.json | 50 ++-- docs/log.md | 6 + docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 2 +- docs/planning/benchmark-plan.md | 2 +- docs/planning/decision-register.md | 1 + docs/planning/dirty-range-upload-research.md | 38 +-- docs/planning/font-baker-implementation.md | 26 +- docs/planning/raster-technique-contract.md | 229 ++++++++++++++++++ docs/planning/tooling-fixtures.md | 2 +- docs/planning/typegpu-api.md | 16 +- packages/text/src/core.ts | 12 + packages/text/src/core/policy-program.ts | 35 +++ packages/text/src/core/technique-schema.ts | 77 ++++++ packages/text/src/raster/bitmap-technique.ts | 46 ++++ packages/text/src/raster/msdf.ts | 76 ++++++ packages/text/src/raster/slug-technique.ts | 82 +++++++ packages/text/src/three/engine-plan-target.ts | 13 +- packages/text/src/three/render-policy.ts | 162 ++++++------- .../tests/package/schema-authority.test.mjs | 48 ++++ .../text/tests/types/technique-schema.test.ts | 49 ++++ 22 files changed, 832 insertions(+), 161 deletions(-) create mode 100644 docs/planning/raster-technique-contract.md create mode 100644 packages/text/src/core/technique-schema.ts create mode 100644 packages/text/tests/package/schema-authority.test.mjs create mode 100644 packages/text/tests/types/technique-schema.test.ts diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index caf40501..96a83b67 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -7,11 +7,14 @@ export const packageSizeBudgets = { }, // The renderer-neutral core subpath (D-249) must stay integration-free; the graph // assertion in measure-package-sizes.mts already rejects any three/tsl/react pull. + // Grew with the technique-schema authority layer (D-251): declarations, validation, + // and the schema-typed store path. Re-based when tsdown bundling lands per the + // technique contract plan. 'core-subpath-js': { - rawBytes: 217_000, - minifiedBytes: 148_000, - gzipBytes: 38_000, - brotliBytes: 32_500, + rawBytes: 222_000, + minifiedBytes: 153_000, + gzipBytes: 39_000, + brotliBytes: 33_500, }, 'tsl-subpath-js': { rawBytes: 27_000, @@ -51,10 +54,10 @@ export const packageSizeBudgets = { // growth is comment- and name-dominated: minified, gzip, and Brotli stayed inside // their existing ceilings. 'three-runtime-js': { - rawBytes: 356_000, - minifiedBytes: 232_000, - gzipBytes: 60_000, - brotliBytes: 51_000, + rawBytes: 362_000, + minifiedBytes: 238_000, + gzipBytes: 61_500, + brotliBytes: 52_000, }, 'font-inter-bitmap-16-32': { rawBytes: 3_200_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index f22b8dc4..91002836 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Renderer-neutral core JS", "status": "measured", "format": "javascript", - "sha256": "e7c86ae4efd79d037df42116d725b7b57425e89abdf98e501f2aad7e2e1ee32a", - "rawBytes": 214611, - "minifiedBytes": 147043, - "gzipBytes": 37371, - "brotliBytes": 31793 + "sha256": "d35af890d0ae2201069ddf3b7d4413cd66e17f958431d6b7779a7d0f9f4b5161", + "rawBytes": 220303, + "minifiedBytes": 151682, + "gzipBytes": 38368, + "brotliBytes": 32666 }, { "id": "tsl-subpath-js", @@ -54,11 +54,11 @@ "label": "Three.js adapter JS", "status": "measured", "format": "javascript", - "sha256": "529ba1d2d6e2c88f6e1d8aa1844737e859dde88799c76dc6dfbeb0138d293d8d", - "rawBytes": 353908, - "minifiedBytes": 231724, - "gzipBytes": 59900, - "brotliBytes": 50581 + "sha256": "4fcc72682ca242d3d209ab2cf10486c4e676eea08696fdf1cfc9113e41b35cc3", + "rawBytes": 360387, + "minifiedBytes": 236238, + "gzipBytes": 60742, + "brotliBytes": 51323 }, { "id": "font-inter-bitmap-16-32", @@ -164,33 +164,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "e369b769a1559ab697511b8df7e322aaffaf2cdbf28d7092dc32ca06b9c8940e", - "rawBytes": 343160, - "minifiedBytes": 224409, - "gzipBytes": 58409, - "brotliBytes": 48971 + "sha256": "bef3193849ab7f1312faf0f3cedb7bc202bd70f2803f5081129e1e32b95efe38", + "rawBytes": 349639, + "minifiedBytes": 228916, + "gzipBytes": 59542, + "brotliBytes": 49755 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "2726614f19ef1fe3dde44f1209cb67b96c1497cff7f189e52d8c1858beaa6f5a", - "rawBytes": 343156, - "minifiedBytes": 224391, - "gzipBytes": 58394, - "brotliBytes": 48944 + "sha256": "1da9d928fd74dbbe012be7fa8c396955e741fc833a0f85b609e22f6367a1018d", + "rawBytes": 349635, + "minifiedBytes": 228897, + "gzipBytes": 59584, + "brotliBytes": 49735 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "2655126afeabd4dc51df19973555fa53bb09057f82ec7aebbe19aa9f35b23ab0", - "rawBytes": 343158, - "minifiedBytes": 224475, - "gzipBytes": 58312, - "brotliBytes": 48989 + "sha256": "01db743c3e4edb17bf1077f2f5a38fc9b5d2ca8d87649749789d374393b6a322", + "rawBytes": 349637, + "minifiedBytes": 228991, + "gzipBytes": 59425, + "brotliBytes": 49726 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index 7444dd7f..38c3de4c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-11 +- **Technique schema authority (D-251)** — Buffer ids, lanes, and binding fields are declared once per technique + by colocated schemas; programs store through schema handles, the executor reads declared ids, and a repository + gate forbids literal buffer identity anywhere else. Policy bytes proven byte-identical across the change. The + full contract plan — shader-interface derivation, data origins including the reserved `pretext` fallback, and + the tsdown build — is recorded in docs/planning/raster-technique-contract.md. + - **Policy interpreter tail measurement** — Driving the kernel-lab explicit artifact at controlled record counts: a 4-record vector iteration costs ~50–75 ns and each scalar tail record ~50 ns, so a span of 7 records (1 vector + 3 scalar, 282 ns) costs more than the 8-record two-vector shape (181 ns) that a tail-overlap diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 1dd9a2ee..a552f48d 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:e0d7642c483e1869a7838273cf9eaf0d4ef5246a0ec842227b50b993bb3451c9' +source_digest: 'sha256:4e2ef405a86df105452471377b2b469283f9b737047c8ac27b9493a1f115c93f' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index 6a047427..45fb6246 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:bc2e4fb6f41396dcff3ab8130eb7c1e2e0bed6f2d927f1368230a3f887462ab9' +source_digest: 'sha256:6caaa25dcdefb55e1f8d9105430d55fb3a76e686b5625a6dff22e9b2a5261f78' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/benchmark-plan.md b/docs/planning/benchmark-plan.md index f94c1dbf..be981ecd 100644 --- a/docs/planning/benchmark-plan.md +++ b/docs/planning/benchmark-plan.md @@ -73,7 +73,7 @@ Status key: ✅ specified or available · 🟡 partial or conditional · ⬜ not | Harness gate | Status | Evidence required to advance | | -------------------------------------------- | :----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Canonical architecture and scenario contract | ✅ | This plan owns one target registry, one scenario registry, and one runner contract for interactive and headless surfaces. | -| Portable baker target | ✅ | `packages/text/rust/font-baker` and the app run immutable Inter 4.1 bytes through the direct-memory Wasm API with deterministic GLB evidence. | +| Portable baker target | ✅ | `packages/text/rust/font-baker` and the app run immutable Inter 4.1 bytes through the direct-memory Wasm API with deterministic GLB evidence. | | Lab shell under `apps/benchmarks` | ✅ | The responsive token/component shell defaults to the human-facing live benchmark with mode, technique, backend, and workload URL state; finite visual conformance is separate. Fixed histories report renderer-callback CPU time, FPS, and real WebGPU/WebGL2 GPU timestamps when supported, while capture/export snapshots the live contract on demand. Causal product checks own label fit, control density, horizontal overflow, and mobile/tablet/desktop flow at 390, 1,024, and 1,280 CSS pixels. | | Headless product E2E | 🟡 | A browser CLI, Vitexec, and Playwright call the same strict registry execution module. The bounded CI-safe conformance suite includes synthetic, forced-WebGL2 TSL and bitmap rendering, public React `Text` reconciliation, direct-baker, loader/Worker, HarfRust, paragraph, bidi/policy/uikit, and item-5.4 CJK lanes. Hardware-WebGPU and pending-Suspense probes remain maintainer-local, and Milestone 6 awaits its closure review. | | Package-size lane | ✅ | Independent library-mode entries produce nonzero raw/minified/gzip/Brotli initial-core, Unicode 17 analysis, lazy-validator, runtime-host, runtime-Worker, baker, and shaper JavaScript sizes plus raw/gzip/Brotli Wasm. Rollup static closures exclude dynamic chunks; the browser-core lane externalizes declared `three`, React, and R3F peers, while Worker and shaper JavaScript exclude separately measured Wasm assets. The record names its measurement host: same-host output stays exact, while every foreign-host entry must satisfy the shared complete reviewed budgets. Unicode analysis is 139,936 bytes minified and the Darwin arm64 shaper record is 32,778 bytes minified JavaScript plus 680,312 bytes optimized Wasm. | diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 03664f59..c22b3c8d 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -327,6 +327,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-239 | A known local font is directly expressible through `text bake --input --output` without authoring a discovery module or custom baking script. The package exposes one `text` executable with command-specific help and version output. First-party `--bitmap`, `--msdf`, and `--slug` flags select embedded raster resources; `--unicodes` invokes the package-owned Fontations/Skera baker Wasm before the shared `bakeFont` path so one prepared source feeds the shaping font and every raster; and `--check` performs a temporary byte-exact rebuild. `text glyphs` uses the same Wasm and Skrifa to surface Unicode mappings, exact glyph IDs, and retained `post`/CFF names as JSON or a bake-ready Unicode set without inventing semantic names. Product baking has no HarfBuzz executable dependency; pinned HarfBuzz remains an internal correctness oracle only. Runtime and R3F loading accept one nonempty tuple of raster requests for one input and return a position-preserving typed tuple of `LoadedFont` values. The font artifact is fetched and registered once while each declared raster still performs its required independent decode. Required per-technique options remain compile-time enforced. | Accepted | | D-240 | CLI, Node, and runtime Worker baking share one prepare-once pipeline. A runtime request carries normalized Unicode ranges and the complete ordered raster plan; the Worker feeds the exact prepared source to the shaping bake and every selected first-party raster, composes one canonical GLB, validates it, and transfers one artifact. Only that final artifact is eligible for Worker-owned `CacheStorage`, keyed by source, face, ranges, exact raster descriptors/keys, and contract versions. Persistence inherits the source response's reusable freshness (`max-age` or `Expires`); `no-store`, `no-cache`, missing freshness, and expired responses remain memory-only. Browser quota eviction owns storage pressure, and storage failures remain transparent misses. Every GLB producer records `asset.generator` as the publishing package identity `@pmndrs/text`. | Accepted | | D-241 | The package exposes its React integration as `@pmndrs/text/react`, matching the original public API, roadmap, and ecosystem convention. React Three Fiber remains the internal reconciler and a peer dependency, but is not encoded into the public subpath name. The stale `/r3f` export and generated entry are removed rather than retained as a second alias before publication. | Accepted | +| D-251 | Each raster technique's physical shape is declared once by a colocated, exported schema (`defineTechniqueSchema` in core; `bitmapSchema`/`msdfSchema`/`slugSchema` beside their techniques, `decorationSchema` and the Three policy's system buffers in the Three policy): buffer ids, scalar kinds, lane meanings, binding field names, and resource kinds. Policy programs build with `techniqueProgram(schema)` and store through schema buffer handles; the plan executor looks buffers up by declared id; a repository gate rejects any literal buffer lookup, literal attribute name, or parallel id const outside the declaration sites. The compiled policy bytes are proven byte-identical across the change — the schema layer is pure naming with an owner. The full cleanup plan, including shader-interface derivation, the data-origin axis with the reserved `pmndrs.pretext` fallback technique, and the tsdown build change, is recorded in [the technique contract plan](raster-technique-contract.md). | Accepted | | D-250 | Policy programs are authored through a compile-time expression DSL in `@pmndrs/text/core` (`policyProgram`, `addF32`/`subtractF32`/`multiplyF32`/`u32ToF32`, typed constants) instead of hand-numbered registers. Authors reference named semantic handles (`inlineOrigin`, `fontSize`, `color.red`) and declared binding fields (`bearingX`, `uvOriginX`, `page`); `compile()` lowers the expression graph to the same forward-only `PolicyOperation` records, allocating registers automatically with use-before-write and exhaustion as errors and deduplicating reused values. The wire format, Rust validator, and interpreter are untouched, and the u32/f32 distinction remains wire-level per operation and buffer schema — the DSL's branded value types exist only at authoring time. The four Three programs are ported with per-technique named buffer ids; a semantic-equivalence test decodes old and new bytes and proves identical input tables, buffer schemas, metadata, and per-lane store dataflow against the hand-numbered fixtures, and the byte goldens are re-pinned once over that proof. | Accepted | | D-249 | The renderer-neutral core publishes as `@pmndrs/text/core` and the technique shader library as `@pmndrs/text/tsl`. Core carries runtime shaper creation, the engine host and sessions, frame-wire serialization, render-plan and layout views, font-binding compilation, the versioned ABI, and the policy-authoring toolkit; the runtime-to-shaper bridge is public. Three-specific policy — per-technique programs, capability set, first-party buffer ids — moves from core internals to `three/render-policy.ts` and is built with the same public toolkit a third party uses. The four technique TSL node graphs, including the Slug shader tree formerly in core internals, move to `src/tsl/` under Tsl-prefixed names; the Three entry stops re-exporting shader symbols. First-party integration rigor is enforced by a scoped `no-restricted-imports` lint denying the three, tsl, and react surfaces any import from `internal/` or `generated/`. Wire contracts and behavior are unchanged; the moves are type-level, pinned by subpath type tests. | Accepted | | D-248 | Text decoration rendering lands as the first 11.18 slice, pulled forward for visual proof. Spans declare `decoration` (underline, overline, line-through; solid only — other line styles are rejected at the boundary rather than silently rendered solid). The engine cascade stamps the CSS decorating box: the declaring span's resolved font size rides the resolved decoration group, positioning derives one continuous line per decorating box from the baked `post`/`OS/2` metrics (top-of-stroke semantics), and adjacent runs with one decoration identity merge across nested font-size changes. Decoration records flow as a reserved resource-free `pmndrs.decoration` technique: plan programs declare a primitive kind in the former reserved wire field, planners admit resource-free rows and emit `PRIMITIVE_DECORATION` with zero-resource draws, underline/overline rows append before the paragraph's glyphs and line-through after so draw order matches CSS paint order, and Three realizes one shared flat-quad TSL material with no texture, decoding the packed sRGB paint through the sRGB EOTF so a text-colored line is byte-identical to its glyph ink at the framebuffer. Decorated sessions rebuild their gather output; the undecorated fast path is unchanged, verified by a same-window interleaved A/B against the pre-decoration checkpoint (−0.3%/+0.6%/−0.7%/+1.6% on cold/font-size/suffix-edit/splice; earlier apparent regressions reproduced on the checkpoint under ambient load). Retained decoration diffing and patterned line styles remain 11.18 work. | Accepted | diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md index ae73d682..14d3c333 100644 --- a/docs/planning/dirty-range-upload-research.md +++ b/docs/planning/dirty-range-upload-research.md @@ -106,12 +106,12 @@ scratch vectors rather than allocating one tracker object per frame.[^text-order `coalesce_ranges` then applies four renderer-declared controls:[^text-packing] -| Capability | First-party Three value | Effect | -| --- | ---: | --- | -| update alignment | 4 bytes | expands record ranges to legal backend alignment | -| accepted gap | max(128, 256) bytes | merges neighboring ranges when uploading the gap is cheaper than another call | -| fragmentation budget | 8 ranges | collapses excess fragments to one first-to-last span | -| whole-buffer threshold | 7,500 basis points | selects `0..live_records` when modeled partial cost reaches 75% of live bytes; later alignment may include initialized capacity padding | +| Capability | First-party Three value | Effect | +| ---------------------- | ----------------------: | --------------------------------------------------------------------------------------------------------------------------------------- | +| update alignment | 4 bytes | expands record ranges to legal backend alignment | +| accepted gap | max(128, 256) bytes | merges neighboring ranges when uploading the gap is cheaper than another call | +| fragmentation budget | 8 ranges | collapses excess fragments to one first-to-last span | +| whole-buffer threshold | 7,500 basis points | selects `0..live_records` when modeled partial cost reaches 75% of live bytes; later alignment may include initialized capacity padding | This is the Flatland policy generalized from fixed buckets into exact ranges and an explicit byte/call cost model. It is also already in the correct ownership layer: the renderer supplies capabilities as static data, while Rust makes one @@ -180,15 +180,15 @@ renderer-neutral patch ABI. ## Proposed ownership -| Concern | Owner | Reason | -| --- | --- | --- | -| semantic dependency and changed glyph records | Rust retained engine | only this layer knows what changed and why | -| exact range, gap, fragmentation, and full-live decision | Rust render-plan compiler | one deterministic decision before publication | -| cost constants and backend limits | registered renderer capability set | renderer knowledge expressed as validated data, not a callback | -| packing math | policy program executed by Rust | existing straight-line data transformation boundary | -| byte-range to Three update-range translation | Three executor | backend object and scalar-width knowledge | -| scene matrices and presentation-origin dirty tracking | Three executor | renderer-local data never seen by `text_update` | -| final GPU command submission | Three WebGPU/WebGL backend | outside the renderer-neutral plan | +| Concern | Owner | Reason | +| ------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------- | +| semantic dependency and changed glyph records | Rust retained engine | only this layer knows what changed and why | +| exact range, gap, fragmentation, and full-live decision | Rust render-plan compiler | one deterministic decision before publication | +| cost constants and backend limits | registered renderer capability set | renderer knowledge expressed as validated data, not a callback | +| packing math | policy program executed by Rust | existing straight-line data transformation boundary | +| byte-range to Three update-range translation | Three executor | backend object and scalar-width knowledge | +| scene matrices and presentation-origin dirty tracking | Three executor | renderer-local data never seen by `text_update` | +| final GPU command submission | Three WebGPU/WebGL backend | outside the renderer-neutral plan | Adding bucket size or a fixed dirty-bucket count to the public policy now would overfit Flatland. The existing byte-cost fields can express the decision more generally. A new capability field is justified only if the benchmark matrix shows @@ -269,11 +269,19 @@ three raster techniques. Backend-specific constants are acceptable; backend-spec - Partial WebGL fallback PBO texture upload has not been proven through Three r185. [^flatland-tracker]: The tracker stores first/last dirty slots in fixed typed arrays and scans the complete bucket table only at flush. + [^flatland-batch]: The audited snapshot fixes the bucket size at 256 and thresholds at 5 for matrices and 3 for interleaved/custom streams. + [^flatland-flush]: `flushDirtyRangesSystem` is scheduled after batch writes and reads `isDirty` before flushing. + [^text-packing]: `coalesce_ranges` implements gap merging, fragmentation collapse, and a basis-point whole-live threshold in `no_std + alloc` Rust. + [^text-ordered-plan]: Ordered-direct compilation derives changes from retained stable identity and content revision in physical order. + [^text-stable-plan]: Stable-indirect compilation retains physical slots and a separate 64-entry chunked logical-order buffer. + [^three-webgpu]: Three r185 WebGPU emits one `GPUQueue.writeBuffer` call for each declared update range. + [^three-webgl-fallback]: Three r185 WebGL fallback emits one `bufferSubData` call for each declared attribute range. + [^three-webgl]: Three r185 legacy WebGL merges overlapping or adjacent ranges in place before upload. diff --git a/docs/planning/font-baker-implementation.md b/docs/planning/font-baker-implementation.md index 4ff319d9..cf26d426 100644 --- a/docs/planning/font-baker-implementation.md +++ b/docs/planning/font-baker-implementation.md @@ -41,19 +41,19 @@ This page records evidence owned by `packages/text/rust/font-baker`. It does not Status key: ✅ complete for the declared slice · 🟡 in progress · ⬜ not started · ⛔ blocked -| Area | Status | Current evidence | Next gate | -| ------------------------ | :----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Package placement | ✅ | Rust/Wasm lives in `packages/text/rust/font-baker`; its TypeScript bridge, build support, and tests live in the matching `packages/text` ownership tree. `@pmndrs/text/bake` is the sole programmatic product surface and no second font-baker package is published. | Keep future baker artifacts inside `@pmndrs/text` or a baker-only source crate. | -| Portable Rust core | ✅ | Delegates SFNT/TTC and typed-table parsing to Fontations `read-fonts`, and metrics/bounds interpretation to `skrifa`; public fixtures cover container/table policy, face selection, deterministic reduction, dense extents, shaping identity, and exact Inter 4.1 output.[^fontations] | Keep every new policy branch paired with a focused regression. | -| Source preparation | ✅ | Feature-gated Skera 0.5.1 prepares canonical Unicode subsets and Skrifa enumerates exact cmap/glyph-name facts through generated `prepare` and `inspect` ABI exports. CLI, Node, and runtime Worker hosts share one functional pipeline: one normalized preparation result feeds the shaping bake and every requested Bitmap, MSDF, and Slug bake before one canonical GLB is composed and validated. An actual Inter ASCII Worker integration proves its output byte-identical to Node, and a request-boundary regression proves all three raster plans cross once. The capability is confined to baker paths: runtime/shared crates remain `no_std`, while baker-only shared crates may reuse it. | Keep new producer paths on this shared pipeline and preserve byte-identical Node/Worker output. | -| Stable Wasm ABI | ✅ | Fixed-width `#[repr(C)]` types are the sole layout authority. Build-only Rust generation derives size, alignment, and offsets with `size_of`/`align_of`/`offset_of!`, publishes portable JSON, and emits an exact typed `as const` TypeScript module. CI rejects stale generated source; production Wasm embeds no contract and exports no ABI bootstrap. | Keep compiler-derived JSON/TypeScript identity and absent-Wasm-contract checks mandatory as the ABI evolves. | -| Wasm allocator | ✅ | Both the `no_std` compatibility build and optional `std` baker artifact use pinned ABI-private dynamic Talc. Module-owned allocation registries cap caller-controlled requests at 64 MiB, reserve fallibly, retain actual `Vec` ownership, require exact pointer/length pairs, and check response sizes; forged and repeated releases have regression coverage. A 128 MiB global arena saved no meaningful transfer bytes while raising initial memory to about 129 MiB, so it is rejected. One fixed small `WasmState` still uses infallible `Box::new` once per instance because stable Rust lacks the proportionate fallible API. | Consider a request-local scratch arena only after phase profiling proves a bounded shared lifetime outside persistent Worker state. | -| TypeScript wrapper | ✅ | Implements bake, source preparation, and font inspection over one direct-memory envelope, instantiates the raw Wasm module, consumes the generated ABI constant, returns typed bytes/reports, and maps structured errors. Its package owns the sole optimized Wasm artifact and canonical URL consumed by both the offline Node host and Worker. | Preserve exact offline/Worker output parity and one-copy artifact ownership. | -| Unit verification | ✅ | Rust unit tests isolate checksum padding, outward V0 bounds encoding, and GLB alignment behavior. | Add a focused regression with every internal defect or policy branch. | -| Package integration | ✅ | Public Rust tests validate ABI fields, source/container/table policy, TTC face selection, and structured errors. Compiled-Wasm tests validate the pinned optimized module, zero imports, generated/published ABI identity, direct-memory behavior, exact and forged release metadata, and recovery. The reusable validation entry adds strict GLB framing, exact Khronos-report admission, Draft-04 required/union coverage, schema-copy identity, semantic identity, hostile payload mutation tests, and repeatable non-mutating Node `Buffer` validation. | Reuse the same hostile-input discipline at loader, shaping, paragraph, and renderer boundaries. | -| Fuzz verification | ✅ | CI runs deterministic arbitrary-byte Rust bake smoke and artifact-mutation validation smoke with seed `0x504d4e44`. Longer source/artifact mutation drivers remain stable-toolchain tools. The isolated coverage target uses mise-owned `nightly-2026-06-01`, cargo-fuzz 0.13.2, and libfuzzer-sys 0.4.13 against the same public bake boundary, seeded from pinned Inter without copying fixture bytes. Minimized failures must enter the malformed corpus. | Add package-owned targets whenever bitmap, loader, shaping, layout, or renderer trust boundaries arrive. | -| Real-font vertical slice | ✅ | Mandatory Inter, Amiri, and Noto Sans CJK E2E tests authenticate each source, bake and validate the GLB, extract the reduced SFNT, and prove complete source/reduced HarfRust equality. The Noto lane also fixes the maximum 65,535-glyph boundary, `cmap` 12/14 mappings, conditional vertical-data retention, payload arithmetic, and exact HarfBuzz 13 equality. | Preserve this evidence while raster and renderer packages consume the artifact. | -| TypeScript verification | ✅ | Generated JSON/TypeScript identity, no embedded Wasm ABI exports, zero-import, structured-error handling, declaration generation, package build, and workspace type checks pass with the pinned workspace dependencies. | Keep these checks mandatory as public host surfaces evolve. | +| Area | Status | Current evidence | Next gate | +| ------------------------ | :----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Package placement | ✅ | Rust/Wasm lives in `packages/text/rust/font-baker`; its TypeScript bridge, build support, and tests live in the matching `packages/text` ownership tree. `@pmndrs/text/bake` is the sole programmatic product surface and no second font-baker package is published. | Keep future baker artifacts inside `@pmndrs/text` or a baker-only source crate. | +| Portable Rust core | ✅ | Delegates SFNT/TTC and typed-table parsing to Fontations `read-fonts`, and metrics/bounds interpretation to `skrifa`; public fixtures cover container/table policy, face selection, deterministic reduction, dense extents, shaping identity, and exact Inter 4.1 output.[^fontations] | Keep every new policy branch paired with a focused regression. | +| Source preparation | ✅ | Feature-gated Skera 0.5.1 prepares canonical Unicode subsets and Skrifa enumerates exact cmap/glyph-name facts through generated `prepare` and `inspect` ABI exports. CLI, Node, and runtime Worker hosts share one functional pipeline: one normalized preparation result feeds the shaping bake and every requested Bitmap, MSDF, and Slug bake before one canonical GLB is composed and validated. An actual Inter ASCII Worker integration proves its output byte-identical to Node, and a request-boundary regression proves all three raster plans cross once. The capability is confined to baker paths: runtime/shared crates remain `no_std`, while baker-only shared crates may reuse it. | Keep new producer paths on this shared pipeline and preserve byte-identical Node/Worker output. | +| Stable Wasm ABI | ✅ | Fixed-width `#[repr(C)]` types are the sole layout authority. Build-only Rust generation derives size, alignment, and offsets with `size_of`/`align_of`/`offset_of!`, publishes portable JSON, and emits an exact typed `as const` TypeScript module. CI rejects stale generated source; production Wasm embeds no contract and exports no ABI bootstrap. | Keep compiler-derived JSON/TypeScript identity and absent-Wasm-contract checks mandatory as the ABI evolves. | +| Wasm allocator | ✅ | Both the `no_std` compatibility build and optional `std` baker artifact use pinned ABI-private dynamic Talc. Module-owned allocation registries cap caller-controlled requests at 64 MiB, reserve fallibly, retain actual `Vec` ownership, require exact pointer/length pairs, and check response sizes; forged and repeated releases have regression coverage. A 128 MiB global arena saved no meaningful transfer bytes while raising initial memory to about 129 MiB, so it is rejected. One fixed small `WasmState` still uses infallible `Box::new` once per instance because stable Rust lacks the proportionate fallible API. | Consider a request-local scratch arena only after phase profiling proves a bounded shared lifetime outside persistent Worker state. | +| TypeScript wrapper | ✅ | Implements bake, source preparation, and font inspection over one direct-memory envelope, instantiates the raw Wasm module, consumes the generated ABI constant, returns typed bytes/reports, and maps structured errors. Its package owns the sole optimized Wasm artifact and canonical URL consumed by both the offline Node host and Worker. | Preserve exact offline/Worker output parity and one-copy artifact ownership. | +| Unit verification | ✅ | Rust unit tests isolate checksum padding, outward V0 bounds encoding, and GLB alignment behavior. | Add a focused regression with every internal defect or policy branch. | +| Package integration | ✅ | Public Rust tests validate ABI fields, source/container/table policy, TTC face selection, and structured errors. Compiled-Wasm tests validate the pinned optimized module, zero imports, generated/published ABI identity, direct-memory behavior, exact and forged release metadata, and recovery. The reusable validation entry adds strict GLB framing, exact Khronos-report admission, Draft-04 required/union coverage, schema-copy identity, semantic identity, hostile payload mutation tests, and repeatable non-mutating Node `Buffer` validation. | Reuse the same hostile-input discipline at loader, shaping, paragraph, and renderer boundaries. | +| Fuzz verification | ✅ | CI runs deterministic arbitrary-byte Rust bake smoke and artifact-mutation validation smoke with seed `0x504d4e44`. Longer source/artifact mutation drivers remain stable-toolchain tools. The isolated coverage target uses mise-owned `nightly-2026-06-01`, cargo-fuzz 0.13.2, and libfuzzer-sys 0.4.13 against the same public bake boundary, seeded from pinned Inter without copying fixture bytes. Minimized failures must enter the malformed corpus. | Add package-owned targets whenever bitmap, loader, shaping, layout, or renderer trust boundaries arrive. | +| Real-font vertical slice | ✅ | Mandatory Inter, Amiri, and Noto Sans CJK E2E tests authenticate each source, bake and validate the GLB, extract the reduced SFNT, and prove complete source/reduced HarfRust equality. The Noto lane also fixes the maximum 65,535-glyph boundary, `cmap` 12/14 mappings, conditional vertical-data retention, payload arithmetic, and exact HarfBuzz 13 equality. | Preserve this evidence while raster and renderer packages consume the artifact. | +| TypeScript verification | ✅ | Generated JSON/TypeScript identity, no embedded Wasm ABI exports, zero-import, structured-error handling, declaration generation, package build, and workspace type checks pass with the pinned workspace dependencies. | Keep these checks mandatory as public host surfaces evolve. | The portable TypeScript package remains intentionally internal. The public `@pmndrs/text/bake` Node subpath wraps it without exposing the raw allocation protocol; the runtime path remains a dynamically imported Worker host over the same core. diff --git a/docs/planning/raster-technique-contract.md b/docs/planning/raster-technique-contract.md new file mode 100644 index 00000000..8e61773c --- /dev/null +++ b/docs/planning/raster-technique-contract.md @@ -0,0 +1,229 @@ +--- +type: Research Concept +title: Raster technique contract and single-authority cleanup +description: Defines one authoritative, colocated declaration per raster technique — schema, shader interface, binding, data origin, and policy program — with package subpaths as the reasoning and tree-shaking boundaries. +documentation_type: explanation +status: draft +tags: [planning, raster, technique, policy, tsl, typegpu, boundaries] +sources: + - id: raster-technique-api + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: typegpu-shader-authority + resource: typegpu-first-shader-authority.md + title: TypeGPU-first shader authority + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration proof + - id: technique-example + resource: ../../packages/glyph-example-raster/src/raster.ts + title: External raster technique example + - id: core-policy-dsl + resource: ../../packages/text/src/core/policy-program.ts + title: Policy-program expression DSL + - id: three-policy + resource: ../../packages/text/src/three/render-policy.ts + title: Three render policy programs + - id: font-binding + resource: ../../packages/text/src/core/font-binding.ts + title: Font-binding compiler and per-technique tables + - id: plan-executor + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three command-buffer executor +generated: + by: anthropic-claude/fable-5 + at: '2026-08-12T00:00:00Z' +--- + +# Raster technique contract and single-authority cleanup + +A raster technique is the unit of extensibility this library promises: Bitmap, MSDF, and Slug are the first-party +proofs, `glyph-example-raster` is the external proof, and TypeGPU integrations are the next consumer. Today a +technique passes every test while its definition is smeared across six sites that agree only by convention. This +plan makes each technique one colocated, authoritative declaration; makes package subpaths the reasoning and +tree-shaking boundaries; and states the proof obligations that keep the contract honest. + +## The sin, measured + +Where "Bitmap" lives today — six sites, four of which repeat schema knowledge the others cannot see: + +```mermaid +flowchart TB + subgraph raster ["@pmndrs/text/raster/bitmap"] + contract["technique object\ndescriptor · rasterKey · decode"] + end + subgraph core ["@pmndrs/text/core"] + binding["font-binding.ts\ncompileBitmap: field lambdas\n(order = implicit schema)"] + end + subgraph tsl ["@pmndrs/text/tsl"] + shader["bitmap-shader.ts\nreads uvOrigin, uvSize, color\n(names = implicit schema)"] + end + subgraph three ["@pmndrs/text/three"] + policy["render-policy.ts\nBITMAP_COLOR = 5\n(ids = declared here)"] + exec["engine-plan-target.ts\nbyPolicyId.get(1)\n(ids = known again, by hand)"] + material["material wiring\n_pmndrsText_5 → color node\n(ids = known a third time)"] + end + subgraph bakers ["@pmndrs/text/bakers/bitmap"] + baker["baker + validator\n(artifact schema)"] + end + contract -.-> binding + binding -. "field order must match" .-> policy + policy -. "buffer ids must match" .-> exec + policy -. "buffer ids must match" .-> material + shader -. "lane meaning must match" .-> policy + baker -.-> contract +``` + +Every dotted edge is an agreement with no owner. The D-250 DSL fixed this _inside_ a policy program; the same +disease persists at every seam between packages. The decoration slice's sRGB bug and the gather-row leak both grew +in these seams: the information existed, but no single artifact carried it. + +## Boundaries: subpaths are the reasoning units + +The rule this plan adopts: **you can reason about the software path by path, and each subpath is a tree-shaking +point.** A subpath owns its concepts, exports its contracts, and consumes other subpaths only through their public +entries — enforced by lint (D-249) and measured by per-subpath size entries. + +| subpath | owns | must never know | +| ------------------------- | ---------------------------------------------------------------------- | ----------------------------------------- | +| `@pmndrs/text` | fonts, text, styles, runtime | GPUs, shaders, plans | +| `@pmndrs/text/core` | engine host, frame wire, plan view, policy authoring, binding compiler | any renderer, any shader language | +| `@pmndrs/text/raster/` | **the technique declaration** (this plan's construct) | Three, TSL node graphs | +| `@pmndrs/text/tsl` | TSL realizations of declared shader interfaces | buffer ids, binding field order | +| `@pmndrs/text/three` | scene lifecycle, plan execution, material realization | field meanings beyond the declared schema | +| `@pmndrs/text/bakers/` | artifact production and validation | rendering | + +## The construct: one technique, one declaration + +The authority question — "if the shaders define it, we need a construct for shaders and buffers; if the engine +defines it, it goes there" — resolves to neither: **the technique defines it**, because the schema is precisely +the meeting point of what the technique's programs produce and what its shaders consume. The engine stays agnostic +(it validates shape and carries ids opaquely — correct today, unchanged). The construct: + +```ts +// @pmndrs/text/raster/bitmap — the ONLY place bitmap's shape is stated. +export const bitmapSchema = defineTechniqueSchema({ + technique: 'pmndrs.bitmap', + scope: 'strike', + binding: { + f32: ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY'], + u32: ['page'], + }, + buffers: { + origin: { id: 1, scalar: 'f32', lanes: ['inlineOrigin', 'blockOrigin'] }, + size: { id: 2, scalar: 'f32', lanes: ['width', 'height'] }, + uvOrigin: { id: 3, scalar: 'f32', lanes: ['u', 'v'] }, + uvSize: { id: 4, scalar: 'f32', lanes: ['uSpan', 'vSpan'] }, + color: { id: 5, scalar: 'f32', lanes: ['red', 'green', 'blue', 'alpha'] }, + page: { id: 6, scalar: 'u32', lanes: ['page'] }, + }, + resources: { atlas: { kind: 'texture-array', format: 'r8unorm' } }, +}); +``` + +Everything else _derives_ from the schema instead of restating it: + +```ts +// Policy program (any renderer's policy file): stores take schema buffers, not integers. +p.store(bitmapSchema.buffers.origin, [left, top]); + +// Binding compiler: fields are declared by the same names the program loads. +compileFontBinding(bitmapSchema, { bearingX: (row) => …, uvOriginX: (row) => … }); + +// Executor: lookups are named, not remembered. +buffers.get(bitmapSchema.buffers.origin); // replaces byPolicyId.get(1) + +// Shader interface: the technique declares WHAT a shader receives and must return — +// independent of shader language. TSL and TypeGPU are two realizations of one interface. +export interface BitmapShaderInterface { + instance: SchemaNodes; // typed per-buffer/lane nodes + resources: { atlas: TextureHandle }; + output: { position; color; opacity; coverage }; +} +``` + +A wrong id, a renamed lane, or a program/shader mismatch becomes a type error at the declaration site — the same +move D-250 made for registers, applied to the seams. + +## Shader authority: who owns the data under TypeGPU + +With the schema owned by the technique, the shader-language question becomes small: **a shader library owns only +the realization of a declared interface.** `@pmndrs/text/tsl` implements `BitmapShaderInterface` with TSL nodes; +a future `@pmndrs/text/typegpu` implements the same interface with TypeGPU; both import the technique's schema and +neither owns any data. The data path (binding tables, storage buffers, patch application) is core + the renderer +integration; the shader receives typed views it did not define. This is the answer typegpu-first-shader-authority +needs and the reason the shader library moved out of `three/`: shader realizations are per-language, schemas are +per-technique, and they must not be the same file. + +## Data origin: the axis the contract is missing + +`decode(font, raster: RegisteredRaster)` hard-codes one origin: an artifact raster entry. The offscreen-canvas +technique — rasterize glyphs with the platform text stack, render from that texture — is rendering-expressible +today but **data-inexpressible**: there is no artifact entry to resolve. The contract gains an explicit origin +axis: + +| origin | source | examples | +| ----------------- | ---------------------------------------- | ------------------------------ | +| `artifact` | raster entry in the baked GLB | Bitmap, MSDF, Slug today | +| `worker-baked` | runtime bake producing a GLB | existing opt-in path | +| `runtime-sourced` | technique-supplied provider, no artifact | the planned `pretext` fallback | + +`runtime-sourced` techniques implement `provide(font, signal) → Data` instead of `decode`; the loader treats the +provider as the raster resolution step. + +### The planned `pretext` technique + +The canonical `runtime-sourced` case, named now so the contract is designed against it: **pretext** is the +old-school fallback — the browser shapes and renders whole lines to an offscreen canvas, and rendering samples +line UVs from that texture. It bypasses the Wasm shaper entirely, for consumers who do not want to pay for full +shaping. That makes it the most demanding test of the contract on two axes at once: records are per-line rather +than per-glyph (the schema construct must not assume glyph granularity), and its data origin is pure runtime. Not +required today; this plan reserves the name `pmndrs.pretext` and keeps both axes explicit so pretext lands as a +technique declaration, not a special case. + +## Build: bundle the boundaries we reason about + +Sizes are currently measured over `tsc`-emitted files, so comments and identifier length leak into raw +measurements (the D-250 budget bump was comment growth, not code). The dist should be what we measure and ship: + +- **tsdown** bundles each export-map entry to one ESM file with source maps; comments stripped from output. +- The export map keeps exactly the boundary table above — one bundle per subpath, so the size entries measure the + true tree-shaking units and `raw ≈ minified` stops lying about growth. +- Declaration output remains per-entry `.d.ts` (the dist-declarations gate stays). + +## DSL: more functional, not bigger + +The policy DSL stays the authoring layer, tightened along the review's direction: free combinators over builder +methods where the seams allow (`store(schemaBuffer, values)` as a pure description, `compileProgram(schema, +stores)` as the single effectful step), and schema-typed stores replacing raw buffer integers. The size cost of +the DSL is then carried by `core` only; renderers that register a precompiled policy byte blob ship none of it. + +## Proof obligations + +The contract is sound when these hold, each as a permanent gate: + +1. **Byte goldens + decoded equivalence** (exists, D-250): schema-derived programs compile to pinned bytes; the + equivalence decoder proves dataflow when goldens re-pin. +2. **Subpath isolation sizes** (exists, D-249): per-subpath bundles with graph assertions — core pulls no + renderer, tsl pulls no scene integration. +3. **The external example compiles from the contract alone**: `glyph-example-raster` is rebuilt on the construct + and must import nothing undocumented — it _is_ the documentation's test. +4. **The canvas technique exists**: a `runtime-sourced` technique rendering platform-rasterized glyphs, proving + the data-origin axis end-to-end in the browser lane. +5. **The schema is the only witness**: grep-level gate — no `_pmndrsText_`, no `byPolicyId.get()`, + no parallel id consts outside technique declarations. + +## Migration stack + +Dependency-ordered layers, each green standalone: + +1. `core`: `defineTechniqueSchema` + schema-typed `store`/binding/lookup APIs (additive; D-250 goldens pinned). +2. `raster/`: bitmap, msdf, slug, decoration schemas declared; policy/binding/executor consume them; hardcoded + ids die; goldens re-pin over the equivalence proof. +3. `tsl`: shader interfaces derived from schemas; TSL graphs become explicit realizations. +4. `glyph-example-raster` rebuilt on the construct; docs rewritten from it. +5. `runtime-sourced` origin + canvas-texture technique with a browser proof. +6. Build: tsdown bundling, comment-stripped dist, per-subpath size re-pinning. + +Each layer updates this document's status and the decision register; the roadmap's 11.8–11.10 items consume the +result (TypeGPU realizations and the external-package proof both sit directly on this contract). diff --git a/docs/planning/tooling-fixtures.md b/docs/planning/tooling-fixtures.md index b5dce256..d4bb2301 100644 --- a/docs/planning/tooling-fixtures.md +++ b/docs/planning/tooling-fixtures.md @@ -49,7 +49,7 @@ Use one statically selected, redistributable OpenType font for the first complet | Exact source, version, license, and SHA-256 | ✅ | The checked-in manifest binds the upstream release/commit, archive member, OFL-1.1 text, byte sizes, and hashes. | | Portable baker local real-font lane | ✅ | The package E2E verifies the canonical bytes and cannot skip or substitute an environment font. | | Required pull-request real-font lane | ✅ | The checked-in font and license run without network access or ambient machine state. | -| Benchmark-app product scenario | ✅ | Interactive and browser-headless paths run the canonical bytes through `@pmndrs/text/bake`; local upload is an explicit override. | +| Benchmark-app product scenario | ✅ | Interactive and browser-headless paths run the canonical bytes through `@pmndrs/text/bake`; local upload is an explicit override. | | Font-baker fuzzing | ✅ | Fixed-seed Rust and validator-mutation smoke tests run hermetically; longer mutation drivers and pinned cargo-fuzz/libFuzzer exercise the public boundaries, with minimized failures promoted into the checked-in malformed corpus. | | GLB-to-HarfRust shaping | ✅ | Canonical Inter is independently validated, registered through `FontRegistry`, contributes exactly its retained 147,192-byte SFNT, 23,496-byte extents, and 368-byte availability views, and matches every pinned HarfRust field through both public batch calls. | | Complex-script source/GLB equivalence | ✅ | Amiri Regular 1.002 is pinned by immutable Google Fonts and upstream commits. Source HarfRust equals GLB-extracted reduced-SFNT HarfRust exactly; pinned HarfBuzz 13 independently agrees on every Arabic/Latin glyph field. | diff --git a/docs/planning/typegpu-api.md b/docs/planning/typegpu-api.md index 0554e603..8271608f 100644 --- a/docs/planning/typegpu-api.md +++ b/docs/planning/typegpu-api.md @@ -313,15 +313,23 @@ interface TypeGpuParagraphState { readonly visible: boolean; } -interface TypeGpuParagraphBatchTarget - extends ParagraphBatchTarget { +interface TypeGpuParagraphBatchTarget< + Technique extends AnyRasterTechnique, + Variant, + Draw, + Revision, +> extends ParagraphBatchTarget { readonly root: TgpuRoot; setParagraphState(paragraph: ParagraphId, state: TypeGpuParagraphState | undefined): void; encode(pass: GPURenderPassEncoder, revision: Revision, frame: TypeGpuFrame): void; } -interface TypeGpuRasterProgram - extends AnyTypeGpuRasterProgram { +interface TypeGpuRasterProgram< + Technique extends AnyRasterTechnique, + Variant, + Draw, + Revision, +> extends AnyTypeGpuRasterProgram { createTarget(options: { readonly root: TgpuRoot; readonly technique: Technique; diff --git a/packages/text/src/core.ts b/packages/text/src/core.ts index 6ecde0b0..bf0dd962 100644 --- a/packages/text/src/core.ts +++ b/packages/text/src/core.ts @@ -74,12 +74,24 @@ export { type PolicyTransformMode, type ProgramContext, } from './core/render-policy.js'; +export { + definePolicyBuffers, + defineTechniqueSchema, + type PolicyBufferDeclaration, + type PolicyBufferDeclarations, + type PolicyScalarKind, + type TechniqueBindingDeclaration, + type TechniqueResourceDeclaration, + type TechniqueSchema, + type TechniqueSchemaDeclaration, +} from './core/technique-schema.js'; export { addF32, constantF32, constantU32, multiplyF32, policyProgram, + techniqueProgram, subtractF32, u32ToF32, type CompiledPolicyProgramBody, diff --git a/packages/text/src/core/policy-program.ts b/packages/text/src/core/policy-program.ts index 185f1089..3f1884f8 100644 --- a/packages/text/src/core/policy-program.ts +++ b/packages/text/src/core/policy-program.ts @@ -1,5 +1,6 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; import type { PolicyInput, PolicyInputScope, PolicyOperation } from './render-policy.js'; +import type { PolicyBufferDeclaration, TechniqueSchema } from './technique-schema.js'; /** * Expression DSL over the policy-program register machine. Authors reference named @@ -123,11 +124,18 @@ export interface CompiledPolicyProgramBody { export interface PolicyProgramBuilder { readonly semantics: PolicyProgramSemantics; readonly binding: Readonly & Record>; + /** Store into a declared buffer; value kinds and lane count come from the declaration. */ + store( + buffer: Buffer, + lanes: Buffer['scalar'] extends 'f32' ? readonly PolicyF32Value[] : readonly PolicyU32Value[], + ): void; storeF32(buffer: number, lanes: readonly PolicyF32Value[]): void; storeU32(buffer: number, lanes: readonly PolicyU32Value[]): void; compile(): CompiledPolicyProgramBody; } +type BindingNames = Names extends readonly string[] ? Names : readonly []; + interface StoreRecord { readonly opcode: number; readonly buffer: number; @@ -135,6 +143,22 @@ interface StoreRecord { readonly node: Node; } +/** Build a program against one technique's authoritative schema. */ +export function techniqueProgram< + const Buffers extends import('./technique-schema.js').PolicyBufferDeclarations, + const Binding extends import('./technique-schema.js').TechniqueBindingDeclaration, +>( + schema: TechniqueSchema, + options: { readonly inverseFontSize?: boolean } = {}, +): PolicyProgramBuilder, BindingNames> { + return policyProgram({ + scope: schema.scope, + bindingF32: (schema.binding.f32 ?? []) as BindingNames, + bindingU32: (schema.binding.u32 ?? []) as BindingNames, + ...(options.inverseFontSize === undefined ? {} : { inverseFontSize: options.inverseFontSize }), + }); +} + export function policyProgram< const F32 extends readonly string[] = readonly [], const U32 extends readonly string[] = readonly [], @@ -196,6 +220,17 @@ export function policyProgram< return { semantics, binding: binding as PolicyProgramBuilder['binding'], + store(buffer, lanes) { + if (lanes.length !== buffer.lanes.length) { + throw new RangeError( + `buffer ${buffer.id} declares ${buffer.lanes.length} lanes (${buffer.lanes.join(', ')}); got ${lanes.length} values`, + ); + } + const opcode = buffer.scalar === 'f32' ? opcodes.storeF32 : opcodes.storeU32; + for (const [lane, value] of lanes.entries()) { + stores.push({ opcode, buffer: buffer.id, lane, node: nodeOf(value) }); + } + }, storeF32(buffer, lanes) { for (const [lane, value] of lanes.entries()) { stores.push({ opcode: opcodes.storeF32, buffer, lane, node: nodeOf(value) }); diff --git a/packages/text/src/core/technique-schema.ts b/packages/text/src/core/technique-schema.ts new file mode 100644 index 00000000..680b91e0 --- /dev/null +++ b/packages/text/src/core/technique-schema.ts @@ -0,0 +1,77 @@ +/** + * The single authority for a raster technique's physical shape. A schema declares — + * once, colocated with the technique — the buffer ids, scalar kinds, and lane + * meanings that its policy programs produce and its shader realizations consume. + * Policy stores, binding compilers, plan executors, and shader interfaces all + * derive from the declaration; none of them restate it. + */ + +export type PolicyScalarKind = 'f32' | 'u32'; + +export interface PolicyBufferDeclaration { + /** Wire buffer id — nonzero, unique within the owning program. */ + readonly id: number; + readonly scalar: PolicyScalarKind; + /** One name per lane; the lane count is the buffer's vector width. */ + readonly lanes: readonly string[]; +} + +export type PolicyBufferDeclarations = Readonly>; + +/** Validate and freeze a named buffer set: nonzero unique ids, at least one lane each. */ +export function definePolicyBuffers(buffers: Buffers): Buffers { + const seen = new Set(); + for (const [name, buffer] of Object.entries(buffers)) { + if (!Number.isSafeInteger(buffer.id) || buffer.id <= 0 || buffer.id > 0xffff) { + throw new RangeError(`policy buffer "${name}" needs a nonzero u16 id`); + } + if (seen.has(buffer.id)) throw new TypeError(`policy buffer "${name}" reuses id ${buffer.id}`); + seen.add(buffer.id); + if (buffer.lanes.length === 0 || buffer.lanes.length > 4) { + throw new RangeError(`policy buffer "${name}" needs one to four named lanes`); + } + } + return buffers; +} + +export interface TechniqueBindingDeclaration { + readonly f32?: readonly string[]; + readonly u32?: readonly string[]; +} + +export interface TechniqueResourceDeclaration { + readonly kind: string; + readonly format?: string; +} + +export interface TechniqueSchemaDeclaration< + Buffers extends PolicyBufferDeclarations = PolicyBufferDeclarations, + Binding extends TechniqueBindingDeclaration = TechniqueBindingDeclaration, +> { + /** Wire identity string, e.g. `pmndrs.bitmap`. */ + readonly technique: string; + /** Binding input scope the technique's per-glyph data arrives through. */ + readonly scope: 'glyph' | 'strike' | 'resource'; + readonly binding: Binding; + readonly buffers: Buffers; + readonly resources?: Readonly>; +} + +export interface TechniqueSchema< + Buffers extends PolicyBufferDeclarations = PolicyBufferDeclarations, + Binding extends TechniqueBindingDeclaration = TechniqueBindingDeclaration, +> extends TechniqueSchemaDeclaration {} + +/** Validate and freeze one technique's authoritative schema. */ +export function defineTechniqueSchema< + const Buffers extends PolicyBufferDeclarations, + const Binding extends TechniqueBindingDeclaration, +>(declaration: TechniqueSchemaDeclaration): TechniqueSchema { + if (declaration.technique.length === 0) throw new TypeError('technique schemas need a wire identity'); + definePolicyBuffers(declaration.buffers); + const names = [...(declaration.binding.f32 ?? []), ...(declaration.binding.u32 ?? [])]; + if (new Set(names).size !== names.length) { + throw new TypeError(`technique "${declaration.technique}" repeats a binding field name`); + } + return declaration; +} diff --git a/packages/text/src/raster/bitmap-technique.ts b/packages/text/src/raster/bitmap-technique.ts index d5285a70..91f2d1bd 100644 --- a/packages/text/src/raster/bitmap-technique.ts +++ b/packages/text/src/raster/bitmap-technique.ts @@ -194,3 +194,49 @@ async function decodeBitmapData(font: RegisteredFont, raster: RegisteredRaster): } return { strikes, ...(coverage === undefined ? {} : { coverage: coverage.bits }) }; } + +import { defineTechniqueSchema, type TechniqueSchema } from '../core/technique-schema.js'; + +/** + * The authoritative physical shape of the Bitmap technique: binding field order matches + * the strike tables the binding compiler emits; buffer ids and lanes are the contract + * every policy program and shader realization derives from. + */ +export const bitmapSchema: TechniqueSchema< + { + readonly origin: { + readonly id: 1; + readonly scalar: 'f32'; + readonly lanes: readonly ['inlineOrigin', 'blockOrigin']; + }; + readonly size: { readonly id: 2; readonly scalar: 'f32'; readonly lanes: readonly ['width', 'height'] }; + readonly uvOrigin: { readonly id: 3; readonly scalar: 'f32'; readonly lanes: readonly ['u', 'v'] }; + readonly uvSize: { readonly id: 4; readonly scalar: 'f32'; readonly lanes: readonly ['uSpan', 'vSpan'] }; + readonly color: { + readonly id: 5; + readonly scalar: 'f32'; + readonly lanes: readonly ['red', 'green', 'blue', 'alpha']; + }; + readonly page: { readonly id: 6; readonly scalar: 'u32'; readonly lanes: readonly ['page'] }; + }, + { + readonly f32: readonly ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY']; + readonly u32: readonly ['page']; + } +> = defineTechniqueSchema({ + technique: 'pmndrs.bitmap', + scope: 'strike', + binding: { + f32: ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY'], + u32: ['page'], + }, + buffers: { + origin: { id: 1, scalar: 'f32', lanes: ['inlineOrigin', 'blockOrigin'] }, + size: { id: 2, scalar: 'f32', lanes: ['width', 'height'] }, + uvOrigin: { id: 3, scalar: 'f32', lanes: ['u', 'v'] }, + uvSize: { id: 4, scalar: 'f32', lanes: ['uSpan', 'vSpan'] }, + color: { id: 5, scalar: 'f32', lanes: ['red', 'green', 'blue', 'alpha'] }, + page: { id: 6, scalar: 'u32', lanes: ['page'] }, + }, + resources: { atlas: { kind: 'texture-array', format: 'r8unorm' } }, +}); diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index 8a4b10e8..31067e1c 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -203,3 +203,79 @@ function validateMsdfPageDirectory(value: JsonValue, pageIndex: number): void { throw new TypeError('MSDF V0 pages accept only the lossless rgba8unorm baseline'); } } + +import { defineTechniqueSchema, type TechniqueSchema } from '../core/technique-schema.js'; + +/** + * The authoritative physical shape of the MSDF technique. + */ +export const msdfSchema: TechniqueSchema< + { + readonly rect: { + readonly id: 1; + readonly scalar: 'f32'; + readonly lanes: readonly ['left', 'top', 'width', 'height']; + }; + readonly uvRect: { + readonly id: 2; + readonly scalar: 'f32'; + readonly lanes: readonly ['u0', 'v0', 'uSpan', 'vSpan']; + }; + readonly uvBounds: { + readonly id: 3; + readonly scalar: 'f32'; + readonly lanes: readonly ['u0', 'v0', 'uMax', 'vMax']; + }; + readonly color: { + readonly id: 4; + readonly scalar: 'f32'; + readonly lanes: readonly ['red', 'green', 'blue', 'alpha']; + }; + readonly effectA: { readonly id: 5; readonly scalar: 'f32'; readonly lanes: readonly ['x', 'y', 'z', 'w'] }; + readonly effectB: { readonly id: 6; readonly scalar: 'f32'; readonly lanes: readonly ['x', 'y', 'z', 'w'] }; + readonly page: { readonly id: 7; readonly scalar: 'f32'; readonly lanes: readonly ['x', 'y', 'z', 'page'] }; + }, + { + readonly f32: readonly [ + 'bearingX', + 'bearingY', + 'width', + 'height', + 'uvOriginX', + 'uvOriginY', + 'uvSizeX', + 'uvSizeY', + 'uvMaxX', + 'uvMaxY', + ]; + readonly u32: readonly ['page']; + } +> = defineTechniqueSchema({ + technique: 'pmndrs.msdf', + scope: 'glyph', + binding: { + f32: [ + 'bearingX', + 'bearingY', + 'width', + 'height', + 'uvOriginX', + 'uvOriginY', + 'uvSizeX', + 'uvSizeY', + 'uvMaxX', + 'uvMaxY', + ], + u32: ['page'], + }, + buffers: { + rect: { id: 1, scalar: 'f32', lanes: ['left', 'top', 'width', 'height'] }, + uvRect: { id: 2, scalar: 'f32', lanes: ['u0', 'v0', 'uSpan', 'vSpan'] }, + uvBounds: { id: 3, scalar: 'f32', lanes: ['u0', 'v0', 'uMax', 'vMax'] }, + color: { id: 4, scalar: 'f32', lanes: ['red', 'green', 'blue', 'alpha'] }, + effectA: { id: 5, scalar: 'f32', lanes: ['x', 'y', 'z', 'w'] }, + effectB: { id: 6, scalar: 'f32', lanes: ['x', 'y', 'z', 'w'] }, + page: { id: 7, scalar: 'f32', lanes: ['x', 'y', 'z', 'page'] }, + }, + resources: { atlas: { kind: 'texture-array', format: 'rgba8unorm' } }, +}); diff --git a/packages/text/src/raster/slug-technique.ts b/packages/text/src/raster/slug-technique.ts index 1c935e74..a9a86768 100644 --- a/packages/text/src/raster/slug-technique.ts +++ b/packages/text/src/raster/slug-technique.ts @@ -347,3 +347,85 @@ function checkedBytes(left: number, right: number): number { } return total; } + +import { defineTechniqueSchema, type TechniqueSchema } from '../core/technique-schema.js'; + +/** + * The authoritative physical shape of the Slug technique. + */ +export const slugSchema: TechniqueSchema< + { + readonly rect: { + readonly id: 1; + readonly scalar: 'f32'; + readonly lanes: readonly ['left', 'top', 'width', 'height']; + }; + readonly planeRect: { + readonly id: 2; + readonly scalar: 'f32'; + readonly lanes: readonly ['left', 'top', 'width', 'height']; + }; + readonly bandTransform: { + readonly id: 3; + readonly scalar: 'f32'; + readonly lanes: readonly ['scaleX', 'scaleY', 'offsetX', 'offsetY']; + }; + readonly color: { + readonly id: 4; + readonly scalar: 'f32'; + readonly lanes: readonly ['red', 'green', 'blue', 'alpha']; + }; + readonly inverseFontSize: { + readonly id: 5; + readonly scalar: 'f32'; + readonly lanes: readonly ['inverseFontSize', 'unused1', 'unused2', 'unused3']; + }; + readonly tableStarts: { + readonly id: 6; + readonly scalar: 'u32'; + readonly lanes: readonly ['curveStart', 'headerStart', 'referenceStart', 'bandStart']; + }; + readonly bandCounts: { + readonly id: 7; + readonly scalar: 'u32'; + readonly lanes: readonly ['horizontalBands', 'verticalBands', 'unused2', 'unused3']; + }; + }, + { + readonly f32: readonly [ + 'bearingX', + 'bearingY', + 'width', + 'height', + 'bandScaleX', + 'bandScaleY', + 'bandOffsetX', + 'bandOffsetY', + ]; + readonly u32: readonly [ + 'curveStart', + 'headerStart', + 'referenceStart', + 'bandStart', + 'horizontalBands', + 'verticalBands', + ]; + } +> = defineTechniqueSchema({ + technique: 'pmndrs.slug', + scope: 'glyph', + binding: { + f32: ['bearingX', 'bearingY', 'width', 'height', 'bandScaleX', 'bandScaleY', 'bandOffsetX', 'bandOffsetY'], + u32: ['curveStart', 'headerStart', 'referenceStart', 'bandStart', 'horizontalBands', 'verticalBands'], + }, + buffers: { + rect: { id: 1, scalar: 'f32', lanes: ['left', 'top', 'width', 'height'] }, + planeRect: { id: 2, scalar: 'f32', lanes: ['left', 'top', 'width', 'height'] }, + bandTransform: { id: 3, scalar: 'f32', lanes: ['scaleX', 'scaleY', 'offsetX', 'offsetY'] }, + color: { id: 4, scalar: 'f32', lanes: ['red', 'green', 'blue', 'alpha'] }, + inverseFontSize: { id: 5, scalar: 'f32', lanes: ['inverseFontSize', 'unused1', 'unused2', 'unused3'] }, + tableStarts: { id: 6, scalar: 'u32', lanes: ['curveStart', 'headerStart', 'referenceStart', 'bandStart'] }, + bandCounts: { id: 7, scalar: 'u32', lanes: ['horizontalBands', 'verticalBands', 'unused2', 'unused3'] }, + }, + resources: { curves: { kind: 'texture' }, headers: { kind: 'texture' }, references: { kind: 'texture' } }, +}); diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 8b7e1538..6b8ea775 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -2,7 +2,8 @@ import * as TSL from 'three/tsl'; import * as THREE from 'three/webgpu'; import { textShaperAbi } from '../core.js'; -import { STABLE_GLYPH_BUFFER_ID, TRANSFORM_BUFFER_ID } from './render-policy.js'; +import { decorationSchema, threeSystemBuffers } from './render-policy.js'; +import { bitmapSchema } from '../raster/bitmap-technique.js'; import { TextEngineRenderPlanView, type RenderPlanTable, type TextEnginePublication } from '../core.js'; import { bitmap, type BitmapStrikeData } from '../raster/bitmap-technique.js'; import { msdf, type MsdfData } from '../raster/msdf.js'; @@ -461,8 +462,8 @@ export class ThreeTextRenderPlanExecutor { const material = decoration ? this.#decorationMaterial(byPolicyId, transform, addressing) : this.#material(resource!, byPolicyId, materialId, transform, addressing); - const origins = decoration ? undefined : byPolicyId.get(1); - const stableIds = decoration ? undefined : byPolicyId.get(STABLE_GLYPH_BUFFER_ID); + const origins = decoration ? undefined : byPolicyId.get(bitmapSchema.buffers.origin.id); + const stableIds = decoration ? undefined : byPolicyId.get(threeSystemBuffers.stableGlyphId.id); if (origins !== undefined && stableIds !== undefined) { if (!(origins.array instanceof Float32Array) || !(stableIds.array instanceof Uint32Array)) { throw new TypeError('glyph-origin augmentation buffers have invalid scalar types'); @@ -585,7 +586,7 @@ export class ThreeTextRenderPlanExecutor { #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { if (transformId !== 0) return { kind: 'direct', transformId }; - const indices = buffers.get(TRANSFORM_BUFFER_ID); + const indices = buffers.get(threeSystemBuffers.transformIndex.id); if (indices === undefined || !(indices.array instanceof Uint32Array)) { throw new Error('indexed Three draw is missing its u32 transform-index buffer'); } @@ -679,8 +680,8 @@ export class ThreeTextRenderPlanExecutor { transform: TransformRealization, addressing: RecordAddressing, ): THREE.NodeMaterial { - const rect = buffers.get(1); - const packed = buffers.get(2); + const rect = buffers.get(decorationSchema.buffers.rect.id); + const packed = buffers.get(decorationSchema.buffers.packed.id); if (rect === undefined || packed === undefined) { throw new Error('decoration draw is missing its rectangle or packed policy buffer'); } diff --git a/packages/text/src/three/render-policy.ts b/packages/text/src/three/render-policy.ts index c52e3a0a..cf780bbe 100644 --- a/packages/text/src/three/render-policy.ts +++ b/packages/text/src/three/render-policy.ts @@ -4,11 +4,13 @@ import { constantF32, constantU32, createProgram, + definePolicyBuffers, + defineTechniqueSchema, floatBuffers, multiplyF32, - policyProgram, RenderWireIdentityRegistry, subtractF32, + techniqueProgram, u32Buffers, u32ToF32, type PolicyAllocationMode, @@ -16,12 +18,49 @@ import { type PolicyCapabilitySet, type PolicyProgram, type PolicyTransformMode, + type TechniqueSchema, } from '../core.js'; +import { bitmapSchema } from '../raster/bitmap-technique.js'; +import { msdfSchema } from '../raster/msdf.js'; +import { slugSchema } from '../raster/slug-technique.js'; import { textShaperAbi } from '../core.js'; -export const TRANSFORM_BUFFER_ID = 15; +/** Buffers the Three policy itself owns, shared by every program in it. */ +export const threeSystemBuffers: { + readonly stableGlyphId: { readonly id: 14; readonly scalar: 'u32'; readonly lanes: readonly ['stableGlyphId'] }; + readonly transformIndex: { readonly id: 15; readonly scalar: 'u32'; readonly lanes: readonly ['transformIndex'] }; +} = definePolicyBuffers({ + stableGlyphId: { id: 14, scalar: 'u32', lanes: ['stableGlyphId'] }, + transformIndex: { id: 15, scalar: 'u32', lanes: ['transformIndex'] }, +}); -export const STABLE_GLYPH_BUFFER_ID = 14; +export const TRANSFORM_BUFFER_ID: number = threeSystemBuffers.transformIndex.id; + +export const STABLE_GLYPH_BUFFER_ID: number = threeSystemBuffers.stableGlyphId.id; + +/** + * Decoration is a reserved technique of the Three policy rather than a raster + * technique: rows are resource-free and fill the gather lanes directly. + */ +export const decorationSchema: TechniqueSchema< + { + readonly rect: { + readonly id: 1; + readonly scalar: 'f32'; + readonly lanes: readonly ['left', 'top', 'width', 'height']; + }; + readonly packed: { readonly id: 2; readonly scalar: 'u32'; readonly lanes: readonly ['color', 'flags'] }; + }, + { readonly u32: readonly ['color', 'flags'] } +> = defineTechniqueSchema({ + technique: 'pmndrs.decoration', + scope: 'glyph', + binding: { u32: ['color', 'flags'] }, + buffers: { + rect: { id: 1, scalar: 'f32', lanes: ['left', 'top', 'width', 'height'] }, + packed: { id: 2, scalar: 'u32', lanes: ['color', 'flags'] }, + }, +}); export type ThreeTransformMode = PolicyTransformMode; @@ -78,55 +117,26 @@ function threeCapabilitySet(): PolicyCapabilitySet { }; } -// Buffer ids are wire integers; these names map each technique's physical buffers -// to what its shader reads from them. -const BITMAP_ORIGIN = 1; -const BITMAP_SIZE = 2; -const BITMAP_UV_ORIGIN = 3; -const BITMAP_UV_SIZE = 4; -const BITMAP_COLOR = 5; -const BITMAP_PAGE = 6; -const MSDF_RECT = 1; -const MSDF_UV_RECT = 2; -const MSDF_UV_BOUNDS = 3; -const MSDF_COLOR = 4; -const MSDF_EFFECT_A = 5; -const MSDF_EFFECT_B = 6; -const MSDF_PAGE = 7; -const SLUG_RECT = 1; -const SLUG_PLANE_RECT = 2; -const SLUG_BAND_TRANSFORM = 3; -const SLUG_COLOR = 4; -const SLUG_INVERSE_FONT_SIZE = 5; -const SLUG_TABLE_STARTS = 6; -const SLUG_BAND_COUNTS = 7; -const DECORATION_RECT = 1; -const DECORATION_PACKED = 2; - function bitmapProgram( techniqueId: number, programId: number, transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const p = policyProgram({ - scope: 'strike', - bindingF32: ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY'], - bindingU32: ['page'], - }); + const p = techniqueProgram(bitmapSchema); const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; const { bearingX, bearingY, width, height, uvOriginX, uvOriginY, uvSizeX, uvSizeY, page } = p.binding; - p.storeF32(BITMAP_ORIGIN, [ + p.store(bitmapSchema.buffers.origin, [ addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), ]); - p.storeF32(BITMAP_SIZE, [multiplyF32(width, fontSize), multiplyF32(height, fontSize)]); - p.storeF32(BITMAP_UV_ORIGIN, [uvOriginX, uvOriginY]); - p.storeF32(BITMAP_UV_SIZE, [uvSizeX, uvSizeY]); - p.storeF32(BITMAP_COLOR, [color.red, color.green, color.blue, color.alpha]); - if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); - p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); - p.storeU32(BITMAP_PAGE, [page]); + p.store(bitmapSchema.buffers.size, [multiplyF32(width, fontSize), multiplyF32(height, fontSize)]); + p.store(bitmapSchema.buffers.uvOrigin, [uvOriginX, uvOriginY]); + p.store(bitmapSchema.buffers.uvSize, [uvSizeX, uvSizeY]); + p.store(bitmapSchema.buffers.color, [color.red, color.green, color.blue, color.alpha]); + if (transformMode === 'indexed') p.store(threeSystemBuffers.transformIndex, [transformIndex]); + p.store(threeSystemBuffers.stableGlyphId, [stableGlyphId]); + p.store(bitmapSchema.buffers.page, [page]); return createProgram( techniqueId, programId, @@ -145,39 +155,24 @@ function msdfProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const p = policyProgram({ - scope: 'glyph', - bindingF32: [ - 'bearingX', - 'bearingY', - 'width', - 'height', - 'uvOriginX', - 'uvOriginY', - 'uvSizeX', - 'uvSizeY', - 'uvMaxX', - 'uvMaxY', - ], - bindingU32: ['page'], - }); + const p = techniqueProgram(msdfSchema); const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; const { bearingX, bearingY, width, height, uvOriginX, uvOriginY, uvSizeX, uvSizeY, uvMaxX, uvMaxY, page } = p.binding; const zero = constantF32(0); - p.storeF32(MSDF_RECT, [ + p.store(msdfSchema.buffers.rect, [ addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), multiplyF32(width, fontSize), multiplyF32(height, fontSize), ]); - p.storeF32(MSDF_UV_RECT, [uvOriginX, uvOriginY, uvSizeX, uvSizeY]); - p.storeF32(MSDF_UV_BOUNDS, [uvOriginX, uvOriginY, uvMaxX, uvMaxY]); - p.storeF32(MSDF_COLOR, [color.red, color.green, color.blue, color.alpha]); - p.storeF32(MSDF_EFFECT_A, [zero, zero, zero, zero]); - p.storeF32(MSDF_EFFECT_B, [zero, zero, zero, zero]); - p.storeF32(MSDF_PAGE, [zero, zero, zero, u32ToF32(page)]); - if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); - p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); + p.store(msdfSchema.buffers.uvRect, [uvOriginX, uvOriginY, uvSizeX, uvSizeY]); + p.store(msdfSchema.buffers.uvBounds, [uvOriginX, uvOriginY, uvMaxX, uvMaxY]); + p.store(msdfSchema.buffers.color, [color.red, color.green, color.blue, color.alpha]); + p.store(msdfSchema.buffers.effectA, [zero, zero, zero, zero]); + p.store(msdfSchema.buffers.effectB, [zero, zero, zero, zero]); + p.store(msdfSchema.buffers.page, [zero, zero, zero, u32ToF32(page)]); + if (transformMode === 'indexed') p.store(threeSystemBuffers.transformIndex, [transformIndex]); + p.store(threeSystemBuffers.stableGlyphId, [stableGlyphId]); return createProgram( techniqueId, programId, @@ -198,12 +193,7 @@ function slugProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const p = policyProgram({ - scope: 'glyph', - inverseFontSize: true, - bindingF32: ['bearingX', 'bearingY', 'width', 'height', 'bandScaleX', 'bandScaleY', 'bandOffsetX', 'bandOffsetY'], - bindingU32: ['curveStart', 'headerStart', 'referenceStart', 'bandStart', 'horizontalBands', 'verticalBands'], - }); + const p = techniqueProgram(slugSchema, { inverseFontSize: true }); const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; const inverseFontSize = p.semantics.inverseFontSize; if (inverseFontSize === undefined) throw new TypeError('the Slug program declares inverseFontSize'); @@ -225,20 +215,20 @@ function slugProgram( } = p.binding; const zeroF32 = constantF32(0); const zeroU32 = constantU32(0); - p.storeF32(SLUG_RECT, [ + p.store(slugSchema.buffers.rect, [ addF32(inlineOrigin, multiplyF32(bearingX, fontSize)), subtractF32(blockOrigin, multiplyF32(bearingY, fontSize)), multiplyF32(width, fontSize), multiplyF32(height, fontSize), ]); - p.storeF32(SLUG_PLANE_RECT, [bearingX, bearingY, width, height]); - p.storeF32(SLUG_BAND_TRANSFORM, [bandScaleX, bandScaleY, bandOffsetX, bandOffsetY]); - p.storeF32(SLUG_COLOR, [color.red, color.green, color.blue, color.alpha]); - p.storeF32(SLUG_INVERSE_FONT_SIZE, [inverseFontSize, zeroF32, zeroF32, zeroF32]); - p.storeU32(SLUG_TABLE_STARTS, [curveStart, headerStart, referenceStart, bandStart]); - p.storeU32(SLUG_BAND_COUNTS, [horizontalBands, verticalBands, zeroU32, zeroU32]); - if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); - p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); + p.store(slugSchema.buffers.planeRect, [bearingX, bearingY, width, height]); + p.store(slugSchema.buffers.bandTransform, [bandScaleX, bandScaleY, bandOffsetX, bandOffsetY]); + p.store(slugSchema.buffers.color, [color.red, color.green, color.blue, color.alpha]); + p.store(slugSchema.buffers.inverseFontSize, [inverseFontSize, zeroF32, zeroF32, zeroF32]); + p.store(slugSchema.buffers.tableStarts, [curveStart, headerStart, referenceStart, bandStart]); + p.store(slugSchema.buffers.bandCounts, [horizontalBands, verticalBands, zeroU32, zeroU32]); + if (transformMode === 'indexed') p.store(threeSystemBuffers.transformIndex, [transformIndex]); + p.store(threeSystemBuffers.stableGlyphId, [stableGlyphId]); return createProgram( techniqueId, programId, @@ -267,12 +257,12 @@ function decorationProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const p = policyProgram({ scope: 'glyph', bindingU32: ['color', 'flags'] }); + const p = techniqueProgram(decorationSchema); const { inlineOrigin, blockOrigin, fontSize, color, transformIndex, stableGlyphId } = p.semantics; - p.storeF32(DECORATION_RECT, [inlineOrigin, blockOrigin, fontSize, color.red]); - p.storeU32(DECORATION_PACKED, [p.binding.color, p.binding.flags]); - if (transformMode === 'indexed') p.storeU32(TRANSFORM_BUFFER_ID, [transformIndex]); - p.storeU32(STABLE_GLYPH_BUFFER_ID, [stableGlyphId]); + p.store(decorationSchema.buffers.rect, [inlineOrigin, blockOrigin, fontSize, color.red]); + p.store(decorationSchema.buffers.packed, [p.binding.color, p.binding.flags]); + if (transformMode === 'indexed') p.store(threeSystemBuffers.transformIndex, [transformIndex]); + p.store(threeSystemBuffers.stableGlyphId, [stableGlyphId]); return { ...createProgram( techniqueId, diff --git a/packages/text/tests/package/schema-authority.test.mjs b/packages/text/tests/package/schema-authority.test.mjs new file mode 100644 index 00000000..13be1ac1 --- /dev/null +++ b/packages/text/tests/package/schema-authority.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +const sourceRoot = new URL('../../src/', import.meta.url).pathname; + +/** + * The technique schema is the only witness to buffer identity. Nobody else may + * hold a literal buffer id: not executor lookups, not attribute-name strings, + * not parallel const tables. Schema declarations (raster techniques and the + * Three policy's own buffers) are the sanctioned definition sites. + */ +const DEFINITION_SITES = new Set([ + 'raster/bitmap-technique.ts', + 'raster/msdf.ts', + 'raster/slug-technique.ts', + 'three/render-policy.ts', +]); + +test('buffer ids appear only inside schema declarations', async () => { + const offenders = []; + for await (const file of walk(sourceRoot)) { + const relative = file.slice(sourceRoot.length); + const text = await readFile(file, 'utf8'); + for (const [index, line] of text.split('\n').entries()) { + const lookup = /\.get\(\s*\d+\s*\)/.exec(line); + if (lookup && /byPolicyId|buffers/.test(line)) { + offenders.push(`${relative}:${index + 1} literal buffer lookup: ${line.trim()}`); + } + if (/_pmndrsText_\d/.test(line)) { + offenders.push(`${relative}:${index + 1} literal attribute name: ${line.trim()}`); + } + if (!DEFINITION_SITES.has(relative) && /BUFFER_ID\s*=\s*\d/.test(line)) { + offenders.push(`${relative}:${index + 1} parallel id const: ${line.trim()}`); + } + } + } + assert.deepEqual(offenders, [], 'buffer identity leaked outside schema declarations'); +}); + +async function* walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) yield* walk(path); + else if (entry.name.endsWith('.ts')) yield path; + } +} diff --git a/packages/text/tests/types/technique-schema.test.ts b/packages/text/tests/types/technique-schema.test.ts new file mode 100644 index 00000000..d2f9d447 --- /dev/null +++ b/packages/text/tests/types/technique-schema.test.ts @@ -0,0 +1,49 @@ +import { + definePolicyBuffers, + defineTechniqueSchema, + multiplyF32, + techniqueProgram, + type PolicyF32Value, +} from '@pmndrs/text/core'; +import { bitmapSchema } from '@pmndrs/text/raster/bitmap'; + +// A technique schema is the single authority: buffer ids, scalar kinds, and lane +// meanings are declared once and every consumer derives from the declaration. +const schema = defineTechniqueSchema({ + technique: 'example.technique', + scope: 'glyph', + binding: { f32: ['bearingX', 'size'] as const, u32: ['page'] as const }, + buffers: { + rect: { id: 1, scalar: 'f32', lanes: ['left', 'top', 'width', 'height'] }, + page: { id: 2, scalar: 'u32', lanes: ['page'] }, + } as const, +}); + +const p = techniqueProgram(schema); +const { fontSize } = p.semantics; +const { bearingX, size, page } = p.binding; +const scaled: PolicyF32Value = multiplyF32(size, fontSize); +p.store(schema.buffers.rect, [multiplyF32(bearingX, fontSize), scaled, scaled, scaled]); +p.store(schema.buffers.page, [page]); +void p.compile(); + +// The id is data, not convention: consumers read it from the declaration. +const rectId: number = schema.buffers.rect.id; +void rectId; + +// System buffers use the same construct without a technique wrapper. +const system = definePolicyBuffers({ + stableGlyphId: { id: 14, scalar: 'u32', lanes: ['stableGlyphId'] }, +} as const); +p.store(system.stableGlyphId, [p.semantics.stableGlyphId]); + +// The first-party bitmap technique publishes its schema from its own subpath. +const bitmapColorId: number = bitmapSchema.buffers.color.id; +void bitmapColorId; + +// @ts-expect-error An f32 value cannot be stored into a u32 buffer. +p.store(schema.buffers.page, [scaled]); +// @ts-expect-error Undeclared buffers do not exist on the schema. +void schema.buffers.atlas; +// @ts-expect-error Undeclared binding fields do not exist. +void p.binding.kerning; From da67013704964453996009578a5e4237db7241c5 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Tue, 11 Aug 2026 23:46:42 -0400 Subject: [PATCH 6/7] refactor(text): close schema-authority review findings Policy-DSL values carry session provenance: storing a value loaded from another program's input table throws instead of silently reading a shifted input. Technique schemas deep-freeze at definition, making the documented immutability true. The remaining restatement sites derive from schemas: schemaPolicyBuffers replaces hand-rolled width lists in the Three programs, schemaFieldTable orders binding readers by declared names so a missing or misspelled reader is a compile error, the executor resolves draw buffers by schema name instead of literal id ranges, and the plan-program registry references the transform system buffer instead of restating its id. Glyph-origin augmentation is now schema-declared opt-in metadata rather than assuming Bitmap's layout for every technique. The structural gate also rejects literal-width buffer builders, literal id ranges, and restated system ids. Every policy and binding byte golden stayed pinned: the derivations reproduce the hand-rolled bytes exactly. Core and Three raw budgets re-priced (+1.8 KB / +1.9 KB, comment- and name-dominated); compressed ceilings unchanged. --- .../src/benchmark/package-size-budgets.ts | 19 ++- .../src/generated/package-sizes.json | 50 +++--- docs/log.md | 13 ++ docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 2 +- docs/planning/raster-technique-contract.md | 15 +- packages/text/src/core.ts | 2 + packages/text/src/core/font-binding.ts | 161 +++++++++--------- packages/text/src/core/policy-program.ts | 50 +++++- packages/text/src/core/technique-schema.ts | 46 ++++- packages/text/src/raster/bitmap-technique.ts | 1 + packages/text/src/raster/msdf.ts | 1 + packages/text/src/raster/slug-technique.ts | 1 + packages/text/src/three/engine-plan-target.ts | 141 +++++++++------ .../text/src/three/plan-program-registry.ts | 5 +- packages/text/src/three/render-policy.ts | 33 ++-- .../policy-program-provenance.test.mjs | 38 +++++ .../tests/package/schema-authority.test.mjs | 13 ++ .../tests/package/technique-schema.test.mjs | 78 +++++++++ .../text/tests/types/technique-schema.test.ts | 19 +++ 20 files changed, 483 insertions(+), 207 deletions(-) create mode 100644 packages/text/tests/package/policy-program-provenance.test.mjs create mode 100644 packages/text/tests/package/technique-schema.test.mjs diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 96a83b67..ee36e6a3 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -8,11 +8,13 @@ export const packageSizeBudgets = { // The renderer-neutral core subpath (D-249) must stay integration-free; the graph // assertion in measure-package-sizes.mts already rejects any three/tsl/react pull. // Grew with the technique-schema authority layer (D-251): declarations, validation, - // and the schema-typed store path. Re-based when tsdown bundling lands per the - // technique contract plan. + // and the schema-typed store path, then the review-closure pass (schema freezing, + // DSL session provenance, schemaPolicyBuffers/schemaFieldTable derivations) at + // ~+1.8 KB raw / +0.4 KB minified with compressed sizes inside their ceilings. + // Re-based when tsdown bundling lands per the technique contract plan. 'core-subpath-js': { - rawBytes: 222_000, - minifiedBytes: 153_000, + rawBytes: 225_000, + minifiedBytes: 154_000, gzipBytes: 39_000, brotliBytes: 33_500, }, @@ -50,11 +52,12 @@ export const packageSizeBudgets = { gzipBytes: 429_000, brotliBytes: 339_500, }, - // Raw rose for the policy-DSL authoring layer riding the Three bundle (D-250); the - // growth is comment- and name-dominated: minified, gzip, and Brotli stayed inside - // their existing ceilings. + // Raw rose for the policy-DSL authoring layer riding the Three bundle (D-250) and + // again for schema-derived executor lookups replacing literal id ranges; both + // growths are comment- and name-dominated: minified, gzip, and Brotli stayed + // inside their existing ceilings. 'three-runtime-js': { - rawBytes: 362_000, + rawBytes: 365_000, minifiedBytes: 238_000, gzipBytes: 61_500, brotliBytes: 52_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 91002836..42cc1dbc 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Renderer-neutral core JS", "status": "measured", "format": "javascript", - "sha256": "d35af890d0ae2201069ddf3b7d4413cd66e17f958431d6b7779a7d0f9f4b5161", - "rawBytes": 220303, - "minifiedBytes": 151682, - "gzipBytes": 38368, - "brotliBytes": 32666 + "sha256": "b8ab8aa2d679a4a780fb23029e32b24db3b00f8adf7345270138973dac6ddf8b", + "rawBytes": 223828, + "minifiedBytes": 153368, + "gzipBytes": 38819, + "brotliBytes": 33017 }, { "id": "tsl-subpath-js", @@ -54,11 +54,11 @@ "label": "Three.js adapter JS", "status": "measured", "format": "javascript", - "sha256": "4fcc72682ca242d3d209ab2cf10486c4e676eea08696fdf1cfc9113e41b35cc3", - "rawBytes": 360387, - "minifiedBytes": 236238, - "gzipBytes": 60742, - "brotliBytes": 51323 + "sha256": "f0ff464eb1dfb6d468a97d9b7c3a7eea91117fc1b6ab23ea06942fa0757fe154", + "rawBytes": 363922, + "minifiedBytes": 237949, + "gzipBytes": 61324, + "brotliBytes": 51762 }, { "id": "font-inter-bitmap-16-32", @@ -164,33 +164,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "bef3193849ab7f1312faf0f3cedb7bc202bd70f2803f5081129e1e32b95efe38", - "rawBytes": 349639, - "minifiedBytes": 228916, - "gzipBytes": 59542, - "brotliBytes": 49755 + "sha256": "a41b39a6630f47b77511e3b04848720ee09b2161ee889ea385b0b895995bf718", + "rawBytes": 353174, + "minifiedBytes": 230626, + "gzipBytes": 60165, + "brotliBytes": 50349 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "1da9d928fd74dbbe012be7fa8c396955e741fc833a0f85b609e22f6367a1018d", - "rawBytes": 349635, - "minifiedBytes": 228897, - "gzipBytes": 59584, - "brotliBytes": 49735 + "sha256": "f3e58b5938a8e01c2b51b77d660dba1cec586cd2a2bad2a6c598bdddf006f3d6", + "rawBytes": 353170, + "minifiedBytes": 230607, + "gzipBytes": 60221, + "brotliBytes": 50310 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "01db743c3e4edb17bf1077f2f5a38fc9b5d2ca8d87649749789d374393b6a322", - "rawBytes": 349637, - "minifiedBytes": 228991, - "gzipBytes": 59425, - "brotliBytes": 49726 + "sha256": "0e2acc25edabf2c6f8b57492780716586d3b29dc1a1aeb81a4fa2661786b3ebc", + "rawBytes": 353172, + "minifiedBytes": 230701, + "gzipBytes": 60077, + "brotliBytes": 50368 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index 38c3de4c..837018da 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,19 @@ ## 2026-08-11 +- **Technique-contract review closure (D-250/D-251)** — Closed all four adversarial-review findings on the + schema-authority stack. Policy-DSL values now carry session provenance: a value loaded from one program's input + table throws when stored through another builder instead of silently reading a shifted input. Technique schemas + deep-freeze at definition, making the documented immutability true at runtime. The remaining smear sites now + derive from schemas: `schemaPolicyBuffers` replaces hand-rolled width lists in the Three programs, + `schemaFieldTable` orders binding-table readers by the schema's declared names so a misspelled or missing reader + is a compile error, the executor resolves draw buffers by schema name instead of literal id ranges, and the + plan-program registry references the transform system buffer instead of restating `15`. Glyph-origin + augmentation became schema-declared opt-in metadata (`glyphOrigin`) rather than assuming Bitmap's buffer layout + for every technique. The structural gate now also rejects literal-width buffer builders, literal id ranges, and + restated system ids. Every policy and binding byte golden stayed pinned — the derivations reproduce the + hand-rolled bytes exactly, decided by the existing decoded-equivalence proof. + - **Technique schema authority (D-251)** — Buffer ids, lanes, and binding fields are declared once per technique by colocated schemas; programs store through schema handles, the executor reads declared ids, and a repository gate forbids literal buffer identity anywhere else. Policy bytes proven byte-identical across the change. The diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index a552f48d..8a66d01f 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:4e2ef405a86df105452471377b2b469283f9b737047c8ac27b9493a1f115c93f' +source_digest: 'sha256:47c1c308fa0e8447ddc181a63b70c5da272cb59ae20f3a9957cce556aa6d672a' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index 45fb6246..c3b09257 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6caaa25dcdefb55e1f8d9105430d55fb3a76e686b5625a6dff22e9b2a5261f78' +source_digest: 'sha256:8d3b87160366fa22812299c3f4abe5b84460ce62ed96a7a634bc844e5809dd10' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/raster-technique-contract.md b/docs/planning/raster-technique-contract.md index 8e61773c..d5b4c25b 100644 --- a/docs/planning/raster-technique-contract.md +++ b/docs/planning/raster-technique-contract.md @@ -211,15 +211,22 @@ The contract is sound when these hold, each as a permanent gate: 4. **The canvas technique exists**: a `runtime-sourced` technique rendering platform-rasterized glyphs, proving the data-origin axis end-to-end in the browser lane. 5. **The schema is the only witness**: grep-level gate — no `_pmndrsText_`, no `byPolicyId.get()`, - no parallel id consts outside technique declarations. + no parallel id consts outside technique declarations, no literal-width buffer builders outside core, no literal + id ranges mapped into buffer lookups, and no restated system-buffer ids. ## Migration stack Dependency-ordered layers, each green standalone: -1. `core`: `defineTechniqueSchema` + schema-typed `store`/binding/lookup APIs (additive; D-250 goldens pinned). -2. `raster/`: bitmap, msdf, slug, decoration schemas declared; policy/binding/executor consume them; hardcoded - ids die; goldens re-pin over the equivalence proof. +1. ✅ `core`: `defineTechniqueSchema` + schema-typed `store`/binding/lookup APIs. Landed with the D-250/D-251 + stack, then hardened by the adversarial-review closure: schemas deep-freeze at definition, DSL values carry + session provenance (a value from one program throws when stored into another), and `schemaPolicyBuffers` / + `schemaFieldTable` derive wire buffer lists and binding-table order from the declaration. +2. ✅ `raster/`: bitmap, msdf, slug, decoration schemas declared and consumed by programs, binding compilers, + and the executor; hardcoded id ranges, positional field tables, and width lists are gone, and the byte goldens + stayed pinned — the derivations reproduce the hand-rolled bytes exactly. Schemas also carry opt-in + `glyphOrigin` metadata naming the buffer whose first two lanes hold the glyph origin; the executor augments + only techniques that declare it instead of assuming Bitmap's layout everywhere. 3. `tsl`: shader interfaces derived from schemas; TSL graphs become explicit realizations. 4. `glyph-example-raster` rebuilt on the construct; docs rewritten from it. 5. `runtime-sourced` origin + canvas-texture technique with a browser proof. diff --git a/packages/text/src/core.ts b/packages/text/src/core.ts index bf0dd962..9f104687 100644 --- a/packages/text/src/core.ts +++ b/packages/text/src/core.ts @@ -45,6 +45,7 @@ export { TextEngineRenderPlanView, type RenderPlanTable } from './core/plan-view export { readTextEngineLayouts, readTextEngineMeasurements } from './core/layout-query-view.js'; export { compileFontBinding, + schemaFieldTable, emptyFontBindingTable, loadedFontBindingBytes, fontBindingResources, @@ -77,6 +78,7 @@ export { export { definePolicyBuffers, defineTechniqueSchema, + schemaPolicyBuffers, type PolicyBufferDeclaration, type PolicyBufferDeclarations, type PolicyScalarKind, diff --git a/packages/text/src/core/font-binding.ts b/packages/text/src/core/font-binding.ts index 08781367..3d207f94 100644 --- a/packages/text/src/core/font-binding.ts +++ b/packages/text/src/core/font-binding.ts @@ -1,8 +1,8 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; import type { LoadedFont } from '../loaded-font.js'; -import { bitmap, type BitmapData } from '../raster/bitmap-technique.js'; -import { msdf, type MsdfData } from '../raster/msdf.js'; -import { slug, type SlugData } from '../raster/slug-technique.js'; +import { bitmap, bitmapSchema, type BitmapData } from '../raster/bitmap-technique.js'; +import { msdf, msdfSchema, type MsdfData } from '../raster/msdf.js'; +import { slug, slugSchema, type SlugData } from '../raster/slug-technique.js'; import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; import { RenderWireIdentityRegistry, type TechniqueWireIds } from './render-policy.js'; @@ -23,6 +23,19 @@ export interface FontBindingFieldTable { readonly fields: readonly ((row: number) => number)[]; } +/** + * Order a binding table by the schema's declared field names. The same name + * list drives the policy program's input table, so a missing, extra, or + * misspelled reader is a compile error instead of a silently shifted column. + */ +export function schemaFieldTable( + names: Names, + rows: number, + readers: { readonly [Name in Names[number]]: (row: number) => number }, +): FontBindingFieldTable { + return { rows, fields: names.map((name: Names[number]) => readers[name]) }; +} + export interface FontBindingDescriptor { readonly techniqueId: number; readonly programVariant: number; @@ -143,44 +156,36 @@ function compileBitmap( }, glyphF32: emptyFontBindingTable(glyphCount), glyphU32: emptyFontBindingTable(glyphCount), - strikeF32: { - rows, - fields: [ - (row) => { - const { view, record, strike } = strikeRecord(row); - return view.getInt16(record, true) / data.strikes[strike]!.planeUnitsPerEm; - }, - (row) => { - const { view, record, strike } = strikeRecord(row); - return view.getInt16(record + 6, true) / data.strikes[strike]!.planeUnitsPerEm; - }, - (row) => { - const { view, record, strike } = strikeRecord(row); - return ( - (view.getInt16(record + 4, true) - view.getInt16(record, true)) / data.strikes[strike]!.planeUnitsPerEm - ); - }, - (row) => { - const { view, record, strike } = strikeRecord(row); - return ( - (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / data.strikes[strike]!.planeUnitsPerEm - ); - }, - (row) => atlas(row, 8, 'width'), - (row) => atlas(row, 10, 'height'), - (row) => span(row, 8, 12, 'width'), - (row) => span(row, 10, 14, 'height'), - ], - }, - strikeU32: { - rows, - fields: [ - (row) => { - const { view, record } = strikeRecord(row); - return view.getUint16(record + 16, true); - }, - ], - }, + strikeF32: schemaFieldTable(bitmapSchema.binding.f32, rows, { + bearingX: (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + bearingY: (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record + 6, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + width: (row) => { + const { view, record, strike } = strikeRecord(row); + return (view.getInt16(record + 4, true) - view.getInt16(record, true)) / data.strikes[strike]!.planeUnitsPerEm; + }, + height: (row) => { + const { view, record, strike } = strikeRecord(row); + return ( + (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / data.strikes[strike]!.planeUnitsPerEm + ); + }, + uvOriginX: (row) => atlas(row, 8, 'width'), + uvOriginY: (row) => atlas(row, 10, 'height'), + uvSizeX: (row) => span(row, 8, 12, 'width'), + uvSizeY: (row) => span(row, 10, 14, 'height'), + }), + strikeU32: schemaFieldTable(bitmapSchema.binding.u32, rows, { + page: (row) => { + const { view, record } = strikeRecord(row); + return view.getUint16(record + 16, true); + }, + }), resourceF32: emptyFontBindingTable(resources.length), resourceU32: emptyFontBindingTable(resources.length), }); @@ -216,23 +221,21 @@ function compileMsdf( resourceIndex(row) { return pageAt(row) === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.resource); }, - glyphF32: { - rows: glyphCount, - fields: [ - (row) => view.getInt16(rowRecord(row), true) / data.planeUnitsPerEm, - (row) => view.getInt16(rowRecord(row) + 6, true) / data.planeUnitsPerEm, - (row) => (view.getInt16(rowRecord(row) + 4, true) - view.getInt16(rowRecord(row), true)) / data.planeUnitsPerEm, - (row) => - (view.getInt16(rowRecord(row) + 6, true) - view.getInt16(rowRecord(row) + 2, true)) / data.planeUnitsPerEm, - (row) => atlas(row, 8, 'width'), - (row) => atlas(row, 10, 'height'), - (row) => span(row, 8, 12, 'width'), - (row) => span(row, 10, 14, 'height'), - (row) => atlas(row, 12, 'width'), - (row) => atlas(row, 14, 'height'), - ], - }, - glyphU32: { rows: glyphCount, fields: [(row) => pageAt(row)] }, + glyphF32: schemaFieldTable(msdfSchema.binding.f32, glyphCount, { + bearingX: (row) => view.getInt16(rowRecord(row), true) / data.planeUnitsPerEm, + bearingY: (row) => view.getInt16(rowRecord(row) + 6, true) / data.planeUnitsPerEm, + width: (row) => + (view.getInt16(rowRecord(row) + 4, true) - view.getInt16(rowRecord(row), true)) / data.planeUnitsPerEm, + height: (row) => + (view.getInt16(rowRecord(row) + 6, true) - view.getInt16(rowRecord(row) + 2, true)) / data.planeUnitsPerEm, + uvOriginX: (row) => atlas(row, 8, 'width'), + uvOriginY: (row) => atlas(row, 10, 'height'), + uvSizeX: (row) => span(row, 8, 12, 'width'), + uvSizeY: (row) => span(row, 10, 14, 'height'), + uvMaxX: (row) => atlas(row, 12, 'width'), + uvMaxY: (row) => atlas(row, 14, 'height'), + }), + glyphU32: schemaFieldTable(msdfSchema.binding.u32, glyphCount, { page: (row) => pageAt(row) }), strikeF32: emptyFontBindingTable(glyphCount), strikeU32: emptyFontBindingTable(glyphCount), resourceF32: emptyFontBindingTable(resources.length), @@ -271,30 +274,24 @@ function compileSlug( const page = pageAt(row); return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.pages[page]!.resource); }, - glyphF32: { - rows: glyphCount, - fields: [ - (row) => normalized(row, 0), - (row) => normalized(row, 6), - (row) => width(row), - (row) => height(row), - (row) => bandScaleX(row), - (row) => bandScaleY(row), - (row) => -normalized(row, 0) * bandScaleX(row), - (row) => -normalized(row, 2) * bandScaleY(row), - ], - }, - glyphU32: { - rows: glyphCount, - fields: [ - (row) => view.getUint32(record(row) + 16, true), - (row) => view.getUint32(record(row) + 24, true), - (row) => view.getUint32(record(row) + 28, true), - (row) => view.getUint32(record(row) + 32, true), - (row) => horizontalBands(row), - (row) => verticalBands(row), - ], - }, + glyphF32: schemaFieldTable(slugSchema.binding.f32, glyphCount, { + bearingX: (row) => normalized(row, 0), + bearingY: (row) => normalized(row, 6), + width: (row) => width(row), + height: (row) => height(row), + bandScaleX: (row) => bandScaleX(row), + bandScaleY: (row) => bandScaleY(row), + bandOffsetX: (row) => -normalized(row, 0) * bandScaleX(row), + bandOffsetY: (row) => -normalized(row, 2) * bandScaleY(row), + }), + glyphU32: schemaFieldTable(slugSchema.binding.u32, glyphCount, { + curveStart: (row) => view.getUint32(record(row) + 16, true), + headerStart: (row) => view.getUint32(record(row) + 24, true), + referenceStart: (row) => view.getUint32(record(row) + 28, true), + bandStart: (row) => view.getUint32(record(row) + 32, true), + horizontalBands: (row) => horizontalBands(row), + verticalBands: (row) => verticalBands(row), + }), strikeF32: emptyFontBindingTable(glyphCount), strikeU32: emptyFontBindingTable(glyphCount), resourceF32: emptyFontBindingTable(resources.length), diff --git a/packages/text/src/core/policy-program.ts b/packages/text/src/core/policy-program.ts index 3f1884f8..8c2c0800 100644 --- a/packages/text/src/core/policy-program.ts +++ b/packages/text/src/core/policy-program.ts @@ -13,8 +13,8 @@ import type { PolicyBufferDeclaration, TechniqueSchema } from './technique-schem const MAX_REGISTERS = 32; type Node = - | { readonly kind: 'loadF32'; readonly input: number; readonly label: string } - | { readonly kind: 'loadU32'; readonly input: number; readonly label: string } + | { readonly kind: 'loadF32'; readonly input: number; readonly label: string; readonly session: object } + | { readonly kind: 'loadU32'; readonly input: number; readonly label: string; readonly session: object } | { readonly kind: 'binary'; readonly op: 'addF32' | 'subtractF32' | 'multiplyF32'; @@ -58,6 +58,31 @@ function nodeOf(value: PolicyF32Value | PolicyU32Value): Node { return node; } +/** + * A loaded value's input index only means something inside the program that + * created it; storing it elsewhere would silently read a different field. + * Constants and constant-only expressions are session-free. + */ +function assertSession(node: Node, session: object): void { + switch (node.kind) { + case 'loadF32': + case 'loadU32': + if (node.session !== session) { + throw new TypeError(`policy value "${node.label}" belongs to a different authoring session`); + } + return; + case 'binary': + assertSession(node.left, session); + assertSession(node.right, session); + return; + case 'convertU32ToF32': + assertSession(node.source, session); + return; + default: + return; + } +} + export function addF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { return f32Value({ kind: 'binary', op: 'addF32', left: nodeOf(left), right: nodeOf(right) }); } @@ -192,8 +217,9 @@ export function policyProgram< const f32InputCount = 7 + (options.inverseFontSize === true ? 1 : 0) + bindingF32Names.length; const u32InputCount = 2 + bindingU32Names.length; + const session = {}; let nextF32 = 0; - const loadF32 = (label: string): PolicyF32Value => f32Value({ kind: 'loadF32', input: nextF32++, label }); + const loadF32 = (label: string): PolicyF32Value => f32Value({ kind: 'loadF32', input: nextF32++, label, session }); const semantics: PolicyProgramSemantics = { inlineOrigin: loadF32('inlineOrigin'), blockOrigin: loadF32('blockOrigin'), @@ -205,13 +231,13 @@ export function policyProgram< alpha: loadF32('color.alpha'), }, inverseFontSize: options.inverseFontSize === true ? loadF32('inverseFontSize') : undefined, - transformIndex: u32Value({ kind: 'loadU32', input: 0, label: 'transformIndex' }), - stableGlyphId: u32Value({ kind: 'loadU32', input: 1, label: 'stableGlyphId' }), + transformIndex: u32Value({ kind: 'loadU32', input: 0, label: 'transformIndex', session }), + stableGlyphId: u32Value({ kind: 'loadU32', input: 1, label: 'stableGlyphId', session }), }; const binding: Record = {}; for (const name of bindingF32Names) binding[name] = loadF32(name); for (const [index, name] of bindingU32Names.entries()) { - binding[name] = u32Value({ kind: 'loadU32', input: 2 + index, label: name }); + binding[name] = u32Value({ kind: 'loadU32', input: 2 + index, label: name, session }); } const stores: StoreRecord[] = []; @@ -228,17 +254,23 @@ export function policyProgram< } const opcode = buffer.scalar === 'f32' ? opcodes.storeF32 : opcodes.storeU32; for (const [lane, value] of lanes.entries()) { - stores.push({ opcode, buffer: buffer.id, lane, node: nodeOf(value) }); + const node = nodeOf(value); + assertSession(node, session); + stores.push({ opcode, buffer: buffer.id, lane, node }); } }, storeF32(buffer, lanes) { for (const [lane, value] of lanes.entries()) { - stores.push({ opcode: opcodes.storeF32, buffer, lane, node: nodeOf(value) }); + const node = nodeOf(value); + assertSession(node, session); + stores.push({ opcode: opcodes.storeF32, buffer, lane, node }); } }, storeU32(buffer, lanes) { for (const [lane, value] of lanes.entries()) { - stores.push({ opcode: opcodes.storeU32, buffer, lane, node: nodeOf(value) }); + const node = nodeOf(value); + assertSession(node, session); + stores.push({ opcode: opcodes.storeU32, buffer, lane, node }); } }, compile() { diff --git a/packages/text/src/core/technique-schema.ts b/packages/text/src/core/technique-schema.ts index 680b91e0..ac9383a0 100644 --- a/packages/text/src/core/technique-schema.ts +++ b/packages/text/src/core/technique-schema.ts @@ -6,6 +6,9 @@ * derive from the declaration; none of them restate it. */ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { PolicyBuffer } from './render-policy.js'; + export type PolicyScalarKind = 'f32' | 'u32'; export interface PolicyBufferDeclaration { @@ -30,8 +33,10 @@ export function definePolicyBuffers 4) { throw new RangeError(`policy buffer "${name}" needs one to four named lanes`); } + Object.freeze(buffer.lanes); + Object.freeze(buffer); } - return buffers; + return Object.freeze(buffers); } export interface TechniqueBindingDeclaration { @@ -55,6 +60,13 @@ export interface TechniqueSchemaDeclaration< readonly binding: Binding; readonly buffers: Buffers; readonly resources?: Readonly>; + /** + * Opt-in glyph-origin metadata: names the declared f32 buffer whose first two + * lanes carry the glyph's inline/block origin. Renderers that augment glyph + * origins (animation retargeting) consult this instead of assuming a layout; + * techniques without it are never augmented. + */ + readonly glyphOrigin?: { readonly buffer: string }; } export interface TechniqueSchema< @@ -73,5 +85,35 @@ export function defineTechniqueSchema< if (new Set(names).size !== names.length) { throw new TypeError(`technique "${declaration.technique}" repeats a binding field name`); } - return declaration; + if (declaration.glyphOrigin !== undefined) { + const origin: PolicyBufferDeclaration | undefined = declaration.buffers[declaration.glyphOrigin.buffer]; + if (origin === undefined) { + throw new TypeError(`technique "${declaration.technique}" points glyphOrigin at an undeclared buffer`); + } + if (origin.scalar !== 'f32' || origin.lanes.length < 2) { + throw new TypeError(`technique "${declaration.technique}" needs an f32 glyphOrigin buffer with two origin lanes`); + } + Object.freeze(declaration.glyphOrigin); + } + Object.freeze(declaration.binding.f32); + Object.freeze(declaration.binding.u32); + Object.freeze(declaration.binding); + if (declaration.resources !== undefined) { + for (const resource of Object.values(declaration.resources)) Object.freeze(resource); + Object.freeze(declaration.resources); + } + return Object.freeze(declaration); +} + +/** + * Derive the wire buffer list a technique's programs publish, in declaration + * order — the schema is the only witness to ids, scalar kinds, and widths. + */ +export function schemaPolicyBuffers(schema: TechniqueSchema): PolicyBuffer[] { + const scalars = textShaperAbi.policy.scalarTypes; + return Object.values(schema.buffers).map((buffer) => ({ + id: buffer.id, + scalar: buffer.scalar === 'f32' ? scalars.f32 : scalars.u32, + vectorWidth: buffer.lanes.length, + })); } diff --git a/packages/text/src/raster/bitmap-technique.ts b/packages/text/src/raster/bitmap-technique.ts index 91f2d1bd..54e68845 100644 --- a/packages/text/src/raster/bitmap-technique.ts +++ b/packages/text/src/raster/bitmap-technique.ts @@ -226,6 +226,7 @@ export const bitmapSchema: TechniqueSchema< > = defineTechniqueSchema({ technique: 'pmndrs.bitmap', scope: 'strike', + glyphOrigin: { buffer: 'origin' }, binding: { f32: ['bearingX', 'bearingY', 'width', 'height', 'uvOriginX', 'uvOriginY', 'uvSizeX', 'uvSizeY'], u32: ['page'], diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index 31067e1c..5ef59bdc 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -253,6 +253,7 @@ export const msdfSchema: TechniqueSchema< > = defineTechniqueSchema({ technique: 'pmndrs.msdf', scope: 'glyph', + glyphOrigin: { buffer: 'rect' }, binding: { f32: [ 'bearingX', diff --git a/packages/text/src/raster/slug-technique.ts b/packages/text/src/raster/slug-technique.ts index a9a86768..443c58dd 100644 --- a/packages/text/src/raster/slug-technique.ts +++ b/packages/text/src/raster/slug-technique.ts @@ -414,6 +414,7 @@ export const slugSchema: TechniqueSchema< > = defineTechniqueSchema({ technique: 'pmndrs.slug', scope: 'glyph', + glyphOrigin: { buffer: 'rect' }, binding: { f32: ['bearingX', 'bearingY', 'width', 'height', 'bandScaleX', 'bandScaleY', 'bandOffsetX', 'bandOffsetY'], u32: ['curveStart', 'headerStart', 'referenceStart', 'bandStart', 'horizontalBands', 'verticalBands'], diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 6b8ea775..07d502c1 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -4,6 +4,9 @@ import * as THREE from 'three/webgpu'; import { textShaperAbi } from '../core.js'; import { decorationSchema, threeSystemBuffers } from './render-policy.js'; import { bitmapSchema } from '../raster/bitmap-technique.js'; +import { msdfSchema } from '../raster/msdf.js'; +import { slugSchema } from '../raster/slug-technique.js'; +import type { PolicyBufferDeclaration, PolicyBufferDeclarations, TechniqueSchema } from '../core.js'; import { TextEngineRenderPlanView, type RenderPlanTable, type TextEnginePublication } from '../core.js'; import { bitmap, type BitmapStrikeData } from '../raster/bitmap-technique.js'; import { msdf, type MsdfData } from '../raster/msdf.js'; @@ -462,7 +465,11 @@ export class ThreeTextRenderPlanExecutor { const material = decoration ? this.#decorationMaterial(byPolicyId, transform, addressing) : this.#material(resource!, byPolicyId, materialId, transform, addressing); - const origins = decoration ? undefined : byPolicyId.get(bitmapSchema.buffers.origin.id); + const originDeclaration = + decoration || resource === undefined + ? undefined + : glyphOriginBuffer(this.#coordinator.resolveResource(resource.referenceId).technique); + const origins = originDeclaration === undefined ? undefined : byPolicyId.get(originDeclaration.id); const stableIds = decoration ? undefined : byPolicyId.get(threeSystemBuffers.stableGlyphId.id); if (origins !== undefined && stableIds !== undefined) { if (!(origins.array instanceof Float32Array) || !(stableIds.array instanceof Uint32Array)) { @@ -626,11 +633,8 @@ export class ThreeTextRenderPlanExecutor { throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); } const strike = bitmapStrike(resolved); - const required = [1, 2, 3, 4, 5, 6].map((id) => { - const buffer = buffers.get(id); - if (buffer === undefined) throw new Error(`Bitmap draw is missing policy buffer ${id}`); - return buffer; - }); + const part = schemaDrawBuffers(bitmapSchema, buffers, 'Bitmap'); + const required = [part.origin, part.size, part.uvOrigin, part.uvSize, part.color, part.page]; const key = `${resource.id}:${resource.generation}:${materialId}:snap=${String(this.#owner.pixelSnapping)}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; @@ -643,20 +647,14 @@ export class ThreeTextRenderPlanExecutor { const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const shader = bitmapShader( { - origin: TSL.storage(required[0]!.attribute, 'vec2', required[0]!.attribute.count) - .setPBO(true) - .element(instance), - size: TSL.storage(required[1]!.attribute, 'vec2', required[1]!.attribute.count).setPBO(true).element(instance), - uvOrigin: TSL.storage(required[2]!.attribute, 'vec2', required[2]!.attribute.count) - .setPBO(true) - .element(instance), - uvSize: TSL.storage(required[3]!.attribute, 'vec2', required[3]!.attribute.count) - .setPBO(true) - .element(instance), - color: TSL.storage(required[4]!.attribute, 'vec4', required[4]!.attribute.count).setPBO(true).element(instance), - pageIndex: TSL.storage(required[5]!.attribute, 'uint', required[5]!.attribute.count) + origin: TSL.storage(part.origin.attribute, 'vec2', part.origin.attribute.count).setPBO(true).element(instance), + size: TSL.storage(part.size.attribute, 'vec2', part.size.attribute.count).setPBO(true).element(instance), + uvOrigin: TSL.storage(part.uvOrigin.attribute, 'vec2', part.uvOrigin.attribute.count) .setPBO(true) .element(instance), + uvSize: TSL.storage(part.uvSize.attribute, 'vec2', part.uvSize.attribute.count).setPBO(true).element(instance), + color: TSL.storage(part.color.attribute, 'vec4', part.color.attribute.count).setPBO(true).element(instance), + pageIndex: TSL.storage(part.page.attribute, 'uint', part.page.attribute.count).setPBO(true).element(instance), }, { page: texture }, { pixelSnapping: this.#owner.pixelSnapping }, @@ -789,11 +787,8 @@ export class ThreeTextRenderPlanExecutor { addressing: RecordAddressing, ): THREE.NodeMaterial { const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); - const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { - const buffer = buffers.get(id); - if (buffer === undefined) throw new Error(`MSDF draw is missing policy buffer ${id}`); - return buffer; - }); + const part = schemaDrawBuffers(msdfSchema, buffers, 'MSDF'); + const required = [part.rect, part.uvRect, part.uvBounds, part.color, part.effectA, part.effectB, part.page]; const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; @@ -803,22 +798,24 @@ export class ThreeTextRenderPlanExecutor { ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); - const fields = required.map((buffer) => - TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance), - ); + const field = (buffer: RetainedBuffer) => + TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance); + const rect = field(part.rect); + const uvRect = field(part.uvRect); + const page = field(part.page); const shader = msdfShader( { - origin: fields[0]!.xy, - size: fields[0]!.zw, - uvOrigin: fields[1]!.xy, - uvSize: fields[1]!.zw, - uvBounds: fields[2]!, - fillColor: fields[3]!, - outlineColor: fields[4]!, - shadowColor: fields[5]!, - shadowOffset: fields[6]!.xy, - outlineWidth: fields[6]!.z, - pageIndex: fields[6]!.w, + origin: rect.xy, + size: rect.zw, + uvOrigin: uvRect.xy, + uvSize: uvRect.zw, + uvBounds: field(part.uvBounds), + fillColor: field(part.color), + outlineColor: field(part.effectA), + shadowColor: field(part.effectB), + shadowOffset: page.xy, + outlineWidth: page.z, + pageIndex: page.w, }, { atlas: this.#msdfAtlas(resource.referenceId, data), @@ -900,11 +897,16 @@ export class ThreeTextRenderPlanExecutor { addressing: RecordAddressing, ): THREE.NodeMaterial { const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); - const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { - const buffer = buffers.get(id); - if (buffer === undefined) throw new Error(`Slug draw is missing policy buffer ${id}`); - return buffer; - }); + const part = schemaDrawBuffers(slugSchema, buffers, 'Slug'); + const required = [ + part.rect, + part.planeRect, + part.bandTransform, + part.color, + part.inverseFontSize, + part.tableStarts, + part.bandCounts, + ]; const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join( @@ -916,13 +918,14 @@ export class ThreeTextRenderPlanExecutor { ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); - const floatFields = required - .slice(0, 5) - .map((buffer) => TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance)); - const addresses = TSL.storage(required[5]!.attribute, 'uvec4', required[5]!.attribute.count) + const field = (buffer: RetainedBuffer) => + TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance); + const rect = field(part.rect); + const planeRect = field(part.planeRect); + const addresses = TSL.storage(part.tableStarts.attribute, 'uvec4', part.tableStarts.attribute.count) .setPBO(true) .element(instance); - const counts = TSL.storage(required[6]!.attribute, 'uvec4', required[6]!.attribute.count) + const counts = TSL.storage(part.bandCounts.attribute, 'uvec4', part.bandCounts.attribute.count) .setPBO(true) .element(instance); const indexedTransform = @@ -938,13 +941,13 @@ export class ThreeTextRenderPlanExecutor { ); const shader = slugShader( { - origin: floatFields[0]!.xy, - size: floatFields[0]!.zw, - emOrigin: floatFields[1]!.xy, - emSize: floatFields[1]!.zw, - bandTransform: floatFields[2]!, - color: floatFields[3]!, - inverseScale: floatFields[4]!.x, + origin: rect.xy, + size: rect.zw, + emOrigin: planeRect.xy, + emSize: planeRect.zw, + bandTransform: field(part.bandTransform), + color: field(part.color), + inverseScale: field(part.inverseFontSize).x, curveBaseTexel: addresses.x, horizontalHeaderBase: addresses.y, verticalHeaderBase: addresses.z, @@ -1113,6 +1116,36 @@ export class ThreeTextRenderPlanExecutor { } } +/** The techniques this executor realizes, keyed by wire identity. */ +const techniqueSchemas: ReadonlyMap = new Map([ + [bitmap.id, bitmapSchema], + [msdf.id, msdfSchema], + [slug.id, slugSchema], +]); + +/** Glyph-origin augmentation is schema-declared: no declaration, no augmentation. */ +function glyphOriginBuffer(technique: string): PolicyBufferDeclaration | undefined { + const schema = techniqueSchemas.get(technique); + if (schema?.glyphOrigin === undefined) return undefined; + return schema.buffers[schema.glyphOrigin.buffer]; +} + +/** Resolve a draw's retained buffers by the schema's names instead of remembered ids. */ +function schemaDrawBuffers( + schema: TechniqueSchema, + buffers: ReadonlyMap, + label: string, +): { readonly [Name in keyof Buffers]: RetainedBuffer } { + const resolved: Record = {}; + for (const [name, declaration] of Object.entries(schema.buffers)) { + const buffer = buffers.get(declaration.id); + if (buffer === undefined) throw new Error(`${label} draw is missing its "${name}" policy buffer`); + resolved[name] = buffer; + } + // The keys are exactly schema.buffers' own keys, collected in the loop above. + return resolved as { readonly [Name in keyof Buffers]: RetainedBuffer }; +} + function drawRealizationKey( programId: number, resource: RetainedResource | undefined, diff --git a/packages/text/src/three/plan-program-registry.ts b/packages/text/src/three/plan-program-registry.ts index 896ce32c..025f2612 100644 --- a/packages/text/src/three/plan-program-registry.ts +++ b/packages/text/src/three/plan-program-registry.ts @@ -13,6 +13,7 @@ import { import type { LoadedFont } from '../loaded-font.js'; import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; import type { ThreeTextMaterial } from './material.js'; +import { threeSystemBuffers } from './render-policy.js'; export interface ThreePlanProgramBuffer { readonly scalarType: number; @@ -98,7 +99,7 @@ export interface ThreePolicyAbi { readonly batchFields: typeof textShaperAbi.policy.batchFields; readonly semanticF32Fields: typeof textShaperAbi.engine.semanticF32Fields; readonly semanticU32Fields: typeof textShaperAbi.engine.semanticU32Fields; - readonly transformBufferId: 15; + readonly transformBufferId: typeof threeSystemBuffers.transformIndex.id; } export const threePolicyAbi: ThreePolicyAbi = Object.freeze({ @@ -109,7 +110,7 @@ export const threePolicyAbi: ThreePolicyAbi = Object.freeze({ batchFields: textShaperAbi.policy.batchFields, semanticF32Fields: textShaperAbi.engine.semanticF32Fields, semanticU32Fields: textShaperAbi.engine.semanticU32Fields, - transformBufferId: 15, + transformBufferId: threeSystemBuffers.transformIndex.id, }); function compileProgram( diff --git a/packages/text/src/three/render-policy.ts b/packages/text/src/three/render-policy.ts index cf780bbe..4b99e3cc 100644 --- a/packages/text/src/three/render-policy.ts +++ b/packages/text/src/three/render-policy.ts @@ -6,12 +6,11 @@ import { createProgram, definePolicyBuffers, defineTechniqueSchema, - floatBuffers, multiplyF32, RenderWireIdentityRegistry, + schemaPolicyBuffers, subtractF32, techniqueProgram, - u32Buffers, u32ToF32, type PolicyAllocationMode, type PolicyBuffer, @@ -141,9 +140,7 @@ function bitmapProgram( techniqueId, programId, p.compile(), - transformMode === 'indexed' - ? [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer(), transformIndexBuffer()] - : [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer()], + programBuffers(bitmapSchema, transformMode), transformMode, allocationMode, ); @@ -177,11 +174,7 @@ function msdfProgram( techniqueId, programId, p.compile(), - [ - ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), - stableGlyphIdBuffer(), - ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), - ], + programBuffers(msdfSchema, transformMode), transformMode, allocationMode, ); @@ -233,12 +226,7 @@ function slugProgram( techniqueId, programId, p.compile(), - [ - ...floatBuffers([4, 4, 4, 4, 4]), - ...u32Buffers([4, 4], 6), - stableGlyphIdBuffer(), - ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), - ], + programBuffers(slugSchema, transformMode), transformMode, allocationMode, ); @@ -268,9 +256,7 @@ function decorationProgram( techniqueId, programId, p.compile(), - transformMode === 'indexed' - ? [...floatBuffers([4]), ...u32Buffers([2], 2), stableGlyphIdBuffer(), transformIndexBuffer()] - : [...floatBuffers([4]), ...u32Buffers([2], 2), stableGlyphIdBuffer()], + programBuffers(decorationSchema, transformMode), transformMode, allocationMode, ), @@ -279,6 +265,15 @@ function decorationProgram( }; } +/** Every Three program publishes its schema's buffers, then the policy's own system buffers. */ +function programBuffers(schema: TechniqueSchema, transformMode: ThreeTransformMode): PolicyBuffer[] { + return [ + ...schemaPolicyBuffers(schema), + stableGlyphIdBuffer(), + ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), + ]; +} + function transformIndexBuffer(): PolicyBuffer { return { id: TRANSFORM_BUFFER_ID, diff --git a/packages/text/tests/package/policy-program-provenance.test.mjs b/packages/text/tests/package/policy-program-provenance.test.mjs new file mode 100644 index 00000000..692de0cc --- /dev/null +++ b/packages/text/tests/package/policy-program-provenance.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { addF32, constantF32, multiplyF32, policyProgram } from '@pmndrs/text/core'; + +const OPTIONS = { scope: 'glyph', bindingF32: ['bearingX'] }; +const BUFFER = { id: 1, scalar: 'f32', lanes: ['x', 'y'] }; + +/** + * A value loaded from one program's input table means nothing inside another + * program: the input index it carries would silently read a different field. + * Only session-free constants may cross builders. + */ +test('storing another session’s value throws instead of misreading inputs', () => { + const first = policyProgram(OPTIONS); + const second = policyProgram(OPTIONS); + assert.throws(() => second.store(BUFFER, [first.semantics.inlineOrigin, second.semantics.blockOrigin]), /session/); +}); + +test('derived values carry their session across combinators', () => { + const first = policyProgram(OPTIONS); + const second = policyProgram(OPTIONS); + const derived = multiplyF32(first.binding.bearingX, constantF32(2)); + assert.throws(() => second.store(BUFFER, [derived, second.semantics.blockOrigin]), /session/); +}); + +test('constants are session-free and same-session programs still compile', () => { + const program = policyProgram(OPTIONS); + const scale = constantF32(0.5); + program.store(BUFFER, [ + addF32(program.semantics.inlineOrigin, multiplyF32(program.binding.bearingX, scale)), + multiplyF32(program.semantics.blockOrigin, scale), + ]); + const other = policyProgram(OPTIONS); + other.store(BUFFER, [multiplyF32(other.semantics.inlineOrigin, scale), scale]); + assert.equal(program.compile().operations.length > 0, true); + assert.equal(other.compile().operations.length > 0, true); +}); diff --git a/packages/text/tests/package/schema-authority.test.mjs b/packages/text/tests/package/schema-authority.test.mjs index 13be1ac1..c6bc187e 100644 --- a/packages/text/tests/package/schema-authority.test.mjs +++ b/packages/text/tests/package/schema-authority.test.mjs @@ -34,6 +34,19 @@ test('buffer ids appear only inside schema declarations', async () => { if (!DEFINITION_SITES.has(relative) && /BUFFER_ID\s*=\s*\d/.test(line)) { offenders.push(`${relative}:${index + 1} parallel id const: ${line.trim()}`); } + // Buffer id sequences and vector widths derive from a schema, never from a + // hand-rolled numeric list: no literal-width policy-buffer builders outside + // their core definition, no literal id arrays mapped into buffer lookups, + // and no restated system-buffer ids. + if (relative !== 'core/render-policy.ts' && /(?:floatBuffers|u32Buffers)\(\s*\[/.test(line)) { + offenders.push(`${relative}:${index + 1} literal buffer widths: ${line.trim()}`); + } + if (/\[\s*\d+\s*(?:,\s*\d+\s*){2,}\]\.map\(/.test(line)) { + offenders.push(`${relative}:${index + 1} literal buffer id range: ${line.trim()}`); + } + if (/transformBufferId:\s*\d/.test(line)) { + offenders.push(`${relative}:${index + 1} restated system buffer id: ${line.trim()}`); + } } } assert.deepEqual(offenders, [], 'buffer identity leaked outside schema declarations'); diff --git a/packages/text/tests/package/technique-schema.test.mjs b/packages/text/tests/package/technique-schema.test.mjs new file mode 100644 index 00000000..a658f636 --- /dev/null +++ b/packages/text/tests/package/technique-schema.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { defineTechniqueSchema, floatBuffers, schemaPolicyBuffers, textShaperAbi, u32Buffers } from '@pmndrs/text/core'; +import { bitmapSchema } from '@pmndrs/text/raster/bitmap'; +import { msdfSchema } from '@pmndrs/text/raster/msdf'; +import { slugSchema } from '@pmndrs/text/raster/slug'; + +function declaration() { + return { + technique: 'test.technique', + scope: 'glyph', + binding: { f32: ['a', 'b'], u32: ['c'] }, + buffers: { + origin: { id: 1, scalar: 'f32', lanes: ['x', 'y'] }, + flags: { id: 2, scalar: 'u32', lanes: ['flags'] }, + }, + resources: { atlas: { kind: 'texture' } }, + }; +} + +test('defineTechniqueSchema freezes the whole declaration', () => { + const schema = defineTechniqueSchema(declaration()); + assert.ok(Object.isFrozen(schema), 'schema'); + assert.ok(Object.isFrozen(schema.buffers), 'buffers'); + assert.ok(Object.isFrozen(schema.buffers.origin), 'buffer declaration'); + assert.ok(Object.isFrozen(schema.buffers.origin.lanes), 'lanes'); + assert.ok(Object.isFrozen(schema.binding), 'binding'); + assert.ok(Object.isFrozen(schema.binding.f32), 'binding names'); + assert.ok(Object.isFrozen(schema.resources), 'resources'); + assert.ok(Object.isFrozen(schema.resources.atlas), 'resource declaration'); + assert.throws(() => { + schema.buffers.origin.id = 9; + }, TypeError); +}); + +test('every first-party schema is frozen', () => { + for (const schema of [bitmapSchema, msdfSchema, slugSchema]) { + assert.ok(Object.isFrozen(schema), schema.technique); + assert.ok(Object.isFrozen(schema.buffers), schema.technique); + for (const buffer of Object.values(schema.buffers)) { + assert.ok(Object.isFrozen(buffer) && Object.isFrozen(buffer.lanes), schema.technique); + } + } +}); + +test('glyphOrigin metadata must name a declared f32 buffer with two origin lanes', () => { + const valid = defineTechniqueSchema({ ...declaration(), glyphOrigin: { buffer: 'origin' } }); + assert.deepEqual(valid.glyphOrigin, { buffer: 'origin' }); + assert.throws(() => defineTechniqueSchema({ ...declaration(), glyphOrigin: { buffer: 'missing' } }), TypeError); + assert.throws(() => defineTechniqueSchema({ ...declaration(), glyphOrigin: { buffer: 'flags' } }), TypeError); + assert.throws( + () => + defineTechniqueSchema({ + ...declaration(), + buffers: { thin: { id: 1, scalar: 'f32', lanes: ['x'] } }, + glyphOrigin: { buffer: 'thin' }, + }), + TypeError, + ); +}); + +test('first-party techniques declare where their glyph origin lives', () => { + assert.equal(bitmapSchema.glyphOrigin?.buffer, 'origin'); + assert.equal(msdfSchema.glyphOrigin?.buffer, 'rect'); + assert.equal(slugSchema.glyphOrigin?.buffer, 'rect'); +}); + +test('schemaPolicyBuffers derives exactly the hand-rolled wire buffer list', () => { + assert.deepEqual(schemaPolicyBuffers(bitmapSchema), [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6)]); + assert.deepEqual(schemaPolicyBuffers(msdfSchema), floatBuffers([4, 4, 4, 4, 4, 4, 4])); + assert.deepEqual(schemaPolicyBuffers(slugSchema), [...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6)]); + const derived = schemaPolicyBuffers(defineTechniqueSchema(declaration())); + assert.deepEqual(derived, [ + { id: 1, scalar: textShaperAbi.policy.scalarTypes.f32, vectorWidth: 2 }, + { id: 2, scalar: textShaperAbi.policy.scalarTypes.u32, vectorWidth: 1 }, + ]); +}); diff --git a/packages/text/tests/types/technique-schema.test.ts b/packages/text/tests/types/technique-schema.test.ts index d2f9d447..10f3e63e 100644 --- a/packages/text/tests/types/technique-schema.test.ts +++ b/packages/text/tests/types/technique-schema.test.ts @@ -2,7 +2,11 @@ import { definePolicyBuffers, defineTechniqueSchema, multiplyF32, + schemaFieldTable, + schemaPolicyBuffers, techniqueProgram, + type FontBindingFieldTable, + type PolicyBuffer, type PolicyF32Value, } from '@pmndrs/text/core'; import { bitmapSchema } from '@pmndrs/text/raster/bitmap'; @@ -41,9 +45,24 @@ p.store(system.stableGlyphId, [p.semantics.stableGlyphId]); const bitmapColorId: number = bitmapSchema.buffers.color.id; void bitmapColorId; +// Wire buffer lists and binding tables derive from the same declaration. +const wire: PolicyBuffer[] = schemaPolicyBuffers(schema); +void wire; +const table: FontBindingFieldTable = schemaFieldTable(['bearingX', 'size'] as const, 4, { + bearingX: (row) => row, + size: (row) => row * 2, +}); +void table; + // @ts-expect-error An f32 value cannot be stored into a u32 buffer. p.store(schema.buffers.page, [scaled]); // @ts-expect-error Undeclared buffers do not exist on the schema. void schema.buffers.atlas; // @ts-expect-error Undeclared binding fields do not exist. void p.binding.kerning; +// @ts-expect-error A field table must provide a reader for every declared name. +schemaFieldTable(['bearingX', 'size'] as const, 4, { bearingX: (row: number) => row }); +// @ts-expect-error A misspelled reader name is a compile error, not a shifted column. +schemaFieldTable(['bearingX'] as const, 4, { bearingsX: (row: number) => row }); +// @ts-expect-error glyphOrigin must name a declared buffer at runtime; the property is read-only here. +schema.glyphOrigin = { buffer: 'rect' }; From efe2f443d1cca2b9309a5ee7dcb80ef0fddad8b6 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 12 Aug 2026 00:29:02 -0400 Subject: [PATCH 7/7] refactor(text): harden schema and DSL contracts from re-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session provenance is stamped at node construction and combined in O(1): shared expression DAGs no longer trigger an exponential graph walk in store(), and mixing two authoring sessions fails at the combinator. Schema definition validates the caller's input first and returns an owned, deeply frozen copy — rejection leaves caller data untouched, hostile accessors cannot change a validated width afterwards, and only declared fields are carried. Size-budget notes record the measured deltas instead of claiming comment-dominated growth, and the package reference documents /core and /tsl with /three/{bitmap,msdf,slug} as compatibility aliases. --- .../src/benchmark/package-size-budgets.ts | 34 ++++---- .../src/generated/package-sizes.json | 50 +++++------ docs/log.md | 12 ++- docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 10 ++- packages/text/src/core/policy-program.ts | 73 ++++++++++------ packages/text/src/core/technique-schema.ts | 86 +++++++++++++------ .../policy-program-provenance.test.mjs | 16 ++++ .../tests/package/technique-schema.test.mjs | 36 ++++++++ 9 files changed, 222 insertions(+), 97 deletions(-) diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index ee36e6a3..55eb3c6d 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -8,15 +8,16 @@ export const packageSizeBudgets = { // The renderer-neutral core subpath (D-249) must stay integration-free; the graph // assertion in measure-package-sizes.mts already rejects any three/tsl/react pull. // Grew with the technique-schema authority layer (D-251): declarations, validation, - // and the schema-typed store path, then the review-closure pass (schema freezing, - // DSL session provenance, schemaPolicyBuffers/schemaFieldTable derivations) at - // ~+1.8 KB raw / +0.4 KB minified with compressed sizes inside their ceilings. - // Re-based when tsdown bundling lands per the technique contract plan. + // and the schema-typed store path. The review-closure pass added +3,525 raw / + // +1,686 minified / +351 Brotli of real validation, provenance, and derivation + // code (schema normalization and freezing, DSL session provenance, + // schemaPolicyBuffers/schemaFieldTable). Re-based when tsdown bundling lands per + // the technique contract plan. 'core-subpath-js': { - rawBytes: 225_000, - minifiedBytes: 154_000, - gzipBytes: 39_000, - brotliBytes: 33_500, + rawBytes: 226_000, + minifiedBytes: 155_000, + gzipBytes: 39_400, + brotliBytes: 33_900, }, 'tsl-subpath-js': { rawBytes: 27_000, @@ -52,15 +53,16 @@ export const packageSizeBudgets = { gzipBytes: 429_000, brotliBytes: 339_500, }, - // Raw rose for the policy-DSL authoring layer riding the Three bundle (D-250) and - // again for schema-derived executor lookups replacing literal id ranges; both - // growths are comment- and name-dominated: minified, gzip, and Brotli stayed - // inside their existing ceilings. + // Raw rose for the policy-DSL authoring layer riding the Three bundle (D-250), + // then the review-closure pass added +3,535 raw / +1,711 minified / +439 Brotli + // of schema-derived executor lookups, program buffer derivation, and the + // glyph-origin schema map replacing literal id ranges. Real code, not comments; + // the compressed ceilings hold with tight headroom by design. 'three-runtime-js': { - rawBytes: 365_000, - minifiedBytes: 238_000, - gzipBytes: 61_500, - brotliBytes: 52_000, + rawBytes: 366_000, + minifiedBytes: 239_000, + gzipBytes: 61_800, + brotliBytes: 52_200, }, 'font-inter-bitmap-16-32': { rawBytes: 3_200_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 42cc1dbc..5ada2f7d 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Renderer-neutral core JS", "status": "measured", "format": "javascript", - "sha256": "b8ab8aa2d679a4a780fb23029e32b24db3b00f8adf7345270138973dac6ddf8b", - "rawBytes": 223828, - "minifiedBytes": 153368, - "gzipBytes": 38819, - "brotliBytes": 33017 + "sha256": "54a83a8595d3756eee428549ceea7077f546372f655f5d091be5829737cefc4d", + "rawBytes": 225596, + "minifiedBytes": 154330, + "gzipBytes": 39021, + "brotliBytes": 33248 }, { "id": "tsl-subpath-js", @@ -54,11 +54,11 @@ "label": "Three.js adapter JS", "status": "measured", "format": "javascript", - "sha256": "f0ff464eb1dfb6d468a97d9b7c3a7eea91117fc1b6ab23ea06942fa0757fe154", - "rawBytes": 363922, - "minifiedBytes": 237949, - "gzipBytes": 61324, - "brotliBytes": 51762 + "sha256": "59fd78dbb413502ac3b9014bc51a0912eafb15cc588aa4732c459ca5a738cf19", + "rawBytes": 365690, + "minifiedBytes": 238911, + "gzipBytes": 61533, + "brotliBytes": 52080 }, { "id": "font-inter-bitmap-16-32", @@ -164,33 +164,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "a41b39a6630f47b77511e3b04848720ee09b2161ee889ea385b0b895995bf718", - "rawBytes": 353174, - "minifiedBytes": 230626, - "gzipBytes": 60165, - "brotliBytes": 50349 + "sha256": "7f2f6b109b49c430e8b70a35ae43e450d7c49477e8583cb0dff07a7ef604de21", + "rawBytes": 354942, + "minifiedBytes": 231588, + "gzipBytes": 60389, + "brotliBytes": 50563 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "f3e58b5938a8e01c2b51b77d660dba1cec586cd2a2bad2a6c598bdddf006f3d6", - "rawBytes": 353170, - "minifiedBytes": 230607, - "gzipBytes": 60221, - "brotliBytes": 50310 + "sha256": "fcfc3e1cd4da4f7969cae2c4258c58cac52f3e9bff7c4a45748290a371e81180", + "rawBytes": 354938, + "minifiedBytes": 231569, + "gzipBytes": 60449, + "brotliBytes": 50503 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "0e2acc25edabf2c6f8b57492780716586d3b29dc1a1aeb81a4fa2661786b3ebc", - "rawBytes": 353172, - "minifiedBytes": 230701, - "gzipBytes": 60077, - "brotliBytes": 50368 + "sha256": "9d938b57d027a7c910ef4355ebfb696462d562a4a545cb96f03fa4dfcd79484b", + "rawBytes": 354940, + "minifiedBytes": 231663, + "gzipBytes": 60303, + "brotliBytes": 50565 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index 837018da..9d89ad57 100644 --- a/docs/log.md +++ b/docs/log.md @@ -13,7 +13,17 @@ augmentation became schema-declared opt-in metadata (`glyphOrigin`) rather than assuming Bitmap's buffer layout for every technique. The structural gate now also rejects literal-width buffer builders, literal id ranges, and restated system ids. Every policy and binding byte golden stayed pinned — the derivations reproduce the - hand-rolled bytes exactly, decided by the existing decoded-equivalence proof. + hand-rolled bytes exactly, decided by the existing decoded-equivalence proof. The adversarial re-review then + confirmed provenance and glyph-origin closed and surfaced follow-up defects, fixed in a second pass: session + provenance is now stamped at node construction and combined in O(1) — a shared expression DAG no longer costs an + exponential graph walk, and mixing sessions fails at the combinator itself; schema definition validates the + caller's input first and returns an owned, deeply frozen copy, so rejection leaves caller data untouched and a + hostile lanes accessor can never change a validated width; the size-budget notes now record the measured deltas + (+3.5 KB raw / +1.7 KB minified per surface of real validation and derivation code) instead of claiming + comment-dominated growth; and the package reference gained `/core` and `/tsl` rows with `/three/{bitmap,msdf,slug}` + described as the compatibility aliases they are. The re-review's remaining structural finding — TSL shader lane + meaning and the external example still restate schema knowledge — is migration layers 3–4 of the technique + contract plan, scheduled with the audit. - **Technique schema authority (D-251)** — Buffer ids, lanes, and binding fields are declared once per technique by colocated schemas; programs store through schema handles, the executor reads declared ids, and a repository diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 8a66d01f..f2b40fc1 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:47c1c308fa0e8447ddc181a63b70c5da272cb59ae20f3a9957cce556aa6d672a' +source_digest: 'sha256:e5759a718886cbf8a7be111ad13dab9786c3716e77122a0515dba164dac78e45' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index c3b09257..79f63028 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:8d3b87160366fa22812299c3f4abe5b84460ce62ed96a7a634bc844e5809dd10' +source_digest: 'sha256:ddd5648ef8e5115688ed0fdb4b6ba5bdcc9febe280d02e668b4ca2d9acdb1485' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -100,10 +100,12 @@ TypeScript does not independently shape, lay out, or pack paragraphs. | Subpath | Purpose | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `@pmndrs/text` | Font/raster contracts, loading, fallback stacks, formatting helpers, paragraph inputs, layout-query values, and portable bakers. | +| `@pmndrs/text/core` | Renderer-neutral engine host, frame wire, plan/layout-query views, technique schemas, policy-program DSL, and binding compiler. | +| `@pmndrs/text/tsl` | Canonical TSL shader realizations of the first-party technique interfaces; no scene integration. | | `@pmndrs/text/three` | Three `FontLoader`, `Text`, `TextGroup`, material factories, and policy registration. | -| `@pmndrs/text/three/bitmap` | Bitmap technique, policy program, and canonical TSL shader. | -| `@pmndrs/text/three/msdf` | MSDF technique, policy program, and canonical TSL shader. | -| `@pmndrs/text/three/slug` | Slug technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/bitmap` | Compatibility alias re-exporting the renderer-neutral Bitmap raster module. | +| `@pmndrs/text/three/msdf` | Compatibility alias re-exporting the renderer-neutral MSDF raster module. | +| `@pmndrs/text/three/slug` | Compatibility alias re-exporting the renderer-neutral Slug raster module. | | `@pmndrs/text/react` | React ``, ``, and `useFont`, reconciled through React Three Fiber. | | `@pmndrs/text/bake` | Node programmatic font baking, glyph selection, and font inspection used by the `text` CLI. | | `@pmndrs/text/runtime-bake` | Explicit browser Worker host for optional runtime baking. | diff --git a/packages/text/src/core/policy-program.ts b/packages/text/src/core/policy-program.ts index 8c2c0800..bdff5cab 100644 --- a/packages/text/src/core/policy-program.ts +++ b/packages/text/src/core/policy-program.ts @@ -20,10 +20,11 @@ type Node = readonly op: 'addF32' | 'subtractF32' | 'multiplyF32'; readonly left: Node; readonly right: Node; + readonly session: object | undefined; } - | { readonly kind: 'constantF32'; readonly value: number } - | { readonly kind: 'constantU32'; readonly value: number } - | { readonly kind: 'convertU32ToF32'; readonly source: Node }; + | { readonly kind: 'constantF32'; readonly value: number; readonly session: undefined } + | { readonly kind: 'constantU32'; readonly value: number; readonly session: undefined } + | { readonly kind: 'convertU32ToF32'; readonly source: Node; readonly session: object | undefined }; declare const f32Brand: unique symbol; declare const u32Brand: unique symbol; @@ -61,54 +62,74 @@ function nodeOf(value: PolicyF32Value | PolicyU32Value): Node { /** * A loaded value's input index only means something inside the program that * created it; storing it elsewhere would silently read a different field. - * Constants and constant-only expressions are session-free. + * Provenance is stamped at construction and combined in O(1) per node, so + * shared expression DAGs never require a graph walk: constants stay + * session-free, and mixing two sessions fails at the combinator itself. */ +function combinedSession(left: Node, right: Node): object | undefined { + if (left.session !== undefined && right.session !== undefined && left.session !== right.session) { + throw new TypeError('policy values from different authoring sessions cannot combine'); + } + return left.session ?? right.session; +} + function assertSession(node: Node, session: object): void { - switch (node.kind) { - case 'loadF32': - case 'loadU32': - if (node.session !== session) { - throw new TypeError(`policy value "${node.label}" belongs to a different authoring session`); - } - return; - case 'binary': - assertSession(node.left, session); - assertSession(node.right, session); - return; - case 'convertU32ToF32': - assertSession(node.source, session); - return; - default: - return; + if (node.session !== undefined && node.session !== session) { + throw new TypeError('policy value belongs to a different authoring session'); } } export function addF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { - return f32Value({ kind: 'binary', op: 'addF32', left: nodeOf(left), right: nodeOf(right) }); + const leftNode = nodeOf(left); + const rightNode = nodeOf(right); + return f32Value({ + kind: 'binary', + op: 'addF32', + left: leftNode, + right: rightNode, + session: combinedSession(leftNode, rightNode), + }); } export function subtractF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { - return f32Value({ kind: 'binary', op: 'subtractF32', left: nodeOf(left), right: nodeOf(right) }); + const leftNode = nodeOf(left); + const rightNode = nodeOf(right); + return f32Value({ + kind: 'binary', + op: 'subtractF32', + left: leftNode, + right: rightNode, + session: combinedSession(leftNode, rightNode), + }); } export function multiplyF32(left: PolicyF32Value, right: PolicyF32Value): PolicyF32Value { - return f32Value({ kind: 'binary', op: 'multiplyF32', left: nodeOf(left), right: nodeOf(right) }); + const leftNode = nodeOf(left); + const rightNode = nodeOf(right); + return f32Value({ + kind: 'binary', + op: 'multiplyF32', + left: leftNode, + right: rightNode, + session: combinedSession(leftNode, rightNode), + }); } export function u32ToF32(source: PolicyU32Value): PolicyF32Value { - return f32Value({ kind: 'convertU32ToF32', source: nodeOf(source) }); + const sourceNode = nodeOf(source); + return f32Value({ kind: 'convertU32ToF32', source: sourceNode, session: sourceNode.session }); } export function constantF32(value: number): PolicyF32Value { if (!Number.isFinite(value)) throw new RangeError('policy f32 constants must be finite'); - return f32Value({ kind: 'constantF32', value }); + return f32Value({ kind: 'constantF32', value, session: undefined }); } export function constantU32(value: number): PolicyU32Value { if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { throw new RangeError('policy u32 constants must be u32'); } - return u32Value({ kind: 'constantU32', value }); + return u32Value({ kind: 'constantU32', value, session: undefined }); } /** The glyph color channels — the resolved paint; the engine has no background. */ diff --git a/packages/text/src/core/technique-schema.ts b/packages/text/src/core/technique-schema.ts index ac9383a0..2be18219 100644 --- a/packages/text/src/core/technique-schema.ts +++ b/packages/text/src/core/technique-schema.ts @@ -21,22 +21,34 @@ export interface PolicyBufferDeclaration { export type PolicyBufferDeclarations = Readonly>; -/** Validate and freeze a named buffer set: nonzero unique ids, at least one lane each. */ +/** + * Validate and freeze a named buffer set: nonzero unique ids, at least one lane + * each. The result is an owned, deeply frozen copy — caller input is never + * mutated (rejection leaves it untouched), and caller accessors are read once + * here so they can never change a validated width afterwards. + */ export function definePolicyBuffers(buffers: Buffers): Buffers { const seen = new Set(); + const owned: Record = {}; for (const [name, buffer] of Object.entries(buffers)) { - if (!Number.isSafeInteger(buffer.id) || buffer.id <= 0 || buffer.id > 0xffff) { + const id = buffer.id; + const scalar = buffer.scalar; + const lanes = [...buffer.lanes]; + if (!Number.isSafeInteger(id) || id <= 0 || id > 0xffff) { throw new RangeError(`policy buffer "${name}" needs a nonzero u16 id`); } - if (seen.has(buffer.id)) throw new TypeError(`policy buffer "${name}" reuses id ${buffer.id}`); - seen.add(buffer.id); - if (buffer.lanes.length === 0 || buffer.lanes.length > 4) { + if (seen.has(id)) throw new TypeError(`policy buffer "${name}" reuses id ${id}`); + seen.add(id); + if (scalar !== 'f32' && scalar !== 'u32') { + throw new TypeError(`policy buffer "${name}" needs an f32 or u32 scalar kind`); + } + if (lanes.length === 0 || lanes.length > 4) { throw new RangeError(`policy buffer "${name}" needs one to four named lanes`); } - Object.freeze(buffer.lanes); - Object.freeze(buffer); + owned[name] = Object.freeze({ id, scalar, lanes: Object.freeze(lanes) }); } - return Object.freeze(buffers); + // The copy carries exactly the declared keys read above, so it satisfies Buffers. + return Object.freeze(owned) as Buffers; } export interface TechniqueBindingDeclaration { @@ -79,30 +91,56 @@ export function defineTechniqueSchema< const Buffers extends PolicyBufferDeclarations, const Binding extends TechniqueBindingDeclaration, >(declaration: TechniqueSchemaDeclaration): TechniqueSchema { - if (declaration.technique.length === 0) throw new TypeError('technique schemas need a wire identity'); - definePolicyBuffers(declaration.buffers); - const names = [...(declaration.binding.f32 ?? []), ...(declaration.binding.u32 ?? [])]; + // Read every input property exactly once into owned structures, validate the + // owned data, then freeze and return the copy. Caller input is never mutated + // or frozen — a rejected declaration leaves it exactly as passed — and only + // the declared fields are carried, so no foreign reachable state survives. + const technique = declaration.technique; + if (technique.length === 0) throw new TypeError('technique schemas need a wire identity'); + const scope = declaration.scope; + if (scope !== 'glyph' && scope !== 'strike' && scope !== 'resource') { + throw new TypeError(`technique "${technique}" needs a glyph, strike, or resource binding scope`); + } + const bindingF32 = declaration.binding.f32 === undefined ? undefined : Object.freeze([...declaration.binding.f32]); + const bindingU32 = declaration.binding.u32 === undefined ? undefined : Object.freeze([...declaration.binding.u32]); + const names = [...(bindingF32 ?? []), ...(bindingU32 ?? [])]; if (new Set(names).size !== names.length) { - throw new TypeError(`technique "${declaration.technique}" repeats a binding field name`); + throw new TypeError(`technique "${technique}" repeats a binding field name`); } + const buffers = definePolicyBuffers(declaration.buffers); + let resources: Readonly> | undefined; + if (declaration.resources !== undefined) { + const owned: Record = {}; + for (const [name, resource] of Object.entries(declaration.resources)) { + const format = resource.format; + owned[name] = Object.freeze({ kind: resource.kind, ...(format === undefined ? {} : { format }) }); + } + resources = Object.freeze(owned); + } + let glyphOrigin: { readonly buffer: string } | undefined; if (declaration.glyphOrigin !== undefined) { - const origin: PolicyBufferDeclaration | undefined = declaration.buffers[declaration.glyphOrigin.buffer]; + const origin: PolicyBufferDeclaration | undefined = buffers[declaration.glyphOrigin.buffer]; if (origin === undefined) { - throw new TypeError(`technique "${declaration.technique}" points glyphOrigin at an undeclared buffer`); + throw new TypeError(`technique "${technique}" points glyphOrigin at an undeclared buffer`); } if (origin.scalar !== 'f32' || origin.lanes.length < 2) { - throw new TypeError(`technique "${declaration.technique}" needs an f32 glyphOrigin buffer with two origin lanes`); + throw new TypeError(`technique "${technique}" needs an f32 glyphOrigin buffer with two origin lanes`); } - Object.freeze(declaration.glyphOrigin); - } - Object.freeze(declaration.binding.f32); - Object.freeze(declaration.binding.u32); - Object.freeze(declaration.binding); - if (declaration.resources !== undefined) { - for (const resource of Object.values(declaration.resources)) Object.freeze(resource); - Object.freeze(declaration.resources); + glyphOrigin = Object.freeze({ buffer: declaration.glyphOrigin.buffer }); } - return Object.freeze(declaration); + const binding = Object.freeze({ + ...(bindingF32 === undefined ? {} : { f32: bindingF32 }), + ...(bindingU32 === undefined ? {} : { u32: bindingU32 }), + // The copies carry exactly the declared binding names read above. + }) as Binding; + return Object.freeze({ + technique, + scope, + binding, + buffers, + ...(resources === undefined ? {} : { resources }), + ...(glyphOrigin === undefined ? {} : { glyphOrigin }), + }); } /** diff --git a/packages/text/tests/package/policy-program-provenance.test.mjs b/packages/text/tests/package/policy-program-provenance.test.mjs index 692de0cc..038f448e 100644 --- a/packages/text/tests/package/policy-program-provenance.test.mjs +++ b/packages/text/tests/package/policy-program-provenance.test.mjs @@ -24,6 +24,22 @@ test('derived values carry their session across combinators', () => { assert.throws(() => second.store(BUFFER, [derived, second.semantics.blockOrigin]), /session/); }); +test('combinators reject cross-session operands at construction', () => { + const first = policyProgram(OPTIONS); + const second = policyProgram(OPTIONS); + assert.throws(() => multiplyF32(first.semantics.fontSize, second.semantics.fontSize), /session/); +}); + +test('deep shared expression DAGs stay cheap to store', () => { + const program = policyProgram(OPTIONS); + let node = addF32(program.semantics.inlineOrigin, program.binding.bearingX); + for (let depth = 0; depth < 24; depth += 1) node = addF32(node, node); + const start = performance.now(); + program.store(BUFFER, [node, program.semantics.blockOrigin]); + const elapsed = performance.now() - start; + assert.ok(elapsed < 50, `store took ${elapsed}ms over a shared DAG`); +}); + test('constants are session-free and same-session programs still compile', () => { const program = policyProgram(OPTIONS); const scale = constantF32(0.5); diff --git a/packages/text/tests/package/technique-schema.test.mjs b/packages/text/tests/package/technique-schema.test.mjs index a658f636..9fd658f2 100644 --- a/packages/text/tests/package/technique-schema.test.mjs +++ b/packages/text/tests/package/technique-schema.test.mjs @@ -44,6 +44,42 @@ test('every first-party schema is frozen', () => { } }); +test('rejected declarations leave caller-owned input untouched', () => { + const input = { + technique: 'test.rejected', + scope: 'glyph', + binding: { f32: ['duplicate'], u32: ['duplicate'] }, + buffers: { origin: { id: 1, scalar: 'f32', lanes: ['x', 'y'] } }, + }; + assert.throws(() => defineTechniqueSchema(input), TypeError); + assert.equal(Object.isFrozen(input.buffers), false); + assert.equal(Object.isFrozen(input.buffers.origin), false); + assert.equal(Object.isFrozen(input.buffers.origin.lanes), false); +}); + +test('schemas own their data: caller accessors cannot change validated widths', () => { + let reads = 0; + const accessorInput = { + technique: 'test.accessor', + scope: 'glyph', + binding: { f32: ['a'] }, + buffers: { + sneaky: { + id: 1, + scalar: 'f32', + get lanes() { + reads += 1; + return reads > 1 ? ['x', 'y', 'z'] : ['x']; + }, + }, + }, + }; + const schema = defineTechniqueSchema(accessorInput); + assert.deepEqual([...schema.buffers.sneaky.lanes], ['x']); + assert.equal(schemaPolicyBuffers(schema)[0].vectorWidth, 1); + assert.equal(schemaPolicyBuffers(schema)[0].vectorWidth, 1); +}); + test('glyphOrigin metadata must name a declared f32 buffer with two origin lanes', () => { const valid = defineTechniqueSchema({ ...declaration(), glyphOrigin: { buffer: 'origin' } }); assert.deepEqual(valid.glyphOrigin, { buffer: 'origin' });