Skip to main content
Version: 0.10.0

Schema Validation

A pipeline only works if the model it's handed has the shapes and data types it expects. Rather than discover a mismatch as a native crash or garbage output mid-inference, you declare the contract you need and check any .pte against it up front. That is what schema validation does, and it is what lets a user drop their own model into a built-in pipeline: the pipeline states exactly what it accepts, and the model either satisfies it or fails loudly at load time.

Every loaded Model exposes its schema — the exported contract of each method's inputs, outputs, and dimension constraints. You compare that against one or more allowed specs you declare, using validateSpec. Everything here lives in the schema namespace:

import { schema } from 'react-native-executorch';

const { validateSpec, method, f32, i64, DynamicDim, constraint } = schema;

What a schema describes

A schema is a ModelSpec: a map from method name to that method's signature. Each method's MethodSpec has three parts — an ordered list of inputs, an ordered list of outputs, and any runtime constraints relating their dimensions.

Every input and output is one ParamSpec: either a TensorSpec — a data type plus a shape, one entry per dimension — or a primitive value tag such as an int or a boolean. Most of this page is about tensor parameters, since that is where matching gets interesting; primitives and multiple methods are covered further down.

The one distinction to carry through everything below is between a dimension's domain and its runtime value. The domain is the set of sizes a dimension is allowed to take (a fixed constant, a range, or an enum); the runtime value is the single size it actually has in one execution. Validation works entirely on domains — it never sees runtime values — which is why relating actual sizes needs a separate runtime constraint.

Two kinds of spec

The distinction to hold onto is between the spec a model exports and the spec a pipeline allows.

  • An exported spec describes what a model actually provides. It uses ConcreteDim dimensions — every dimension has a fully known domain (a constant, a range, or an enum). This is what model.schema gives you; you never write it by hand.
  • An allowed spec describes what a pipeline can work with. It uses SymbolicDim dimensions, which add named symbols on top of concrete domains. You write these to state your requirements, and you can offer several as variants — matching any one is enough.

validateSpec checks an exported spec against your allowed specs and, on success, tells you which variant matched and what its symbols bound to.

Declaring an allowed spec

Build a method spec with method(name, inputs, outputs, constraints?). Inputs and outputs are ordered lists of parameter specs; for tensors, use the dtype shorthands — f32, i64, i32, ui8, bool — each taking a shape.

Within a shape, each dimension is written one of three ways:

Written asMeansBinds to an exported dimension that is
a number, e.g. 3an exact constantthat same constant
a string, e.g. 'H'a static symbol (wildcard)any constant; repeats must agree
DynamicDim('L')a dynamic symbola range or enum domain; repeats must agree
// "forward takes one float32 image [1, 3, H, W] and returns logits [1, N]",
// where H, W, and N are whatever constants the model was exported with
method('forward', [f32(1, 3, 'H', 'W')], [f32(1, 'N')]);

Use a plain integer when a dimension is genuinely fixed (batch size 1, 3 color channels), a string symbol when the value is fixed at export but you don't want to hard-code it (input resolution, class count), and DynamicDim only when the dimension truly varies per execution (a sequence length).

Validating and reading back symbols

Pass the exported schema and a set of named variants to validateSpec. Variants are tried in order; the first to match wins. The returned SpecMatch carries the matched variant key and accessors for the values each symbol bound to.

const { variant, dims } = validateSpec(model.schema, {
batched: method('forward', [f32(1, 3, 'H', 'W')], [f32(1, 'N')]),
unbatched: method('forward', [f32(3, 'H', 'W')], [f32('N')]),
});

// Read the bound constants back as numbers
const [N, H, W] = dims.constant('N', 'H', 'W');

const inpShape = { batched: [1, 3, H, W], unbatched: [3, H, W] }[variant];
const outShape = { batched: [1, N], unbatched: [N] }[variant];

This is the standard opening of a pipeline: validate first, then use the bound symbols to allocate the exact tensors the model needs. If nothing matches, validateSpec throws SCHEMA_MISMATCH with a per-variant explanation of why each one failed — so validation doubles as the pipeline's precondition check.

The dims accessors are typed to the domain you ask for:

There is also a single-symbol dim(name, kind?) accessor for one-off reads.

Validate before you allocate

Run validateSpec immediately after loadModel, before allocating any tensors. The bound symbols give you the exact shapes to allocate, and a mismatch is caught before you commit any native memory.

Dimension domains

Every concrete dimension has one of three domains, and your symbols bind to them:

  • constant — a single fixed value. Static string symbols (and plain integers) match these.
  • range — values from min to max in steps of step, via RangeDim.
  • enum — an explicit set of choices, via EnumDim.

A DynamicDim binds to a range or an enum. Reusing the same symbol across several dimensions requires every occurrence to bind to the same domain.

Same domain is not the same value

Binding a symbol to a domain says nothing about runtime values. Two dimensions that both bind DynamicDim('L') to the range 1..512 may still take different sizes in a single execution — say 10 and 25. If you need two dimensions to be equal at runtime, that is a runtime constraint, not a shared symbol.

Runtime constraints

Where domains describe the set of allowed values, runtime constraints describe relationships the actual values must satisfy in any single execution. Declare them as the fourth argument to method, using the constraint helpers.

A dimension is referenced by a DimRef{ paramSide, tensorIdx, dimIdx }, where tensorIdx counts only tensor parameters, skipping any primitives.

// Two int64 inputs [1, L1] and [1, L2] whose second dimensions must be equal
// at runtime (e.g. token ids and an attention mask of the same length)
method(
'forward',
[i64(1, DynamicDim('L1')), i64(1, DynamicDim('L2'))],
[f32(1, 'D')],
[
constraint.equality(
{ paramSide: 'input', tensorIdx: 0, dimIdx: 1 },
{ paramSide: 'input', tensorIdx: 1, dimIdx: 1 }
),
]
);

Two kinds are available:

Constraints are matched as declarations: for a variant to validate, the exported spec must declare exactly the same constraints, one-to-one — no missing ones and no extras. validateSpec compares the declarations; it does not evaluate whether they hold. Enforcement against the tensors you actually pass happens later, inside the native runtime.

Primitive parameters

Not every input or output is a tensor — a method can also take or return primitives: integers, doubles, booleans, strings, and their list forms. A primitive parameter has no shape or dtype, so instead of a tensor shorthand you write its ExecuTorchTag directly as { kind: 'Int' } in the appropriate input or output slot.

// forward(image, topK) -> (logits, elapsedMs)
method(
'forward',
[f32(1, 3, 'H', 'W'), { kind: 'Int' }], // second input is a plain int
[f32(1, 'N'), { kind: 'Double' }] // second output is a plain double
);

Primitives are skipped when counting tensors for a DimRef: tensorIdx indexes only tensor parameters. In [f32(1, 'N'), { kind: 'Int' }, i64(1, 'L')], the i64 tensor is tensorIdx: 1, not 2.

Multi-method models

A variant is not limited to a single method. Because method returns a one-method spec object, you merge several into one variant with object spread. validateSpec requires every method you declare to be present and to match (the model may export additional methods you don't mention). Symbols bind across the whole variant, so a symbol reused between methods must resolve consistently — which is exactly how you assert that two methods share a dimension.

// An encoder/decoder whose embedding width D is the same across both methods,
// and whose sequence length L binds to the same dynamic domain in each
const { dims } = validateSpec(model.schema, {
default: {
...method('encode', [i64(1, DynamicDim('L'))], [f32(1, 'D')]),
...method('decode', [f32(1, 'D')], [i64(1, DynamicDim('L'))]),
},
});

const [D] = dims.constant('D');
// Reusing L binds encode's and decode's sequence length to the same *domain*, not
// the same runtime value — the two methods may still run at different lengths.

Where the exported spec comes from

A model's exported schema is populated at load time from one of two sources:

  1. ExecuTorch metadata (default). When the .pte carries only static metadata, every dimension domain is a constant. This is enough for models whose shapes are fully fixed at export.
  2. A get_model_schema companion method. For models with dynamic or enumerated dimensions, or that declare runtime constraints, the .pte must export a method named get_model_schema returning a JSON-encoded spec. The loader calls it and overlays precise range, enum, and constraint information onto the base metadata. Only methods that need overrides have to appear in it.

You embed the companion method during export in Python by passing it as a constant method when lowering:

to_edge_transform_and_lower(
exported_program,
# ...
constant_methods={"get_model_schema": schema_json},
)

where schema_json is the JSON string encoding the model's spec. See Exporting Custom Models for the full export workflow.

Where to go next

API reference