Model Format¶
A MobileTransformers package is a self-describing directory: a top-level manifest plus one subtree per
variant (ABI × quantization × feature set). One package feeds both inference engines (the native
ORT runtime and the ONNX Runtime GenAI engine) — the engine is a selection over the same files, never a
separate download. This page is sourced from the manifest owner
(src/mobiletransformers/hub/package_format.py + artifacts/manifest.py) and the weight-handoff
owner (src/mobiletransformers/artifacts/handoff_map.py).
Two JSON contracts govern the on-disk shape: mobiletransformers_manifest.json (what the package
contains and how to select/download it) and weight_handoff_map.json (the single source of tensor
identity across train → merge → inference). Both are versioned and read fail-closed.
Versioning contract (both files)¶
Every cross-boundary JSON carries a schemaVersion and minReaderVersion ("MAJOR.MINOR") and is gated
by one shared check_compat() helper (artifacts/versioning.py):
- A reader tolerates unknown fields and minor bumps (additive changes are non-breaking).
- A reader rejects (raising
SchemaVersionError) when the file'smajorexceeds the reader's support, or when the file'sminReaderVersionis newer than the reader.
Current reader versions: manifest MANIFEST_READER_VERSION = "1.0"; handoff map
HANDOFF_MAP_READER_VERSION = "1.0"; package format SCHEMA_VERSION = "1.0" /
ARTIFACT_FORMAT_VERSION = 1. Devices enforce the contract via typed fail-closed parsing (they do not
run runtime JSON-schema validation); the schemas/*.schema.json files are CI parity artifacts.
Package layout¶
<package>/
├── mobiletransformers_manifest.json # the manifest (below)
├── tokenizer files (shared)
└── variants/
└── <variant-id>/ # e.g. cpu-int4
├── train/ # training artifacts (training/eval/optimizer models, checkpoint)
│ └── weight_handoff_map.json
├── inference/
│ ├── model.onnx # inference graph (external initializers)
│ ├── frozen_base.onnx.data # frozen base weights blob
│ ├── <tensor-name>.bin # one flat file per trainable/merged tensor
│ ├── <tensor-name>.bin.sha256 # integrity sidecar per tensor
│ └── weight_handoff_map.json
└── embedding/ # optional RAG embedding subtree
On device the installed cache mirrors this per repo: <cacheDir>/<sanitizedRepoId>/{train,inference,embedding}/….
sanitize_repo_id() maps a Hub repo id to that directory name; the installer writes into exactly this shape
so the runtime repositories discover models unchanged.
mobiletransformers_manifest.json¶
Built deterministically by build_manifest() from the on-disk tree. Top-level fields:
| Field | Meaning |
|---|---|
schemaVersion / minReaderVersion |
version gate (above). |
baseModelId |
source HF repo id. |
exportedAt / mobiletransformersVersion / artifactFormatVersion |
provenance. |
architectures / supportedTasks / selectedTask / trustRemoteCode |
model provenance from export. |
optimumOnnxVersion / transformersVersion / onnxRuntimeTrainingVersion / onnxRuntimeGenAIVersion |
toolchain pins. |
peftMethods / quantization |
realized capabilities of this package. |
defaultVariant |
variant id chosen when the caller requests none. |
variants[] |
per-variant descriptors (below). |
downloadPlan |
per-variant, per-feature repo-relative glob patterns — lets a client size + fetch selectively before touching large ONNX blobs. |
requiredFiles |
files that must exist for the package to be usable (includes the default variant's inference/model.onnx). |
fileSizes / sha256 |
stream-hashed integrity for every file. |
weightHandoff |
path to the default variant's weight_handoff_map.json. |
androidRuntime |
{ minimumAndroidApi, recommendedDeviceMemoryMb, requiredAbis }. |
license |
{ framework, baseModelWeights, noticeFile }. |
Each variants[] entry:
| Field | Meaning |
|---|---|
id |
variant id (e.g. cpu-int4) — cpu-<quantization>. Names the training-side quantization; see the note below. |
executionProvider |
cpu | xnnpack | nnapi. |
quantization |
QInt8 | QUInt8 | int4. The requested setting. |
supportedEngines |
subset of ["native", "genai"]. |
abi |
target Android ABI. |
features |
feature groups present (inference, training, rag, embedding, …). |
minimumAndroidApi / recommendedDeviceMemoryMb |
device requirements. |
weightHandoff |
this variant's handoff-map path. |
paths |
per-feature subtree paths. |
The variant id names the TRAINING quantization, not the inference graph's¶
A variant id is cpu-<quantization>, and --quant drives the training stage: the training graph is
weight-quantized (quant_model.onnx, dynamic per-channel) before generate_artifacts runs. The
inference export does not quantize — it ships whatever precision optimum exported, in practice fp32.
So a variant named cpu-int4 legitimately contains a uint8-quantized training graph beside an fp32
inference graph. That is the design, not a packaging bug, but nothing declared it and the directory
name was the only (misleading) signal. Two things now make it explicit:
inference/optimum_config.jsoncarriesinferenceGraphPrecision, measured from the graph that shipped (artifacts/parameter_budget.py::describe_graph_precision) — never inferred from the id.- The export gates the gap numerically:
verify_train_inference_parityruns identical tokens through both graphs and fails the export if the cross-entropy differs by more than 1.5 nats. Weight-only uint8 quantization moves it ~0.4 nats; a graph that lost its weights moves it to the uniform floor.
Do not read precision off the variant id. Read inferenceGraphPrecision for the inference half and
quantization for the training half.
Validation and variant selection¶
MobileTransformersManifest.validate(package_dir)version-gates the manifest, then asserts the selected variant's declared files (theinference/group andweight_handoff_map.json) exist on disk — fail-closed, naming the missing file, before any load.select_variant(execution_provider, quantization, total_mem_mb, requested_engine)hard-filters variants by requested ABI/EP/engine/memory and returns aSelectedVariant. (The soft preference layer — quant preference, download-size tie-break, storage-budget ceiling — lives inhub/variant_select.py.)
weight_handoff_map.json¶
The single source of tensor identity. It replaces the implicit name-agreement between the merge writer,
the native load side, and the inference graph with one declarative artifact. Owned by
artifacts/handoff_map.py; every consumer — the merger, the manifest, the native load path and the federated exporter — reads
this shape and none may redefine it.
Document-level fields:
| Field | Value |
|---|---|
schemaVersion / minReaderVersion |
"1.0" (version gate above). |
handoffMode |
external_initializer — the only supported mode in v1 (model_input / adapter are fail-closed stubs). |
engines |
["native", "genai"] — the engines this layout serves. |
externalDataLayout |
one_file_per_tensor. |
frozenBaseBlob |
frozen_base.onnx.data. |
mergerModels |
resolved MergerVariant → merger ONNX filename. |
entries[] |
one entry per trainable MatMul (below). |
Each entries[] element is one trainable layer's full identity:
| Field | Meaning |
|---|---|
trainingBaseLayerName |
the training-side base layer (e.g. backbone.…q_proj.base_layer). |
dtype / shape |
the weight-like role's dtype (float16/float32/int8/uint8/int4) and shape. |
tensorDtypes / tensorShapes |
role → that role's own on-disk dtype/shape — see "Per-role dtype and shape". |
checkpointNames |
role → ORT-checkpoint tensor name (the frozen weight + the adapter A/B factors). |
mergerOutputNames |
role → the merger graph's output name. |
mergedTensorNames |
role → the name the on-device merger stamps. |
inferenceInitializerNames |
role → the initializer name in inference/model.onnx. |
externalDataLocation |
role → the flat per-tensor .bin filename in inference/. |
sha256 |
role → integrity hash of the shipped (pre-merge) bytes — see "Checksum precedence". |
genaiInputNames |
role → GenAI input name (when GenAI consumes the tensor as an input). |
quantization |
optional { weightQuantizedName, scaleName, zeroPointName }. |
transposePolicy |
no_transpose | already_transposed_for_inference — how the on-disk weight is oriented relative to the training checkpoint. See "Weight orientation" below; do not honour it without reading that section. |
Roles are drawn from the fixed order ("weight", "weight_quantized", "scale", "zero_point").
Invariants (enforced by HandoffMap.validate(), fail-closed)¶
- Merged name equals inference name for every role:
mergedTensorNames[role] == inferenceInitializerNames[role](the external-initializer contract — the writer stamps exactly the name the inference graph reads). - Quantized names come from the observed inference initializers, never derived from
trainingBaseLayerName(quantization.weightQuantizedName/scaleName/zeroPointNamemust equal the observed initializer names). - No two entries may claim the same
externalDataLocationfile or the same inference initializer name. - Serialization is byte-deterministic (
to_json()sorts keys and sorts entries by canonical weight name), so the manifest'ssha256over this file is stable.
Per-role dtype and shape¶
Each <name>.bin is an ONNX external-data blob: raw tensor bytes with no header. Nothing in the
file describes its element type or dimensions, so the map is the device loader's only source — and it
must describe every role separately, because a quantized entry's roles do not share a layout:
| Role | Typical dtype | Shape |
|---|---|---|
weight |
float16 |
the logical [out, in] |
weight_quantized |
uint8 (int4 packed two-per-byte) |
packed, narrower than the logical shape |
scale |
float16 |
one per quantization group |
zero_point |
uint8 |
one per quantization group |
tensorDtypes[role] / tensorShapes[role] carry each role's own pair, taken from the initializers as
actually observed at export (_classify_initializers). The entry-level dtype/shape describe the
weight-like role only and remain as the fallback for maps written before these fields existed — sound
for a single non-quantized role, which is why validate() rejects a quantized entry that omits them.
The device loader (session_cache.h::load_tensor_raw) constructs the tensor from this declaration
rather than checking a parsed header against it, and fails closed when the file size is not exactly
numel × element_size.
Weight orientation¶
Two conventions meet at the merge, and they disagree:
| side | convention |
|---|---|
training checkpoint (base_layer.weight, a PyTorch nn.Linear) |
[out_features, in_features] |
inference initializer (an ONNX MatMul right-hand side) |
[in_features, out_features] |
transposePolicy records which one the on-disk .bin uses, relative to the checkpoint. It is
observed, not declared: the merger computes base + scale · (adapter_B @ adapter_A), whose shape
is (adapter_B.rows, adapter_A.cols). If that equals the weight's on-disk shape the two agree
(no_transpose); if it equals the reverse, the on-disk tensor is the transpose
(already_transposed_for_inference); anything else describes a delta that cannot be added to its own
weight and is refused at export.
A square weight cannot decide its own orientation — [576,576] satisfies both readings — so the
policy is resolved package-wide from the entries that are not square. One export uses one
convention throughout, and mixed conventions fail closed.
⚠️ Consumers: derive the orientation, do not trust this field yet. Every package exported before 2026-08-14 declares
no_transposeunconditionally, because the producing side never assigned it — and that is wrong for all of them. The on-device merger deliberately observes orientation from the tensors themselves rather than reading this field, so that packages already in the wild keep working. The field became meaningful for packages exported after that date. A declaration is only safe to honour once no artifact in circulation carries a wrong value for it.
Checksum precedence: the sidecar wins¶
Each <name>.bin has two possible integrity sources, and they answer different questions:
| Source | Written by | Covers |
|---|---|---|
<name>.bin.sha256 (sidecar) |
the device merger, weight_merger.cpp::write_raw_tensor_atomic, atomically on every merge |
the live bytes currently on disk |
entries[].sha256[role] (in the map) |
the exporter, once, at package build | the shipped bytes as published |
A reader must prefer the sidecar and fall back to the map. After an on-device train→merge the
.bin and its sidecar are both rewritten, but the map is not — C++ only ever reads
weight_handoff_map.json. A reader that preferred the map would therefore compare post-merge bytes
against the pre-merge shipped digest and reject a perfectly correct merge.
Absence of both is fail-closed: the load gate throws rather than skipping verification. A stale
sidecar still fails, so the precedence never weakens the gate — it only picks the authority that is
actually kept current. Enforced by HandoffPrecondition.loadMergedWeightsReady
(internal/runtime/HandoffPrecondition.kt).
The map is produced offline by TrainableTensorCodec.from_peft_mapping(...) (which joins the training-side
peft_mapping with the observed inference initializers — a naming drift raises at build time, never
silently at runtime) and consumed on device by the map-driven load path.