Using toy as a framework

June 12, 2026 · View on GitHub

toy is a CLI you can play with — but it is, first, a library of named, readable ML algorithms you build on. This page is the tour of that surface: the layered stack, the one-require entry point, recipes, and the two ways to start a project of your own.

The stack, as your API

Everything model-shaped lives in five layers. Each layer only calls the ones below it, every file is plain Ruby, and every layer is gated bit-identical against a reference — so when you swap a piece, the gates tell you exactly what changed.

LayerWhat lives thereExample
L1 primitivesthe math: matmul, softmax, layer_norm, RoPEToy.softmax(scores)
L2 blocksnamed compositions: attention, FFN, the transformer blocktransformer_block(x, block)
L3 archswhole models with an algorithm_card: GPT-2, SmolLM2/Llama, ViTToy::SmolLM2.forward(ids)
L4 enginesrealized graphs: forward + training + KV decode on a backendEngine::LlamaSeqEngine
L5 recipestraining plans over an engine: from-scratch, warm-start, LoRARecipes::FromScratch

(L6 — varying a recipe across sweeps and ablations — is experiment territory: yours, or Tao's.)

The promise across all four: the code reads like the paper. Shapes are annotated, blocks are named after the math, and each L3 arch emits Phuong–Hutter-style pseudocode (toy describe <model>) that round-trips back to the constructing Ruby (docs/authoring.md § Cards).

One require, the whole compute surface

require "toy/compute"

That single require loads the full compute API: the TinyNN FFI bridge, Toy::Mat, the L1–L3 stack, the engines (Toy::LLM::Engine::LlamaSeqEngine, Gpt2SeqEngine, VitTinyEngine), the recipes, the GGUF loader and tokenizer, AdamW and label helpers. It is the entry point for library consumers — anything that isn't the toy CLI itself.

For MRI dev-runs (notebook cells, REPL pokes — no Spinel build), require "toy/mri" loads the same surface under plain CRuby: pure-Ruby paths (configs, Mat, TransformerLM + Toy::Trainer) work for real; native calls raise a named Toy::MRI::NativeCallError instead of dying in a NoMethodError. See consuming-toy.md § MRI dev-runs (toy#71).

A training run in one screen

The recipe surface is deliberately flat: realize! builds the whole forward + loss + backward + optimizer graph natively (AdamW is baked into the ggml graph, not a Ruby loop), and step! drives one training step. Knobs are named setters, not positional walls; per-step data rides a validated TrainingBatch; you own the config and the data, the recipe owns the graph.

require "toy/compute"

cfg  = Toy::SmolLM2Config.tiny          # or .mid, or build your own shape
opts = Toy::LLM::RecipeOptions.new
opts.t_seq = 32                         # context window (required)
opts.seed  = 42                         # everything else has sane defaults

adamw = Toy::AdamW.for_from_scratch
batch = Toy::LLM::TrainingBatch.new(cfg.vocab, opts.t_seq, 1)

recipe = Toy::LLM::Recipes::FromScratch.new
recipe.realize!(cfg, opts)

steps.times do |step|
  batch.fill!(next_seq_ids)             # validates shapes, rebuilds labels
  batch.hp = adamw.hp(step)
  loss = recipe.step!(batch.seq_ids, batch.positions, batch.labels,
                      batch.hp, step == 0)
  puts "step #{step}: loss=#{loss}"
end

That is the entire contract — the same one toy's own gates train through (prep/smokes/smoke_compute_surface.rb is the gate fixture; examples/01_train_tiny.rb is the narrated, runnable version). WarmStart resumes from a GGUF instead of random init; LoRA trains adapters over frozen base weights (recipe.realize!(gguf, cfg, rank, opts)); VitTiny is the same shape for images. Writing your own recipe is ~100 lines of plain Ruby over an engine — docs/authoring.md § L4 walks through it.

Afterwards, every run is queryable from plain Ruby:

best = Toy::RunLog.scan("runs").first   # sorted by final loss
puts best.config, best.final_loss, best.loss_curve.first(5)

Starting a project

Two scaffolds, depending on what you're building:

toy new mylab           # an experiment tree: data/, runs/, examples wiring
toy new mylib --lib     # a Ruby library that consumes toy as a gem

The experiment scaffold's hello recipe reads its hyperparameters from ENV — one compile, many runs:

spinel algos/recipes/hello.rb -o hello
D_MODEL=128 N_LAYERS=4 STEPS=10 ./hello
D_MODEL=256 STEPS=10 ./hello            # no recompile

The --lib scaffold gives you a Gemfile (gem "toy"), the vendor wiring for Spinel compilation, a device-agnostic experiment.rb, and a multi-arch build script. Devices are chosen at compile time: your source requires the device-neutral surface, the per-device entry shims (main_cpu.rb / main_cuda.rb / main_metal.rbtoy/compute, toy/compute_cuda, toy/compute_metal) pick the backend, and

./build.sh cpu cuda     # → ./experiment_cpu, ./experiment_cuda

builds one native binary per device from the same experiment source. The full consumer reference — including self-contained native vendoring (ggml builds inside your tree, relocatable, no absolute paths) and the CUDA/Metal opt-ins — is consuming-toy.md.

Where to go deeper

  • authoring.md — add a primitive, block, arch, or recipe; the card round-trip; gate-before-merge.
  • architecture.md — how the plain-Ruby CLI drives Spinel-compiled native runners; the five-layer map.
  • consuming-toy.md — toy as a dependency of your gem or app.
  • gating.md — what "gated bit-identical" means mechanically.