← Back to blog Engineering

model_metadata.json: A Declarative Schema for ML Model Execution

How a single JSON file replaces custom inference scripts and makes any ML model self-describing. No more 'works on my machine.'

Glenn Sonna
· · 6 min read
machinelearningarchitectureopensourceai

Every ML model you’ve ever deployed had the same hidden problem: the inference script.

That Python file with hardcoded tensor shapes, tokenizer paths, magic normalization constants, and platform-specific workarounds. The one that “just works” on the original developer’s machine and breaks everywhere else.

We replaced it with a JSON file.


The Problem

Here’s what typical model deployment looks like:

# inference.py — "just copy this file with the model"
import onnxruntime as ort
import numpy as np
from tokenizer import load_tokenizer  # custom module, good luck finding it

session = ort.InferenceSession("model.onnx")
tokenizer = load_tokenizer("vocab.json")

def run(text):
    tokens = tokenizer.encode(text)
    tokens = np.array([tokens], dtype=np.int64)
    # Magic: pad to 128 because the model was exported that way
    if tokens.shape[1] < 128:
        tokens = np.pad(tokens, ((0,0), (0, 128 - tokens.shape[1])))
    outputs = session.run(None, {"input_ids": tokens})
    # Magic: take argmax of last dim, skip first token, decode
    predicted = np.argmax(outputs[0], axis=-1)[0][1:]
    return tokenizer.decode(predicted)

This script contains critical deployment knowledge:

  • Input tensor name is input_ids
  • Input must be int64
  • Padding to 128 is required
  • Output needs argmax → skip first → decode

None of this is discoverable from the ONNX file alone. Lose the script, lose the ability to run the model.

The Solution: model_metadata.json

Every model in Xybrid ships with a model_metadata.json that declares everything needed to run it:

{
  "model_id": "wav2vec2-base-960h",
  "version": "1.0",
  "description": "Speech recognition using CTC decoding",

  "execution_template": {
    "type": "SimpleMode",
    "model_file": "model.onnx"
  },

  "preprocessing": [
    {
      "type": "AudioDecode",
      "sample_rate": 16000,
      "channels": 1
    }
  ],

  "postprocessing": [
    {
      "type": "CTCDecode",
      "vocab_file": "vocab.json",
      "blank_index": 0
    }
  ],

  "files": ["model.onnx", "vocab.json"],

  "metadata": {
    "task": "speech-recognition",
    "architecture": "wav2vec2",
    "language": "en"
  }
}

The runtime reads this file, builds the execution pipeline, and runs it. No inference script. No custom code. No implicit knowledge.

How It Works

The TemplateExecutor (Xybrid’s core execution engine) processes the metadata in three phases:

Input Envelope


┌─────────────┐
│ Preprocess   │  AudioDecode: WAV bytes → f32 samples at 16kHz
└──────┬──────┘


┌─────────────┐
│ Model        │  ONNX inference: samples → logits
└──────┬──────┘


┌─────────────┐
│ Postprocess  │  CTCDecode: logits → text via vocab.json
└──────┬──────┘


Output Envelope

In Rust:

let metadata: ModelMetadata = serde_json::from_str(
    &std::fs::read_to_string("model_metadata.json")?
)?;

let mut executor = TemplateExecutor::with_base_path("./model-dir");
let output = executor.execute(&metadata, &input)?;

Three lines. Works for ASR, TTS, classification, embeddings — any model type. The metadata file is the only thing that changes.

The Schema

Execution Templates

The execution_template field defines how the model file is loaded and run:

// Standard ONNX model
{ "type": "SimpleMode", "model_file": "model.onnx" }

// GGUF model (llama.cpp)
{
  "type": "Gguf",
  "model_file": "model.gguf",
  "context_length": 4096
}

// Whisper (Candle, autoregressive)
{
  "type": "WhisperMode",
  "model_file": "model.safetensors",
  "tokenizer_file": "tokenizer.json",
  "config_file": "config.json"
}

The template type tells the executor which backend to use (ONNX Runtime, llama.cpp, Candle) and what files are needed.

Preprocessing Steps

Available preprocessing steps and what they do:

StepInputOutputConfig
AudioDecodeWAV bytesf32 samplessample_rate, channels
PhonemizeTextToken IDsbackend, tokens_file, dict_file
TokenizeTextToken IDsvocab_file, max_length

Example — TTS preprocessing:

{
  "preprocessing": [
    {
      "type": "Phonemize",
      "backend": "MisakiDictionary",
      "tokens_file": "tokens.txt"
    }
  ]
}

This converts "Hello world"[phoneme token IDs] using a built-in phonemizer. No external dependencies (no espeak-ng installation needed).

Postprocessing Steps

StepInputOutputConfig
CTCDecodeLogits tensorTextvocab_file, blank_index
TTSAudioEncodef32 waveformWAV bytessample_rate, apply_postprocessing
ArgMaxLogits tensorClass index

Voice Configuration (TTS-Specific)

TTS models can declare available voices:

{
  "metadata": {
    "voices": {
      "voices_file": "voices.bin",
      "format": "Float32Array",
      "embedding_dim": 256,
      "voices": [
        { "id": "af_heart", "name": "Heart", "language": "en-us" },
        { "id": "af_sky", "name": "Sky", "language": "en-us" }
      ],
      "default_voice": "af_heart"
    }
  }
}

The runtime loads the voice embedding from voices.bin and passes it as an additional model input. Consumers just pick a voice by name.

Why Not ONNX Model Zoo Format? Or MLflow?

ONNX Model Zoo has model cards, but they’re documentation, not execution configuration. They don’t tell you how to preprocess inputs or postprocess outputs.

MLflow has model signatures, but they describe tensor shapes, not the pipeline around the model. You still need custom code for tokenization, audio decoding, etc.

model_metadata.json is execution-complete: everything from “raw user input” to “usable output” is declared. The runtime needs zero custom code per model.

ONNX ZooMLflowmodel_metadata.json
Tensor shapesYesYesImplicit
PreprocessingNoNoYes
PostprocessingNoNoYes
Multi-file modelsNoPartialYes (files array)
Voice/variant configNoNoYes
Runtime-executableNoPartialYes

Real Examples

ASR (wav2vec2)

{
  "model_id": "wav2vec2-base-960h",
  "execution_template": { "type": "SimpleMode", "model_file": "model.onnx" },
  "preprocessing": [
    { "type": "AudioDecode", "sample_rate": 16000, "channels": 1 }
  ],
  "postprocessing": [
    { "type": "CTCDecode", "vocab_file": "vocab.json", "blank_index": 0 }
  ]
}

Input: WAV audio bytes → Output: transcribed text.

TTS (Kokoro)

{
  "model_id": "kokoro-82m",
  "execution_template": { "type": "SimpleMode", "model_file": "model.onnx" },
  "preprocessing": [
    { "type": "Phonemize", "backend": "MisakiDictionary", "tokens_file": "tokens.txt" }
  ],
  "postprocessing": [
    { "type": "TTSAudioEncode", "sample_rate": 24000, "apply_postprocessing": true }
  ]
}

Input: text string → Output: WAV audio bytes.

LLM (Qwen 3.5)

{
  "model_id": "qwen3.5-0.8b",
  "execution_template": {
    "type": "Gguf",
    "model_file": "Qwen3.5-0.8B-Q4_K_M.gguf",
    "context_length": 4096
  },
  "preprocessing": [],
  "postprocessing": [],
  "metadata": {
    "task": "text-generation",
    "architecture": "qwen35",
    "backend": "llamacpp"
  }
}

Input: text prompt → Output: generated text. No pre/postprocessing needed — the GGUF backend handles tokenization internally.

Extending the Schema

Need a new preprocessing step? Add a variant to the enum and implement the handler:

// In steps.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PreprocessingStep {
    AudioDecode { sample_rate: u32, channels: u16 },
    Phonemize { backend: PhonemizerBackend, tokens_file: String },
    Tokenize { vocab_file: String },
    // Add your new step:
    MelSpectrogram { n_fft: usize, hop_length: usize, n_mels: usize },
}

Implement the handler, and every model that uses MelSpectrogram in its metadata will automatically work across all platforms.

The Payoff

With model_metadata.json:

  • Model authors declare how their model runs, once
  • Runtime developers implement preprocessing/postprocessing steps, once
  • App developers write executor.execute(&metadata, &input) — no model-specific code
  • New platforms (Flutter, Swift, Kotlin) get every model for free — they use the same metadata

One JSON file. Zero inference scripts. Every model is self-describing and portable.


See it in action: github.com/xybrid-ai/xybrid — browse fixtures/models/ for real model_metadata.json examples.


Thoughts on the schema design? Things we should add? Let us know in the comments.

Related articles

· 7 min read

Building a Cross-Platform ML Inference SDK in Rust

How we built a single Rust core that powers ML inference across CLI, Flutter, Swift, Kotlin, and Unity — and the architectural decisions that made it possible.

rustaiarchitecture
· 6 min read

Streaming LLM Tokens from Rust to Flutter in Real-Time

How we bridge real-time token streaming from a Rust inference engine to Flutter's reactive UI using flutter_rust_bridge callbacks.

flutterrustai
· 10 min read

Edge AI vs Cloud AI: When to Run Models On-Device

A practical decision framework for choosing between on-device and cloud-based AI inference, with cost analysis, comparison tables, and real-world use cases.

edge-aicloud-aiinference