Fluttorch

Ship a PyTorch model to a Flutter app, and fail the build when the export or the quantization moves the numbers.

pubspec.yamlfluttorch: ^1.0.0
fluttorch.dart
// generated from the manifest the exporter emitted, never hand-writtenfinal forecast = await SolarForecast.load(runtime, artifact: bytes);final out = await forecast.run(features: window);// and in the test suite, where the tolerance comes from the manifestawait expectParity( forecast.model, goldens: await DirectoryGoldenBundle.open('build/solar_forecast.fluttorch.json'),);
The product

A typed bridge between a PyTorch export and a Flutter app. The exporter writes the artifact, its manifest and its goldens in one command, and the Dart API is generated from the manifest. The goldens replay in your tests: the parity gate compares the outputs against the recorded tolerance and fails the build when they exceed it.

solar_forecast.fluttorch.jsonWaiting
load_mw float32[4]xnnpack · int8-static
#export goldensdevice|Δ|outcome
[0]·····················
[1]·····················
[2]·····················
[3]·····················
toleranceatol 0.1rtol 0.1cos ≥ 0.998
waiting

The build stops here. The drift is 1.72 against a bound of 0.1, and the report names the tensor, the backend and the quantization it was measured with.

what happens when the numbers move

Putting a model on a phone means exporting it, and usually quantizing it. Both steps change the numbers, and nothing in the toolchain tells you when they change too much: the build succeeds, the app runs, the output looks plausible, and the model is quietly worse than the one you evaluated. Preprocessing goes wrong the other way round. Written once in Python for training and again in Dart for serving, the two copies drift apart at the first refactor. Fluttorch closes both gaps with one document. The exporter writes a manifest next to the artifact, the Dart API is generated from it, and the reference outputs captured during the export replay in the test suite. Shapes, dtypes, preprocessing, labels and the weight hash are emitted once by the exporter and read by three consumers: the code generator, the parity gate and the runtime. Nothing on the Dart side restates them, so nothing there can disagree with training. The gate is the reason this project exists. Typed bindings are a convenience; catching numerical drift is the part nothing else does. expectParity replays the goldens, measures the drift and fails the build with a report naming the tensor, the bound it broke and the backend that measured it. Where the export captured taps, the report also names the earliest layer whose numbers moved. The runtime refuses to load an artifact whose content hash is not the one its manifest recorded, which is what catches a re-export that updated only half of the pair. ExecuTorch is the first backend, not the architecture. A parity gate is worth the same on LiteRT and ONNX Runtime, and the runtime interface is the only seam a backend touches: above that line nothing knows which engine is executing the model. Capabilities are reported rather than assumed, because exposing intermediate activations or running deterministically is a property of the hardware and not of the build, and every report says which backend produced it. At 1.0.0 the API is frozen and the deprecation policy is written down: a deprecated API survives at least two minor releases, which is ExecuTorch's own policy rather than a number chosen here. The tolerances are measured against two models rather than started from, and the measurement said something you would not expect: the simpler model drifts up to eight times further, because its outputs are large and a relative error is measured against the output while the rounding happened on intermediates. What 1.0 does not promise is that every engine runs every model. ExecuTorch lowers VoltaCast and then fails to execute it, identically under its own Python runtime, and the suite records that as a failing expectation so the day upstream fixes it, the suite says so. The three native bindings stay off pub.dev, which cannot carry the native half.

Scope
Open sourceDartFlutterPyTorchOn-device ML
Approach

What we did

01The manifest schema, with a Python writer and a Dart reader that produce the same document
02The fluttorch-export command: artifact, manifest and goldens in one run
03The build_runner generator, from manifest to typed Dart API
04The parity gate: golden replay, drift metrics and a report that fails the build
05Drift attribution to the earliest layer that moved, where the backend exposes taps
Highlights
+The manifest is the single source: shapes, dtypes, preprocessing, labels and the weight hash
+The Dart API is generated from it, so handing the model the wrong tensor is a compile error
+The gate fails the build and names the tensor, the bound and the backend that measured it
+The runtime refuses an artifact whose hash is not the one the manifest recorded
+Device capabilities are reported by the backend, never assumed by the build
+ExecuTorch is the first backend: LiteRT and ONNX Runtime sit behind the same seam
Results
1.0.0: the API is frozen, with a deprecation policy written down in STABILITY.md
Tolerances measured against two models rather than chosen, with the tool that reproduces them
Three engines behind one interface: ExecuTorch, LiteRT and ONNX Runtime
VoltaCast, a seq2seq Transformer forecasting Italian electricity demand, holds its goldens to 5.96e-7 on LiteRT
The Python writer and the Dart reader produce the same manifest byte for byte, down to denormals and negative zero
Three packages on pub.dev: fluttorch, fluttorch_gen and fluttorch_test
Resources
Open on GitHubVisit the siteRepository

Like what we build?

Let us talk about your next project