DEVELOPMENT — how to build on Fucina¶
The working method for new development on this codebase — features, kernels,
model families, training machinery — written for the next contributor,
human- or agent-driven. AGENTS.md is the entry point (toolchain, commands,
repo map, house rules); PORTING.md is the method for ports
specifically; the reference is the API contract. This file is
the connective tissue: the invariants a change must respect, what already
exists so you don't rebuild it, which existing code to start from, and the
delivery loop that takes a change from idea to merged.
The one-line version: find the existing capability first, extend it at its designed seam, verify against the reference backend and the gates, measure before claiming speed, and report what you actually did.
1. Invariants¶
Each invariant states the rule, where it is enforced, and what a violation looks like. The ones marked review-only have no mechanical gate — nothing fails automatically when you break them, which is exactly why they are listed here.
1.1 Layering is a one-way street¶
A band may depend only on bands at or below it: apps (examples/, apps/,
tools/, bench/) → models (src/models/) → facade (src/fucina.zig) → autograd/training
(src/ag/, optim/es/lora/persistence) → tagged ops (src/tag_ops.zig) →
exec runtime (src/exec/) → backends (src/backend/) → tensor/storage/dtype.
fucina_models files import the fucina module (public surface plus
fucina.internal), never individual src/*.zig files.
Enforced by: zig build arch-check — the production import graph of src/,
examples/, apps/, bench/, and tools/ must have zero strongly-connected
components AND zero band inversions (AST-based, test-aware). Band direction is checked against the layer table in
ARCHITECTURE.md, encoded as band_table in
tools/check_import_graph.zig: production layer inversions are a failed
build, full stop. A production file in no band fails the check as well, so a
new src/ root cannot slip in unclassified.
Violation: exec importing ag; an example importing src/tensor.zig
directly; family-specific logic inside src/exec/ (see §1.8).
1.2 Eager and local — no graph compiler (review-only)¶
No global graph object, no fusion pass, no lazy evaluation, no planner. Every
op validates, allocates through ExecContext, and runs a kernel immediately.
This is a deliberate design stance, not debt. An app-level compiled replay is
fine inside an example (apps/facedetect/graph.zig is the precedent);
a core IR is not. Don't add one without a concrete design.
1.3 One public tensor, comptime tags, sealed raw layer¶
fucina.Tensor(spec) is the only public tensor. Tags and rank are comptime;
sizes are runtime. What each dtype branch can do is enforced by the type
system: .f32 differentiates; f16/bf16 add forward math and 16-bit-leaf
autograd (gradients are always f32); integers/bool are constants;
block-quantized tensors dequantize, gather, and serve as matmul RHS — nothing
else. The raw tensor is deliberately not exported (a comptime guard in
src/fucina.zig makes it a compile error); in-tree internal access goes
through fucina.internal.
Violation: a second public tensor type; runtime tag values; loosening a dtype branch with a runtime check where a compile error is the design.
1.4 Explicit ownership and the deinit convention¶
Storage is refcounted and owned; slices and views borrow. Every op returns an
owned result; defer x.deinit() is the norm, and that deinit is what drives
buffer recycling through the BufferPool. Training uses exec scopes
(openExecScope/closeExecScope) to own intermediates implicitly; pure
inference stays deinit-as-you-go. deinit(self) when the struct carries its
own ctx/allocator, deinit(self, allocator) for POD-ish holders; either way
end with self.* = undefined. An arena for transients was evaluated and
rejected — MEMORY-MODEL.md records why (peak memory,
cache locality, refcounted views, training lifetimes); do not reintroduce
one.
Enforced by: the testing allocator (leaks fail tests), the BufferPool's
outstanding == 0 teardown assert, and the undefined tripwire in Debug.
1.5 Kernels never allocate — outputs are exec-supplied (review-only)¶
Backend compute leaves (backend/vector/, backend/quant/ dots) are
allocation-free and infallible; results go into buffers the
ExecContext supplies. The one sanctioned exception is the
quantized-RHS dispatch tier (matmul2DQuantizedRhs*), which takes an
explicit allocator for per-call LHS-quantization scratch. Don't add
allocation below that tier.
1.6 Validate, then call an unchecked kernel (review-only)¶
Shape/stride/alignment/contiguity checks live in the caller/runtime; the kernel underneath is small, unchecked, and fast. ReleaseFast drops safety checks, so a kernel that only behaves because Debug catches it is broken — prove invariants, don't use checks as logic.
1.7 Comptime dispatch, exhaustive switches¶
Backend (-Dbackend), BLAS provider, and GPU provider are chosen at build
time; dead arms are never analyzed. Prefer exhaustive switch over
dtype/backend so adding a variant forces edits everywhere — the compile
error is the enforcement. A silent else that swallows a new dtype defeats
the design. Related trap: a bare -Dtarget cross-build drops to the
architecture baseline and silently loses the fast kernel arms — pin -Dcpu
or build on the machine that runs it.
1.8 Placement policy¶
Reusable engines other src/models consumers should import go in
src/models/<family>/; single-purpose parity ports and their DSP/IO plumbing
stay app-local in apps/<name>/; generic helpers stay flat in
src/models/; family-specific kernel orchestration lives in the family over
the fucina.internal seam, never inside the generic exec runtime; shared
cross-family scheduling lives once in the moe band (moe/chain.zig is
the pattern). New core tensor ops belong in the
exec/backend bands — but a good port usually needs none: nanochat is
entirely app-local over the public facade. The apps band itself has two
homes: examples/<name>/ is teaching code (one main.zig, a README is
fine, no test files, no C shims, no vendored assets; it exists to be read
and copied); apps/<name>/ is everything with product or port shape
(multi-file, own tests, shims, goldens, or a real CLI surface).
1.9 Determinism is a contract¶
src/rng.zig is the repo-owned deterministic RNG; its (seed → values)
mapping is a checkpoint contract (dropout masks, APOLLO projections, ES
noise are regenerated from seeds, not serialized). The SFT loader's
(seed, epoch) → permutation mapping is golden-pinned. Parallel kernels are
bitwise-deterministic for any thread count, or they document their rounding
class precisely (the -Dvector-scan option text is the model to imitate).
Never swap in std.Random for anything seed-persisted; never change the fill
algorithms without accepting that existing checkpoints break.
1.10 Scalar is the specification¶
-Dbackend=scalar is the executable reference leg. It does not select a
second provider: it sets backend/isa.zig's reference flag, and every
kernel entry in the one provider (backend/native.zig) then selects its
scalar reference arm at comptime (the scalar namespaces inside
src/backend/vector/*.zig; the quantized GEMM family selects its plain
scalar accumulators through the scalar tier, and formats without a
scalar twin take their exact-integer portable arm). The SIMD, BLAS, GPU
and lane-pack dispatch arms compile out on that leg.
src/backend/parity_test.zig holds each kernel entry to its scalar twin,
and checks the quantized cases (q8_0/q4_k/q5_k/q6_k/tq2_0 and the packed
x4/x8 arms) against a dequantized f32 reference on both legs.
The scalar leg runs before merge when the change touches src/backend/ or
src/exec/ (once, on the final code; it is slow by design); other changes
skip it, since parity_test.zig already diffs entry against reference arm
inside every native zig build test. Everything integer is bit-exact
across architectures; float tile kernels document association-order
tolerance instead.
2. Check before you build¶
The most common failure mode of a capable contributor on this codebase is rebuilding something that exists. Search this table first; the § pointers go into the reference, whose snippets are machine-verified.
| You need… | It exists as… | Where |
|---|---|---|
| A contraction (matmul, batched, multi-index) | einsum — THE contraction engine; dot is its special case. Don't hand-roll permute+matmul chains. |
§4.8–4.9 |
| A new pointwise op with autograd | elementalUnary/elementalBinary — supply scalar fwd/bwd fns, get a SIMD-chunked parallel op with a VJP. |
§4.4 |
| Indexing / slicing / functional updates | select, slice, sliceStep, indexSelect, gather, setSlice, setRows, indexAdd, scatterAdd, maskedScatter, where, oneHot |
§3.7, §4.17 |
| Reductions, scans | SIMD-promoted sum/mean/max/min/prod/logsumexp; cumsum/cumprod (+ -Dvector-scan) |
§4.7 |
| Attention | groupedAttention: causal/bidirectional, sliding window, additive bias, sinks, ALiBi-style max_bias, f32/f16/q8 KV, saved-stats backward |
§4.13 |
| RoPE | interleaved/half/tail-aligned modes, partial rotary, freq-factor (YaRN-style) tables, inverse tables, hand-fillable RopeTable |
§4.12 |
| Norms, softmax, losses | rmsNorm family (incl. fused mul/add/rope), layerNorm, groupNorm; softmax with scale/mask/sinks; crossEntropy (smoothing, ignore-index, accum scale), mse/huber/bce/kl/nll |
§4.10–4.11, §4.15 |
| Vision / conv | channel-last conv2d (im2col GEMM + Winograd), conv1d/causal/transpose, pool2d, prelu, channelAffine, upsample, zeroPad2d — all with autograd | §4.14 |
| MoE | routerTopK, moe.expertFfn/Batch, MoeRhs packed containers, moe.chain scheduling, disk-streamed experts (ExpertStore) for models larger than RAM |
§4.16, §4.18, §13.2 |
| Autograd machinery | seeded backward (backwardWithGrad), noGrad, activation checkpointing, customVjp, gradcheck; VJP inventory in §5.8 |
§5 |
| Training | SGD/AdamW/Adam/Muon/APOLLO (torch-golden-parity), OptimizerSet param groups, LR schedules, clipping, ParamRegistry, LoRA adapters, ES (incl. ternary-native), 16-bit leaves with f32 grads + optimizer masters |
§11 |
| Quantized weights | hot packed kernels (Q4_K/Q5_K/Q6_K/Q8_0/TQ2_0 + 2-bit expert tier), cold decode (IQ*, FP4…), byte-exact ggml encoders, PTQTP trit-planes, fake-quant round trips (FP8/FP4 microscaling, Hadamard, f16) | §10 |
| Persistence | GGUF read/write/transcode (byte-verbatim re-emit), safetensors, named state dicts with alias remapping, training-checkpoint directories, export-gguf (incl. LoRA merge) |
§12 |
| LLM plumbing | LinearWeight (dispatch to BLAS/Metal/CUDA/quant kernels is inside — never hand-roll a linear), gguf_meta readers, KV cache (f16/q8_0) + crash-safe persistence, BPE + SPM tokenizers, sampler + LogitProcessor + llguidance constrained decoding, generic Conversation chat engine (+ sendBatch), speculative decoding cascade + grammar-constrained drafting, native MTP |
§13 |
| Parallelism / infra | worker team + parallelChunks (this is the parallel-loop contract), BufferPool, RhsLifetime RHS caching, deterministic RNG, GPU offload gates |
§6, §9 |
If a capability is genuinely missing, check the design records first — SPECULATIVE.md, CONSTRAINED-DECODING.md, TERNARY.md, PTQTP.md, MEMORY-MODEL.md — several "obvious" additions (arenas, LUT kernels, negative-step views, do-concurrent wrappers) were evaluated and rejected with measurements, and the records say why.
3. Start from a template¶
Every kind of work has a best-in-class precedent in the tree. Read it before designing.
| New work | Start from | Why |
|---|---|---|
| LLM family, llama-shaped | src/models/qwen3/ |
cleanest dense+MoE model, spec decode, trainer |
| LLM family, SPM tokenizer / sliding window / MoE engines | src/models/gemma/ |
per-layer KV geometry, MoE engine with GPU arm |
| Hybrid/recurrent blocks | src/models/qwen35/ |
Gated-DeltaNet over the same loader conventions |
| MLA / MTP / streamed-expert giants | src/models/deepseek4/ |
compressed KV, hyper-connections, out-of-core experts |
| Non-autoregressive decoder | src/models/diffusion_gemma/ |
two forward modes over one weight set |
| Pure-CNN vision port | apps/facedetect/ |
load-once models, BN-fold at load, byte-identical JSON goldens |
| VLM port | apps/locate_anything/ |
ViT tower + LM, custom RopeTable, MTP box decode |
| ASR / encoder stack | src/models/parakeet/ |
the reusable-family precedent |
| TTS / codec port | apps/omnivoice/ |
codec parity, RNG parity, chunked streaming |
| Streaming DSP / effects | nam-zig (its own repository; a package consumer) | streaming engines over causalConv1dStreaming and Tensor.lstm, format interchange, live IO |
| Training pipeline | apps/nanochat/, examples/spirals/main.zig, apps/finetune/main.zig |
full pretrain→SFT→chat; minimal optimizer demo; LoRA on a real GGUF |
| HTTP/API frontend | apps/lmserve/main.zig |
OpenAI-compatible mapping tables, SSE, backend matrix |
3.1 Adding one tensor op, end to end¶
One differentiable float op touches a fixed file list. Walk it top to
bottom; every stop is either compile-checked or named here so nothing is
left to memory. Use an existing op in the same domain as the line-level
template (e.g. softmax for a row op, maxPool2d for a pool op).
src/backend/ops.zig— only if the op needs a new selector enum member. The consuming switches are exhaustive, so every arm site below becomes a compile error until handled. One exception is named in step 8.src/backend/native.zig— add the kernel to thekernelstable. The declaration list is the interface;backend.zig'sconformKernelschecks thepcrule from the signature (nopcfirst = pool-free).src/backend/vector/<domain>.zig, itsscalarnamespace — the scalar reference arm. This is the specification (see 1.10; for the quantized GEMM family the reference is the*Scalaraccumulator inbackend/quant/, selected by thescalartier ofbackend/isa.zig).src/backend/native.zig+src/backend/vector/<domain>.zig— the SIMD implementation behind the same kernel entry,pc-first signature; the entry selects scalar-vs-SIMD onisa.reference.src/backend/parity_test.zigor the domain'svector/*_tests.zig— pin scalar and native to each other.src/exec/<domain>.zig— the op body over*ExecContext(validate, then call the unchecked kernel), plus one alias line insrc/exec.zigunder the domain's banner.src/ag/backward/<domain>.zig— the VJP (the facade mixin of the same domain imports the file directly; there is no central alias table).- If the op is a
UnaryOp: classify it inag/backward/elementwise.zig'sunaryUsesOutput— the switch is exhaustive, and a wrong classification is a wrong gradient, so decide deliberately whether the derivative is cheaper from the forward output. src/ag/tensor/float/<domain>.zig— the facade method (tag algebra, result finishing), plus one alias line insrc/ag/tensor.zig.src/ag/tensor/typed/<math|int|widened>.zig— only if the op should also exist on non-f32 constant tensors (native over the stored dtype, integer-only, or the f16/bf16 widened family). A view or data-movement op with a dtype-generic kernel goes insrc/ag/tensor/views.ziginstead, once, for every scalar dtype.- GPU offload (only if warranted by measurement): the provider seam in
src/backend/gpu_provider.zigplus both providers andgpu_none.zig; the conformance check forces all three. - Tests:
src/exec/<domain>_tests.zig(exec semantics),src/ag/tensor_tests/<domain>.zig(facade + gradcheck; forward the file fromsrc/ag/tensor.zig's test block). - Docs: the op's row in
docs/reference/04-tensor-operations.md(or the domain chapter); runnable snippets are compile-gated byzig build snippet-check.
Gates for the whole loop: zig build test, zig build test-fucina
-Dbackend=scalar, zig build arch-check, and zig build bench (or the
domain bench) when the op is hot-path (see 4.3).
4. The delivery loop¶
4.1 Plan with mechanical accepts¶
Structure work as items with three parts — Do (imperative, with code anchors), Accept (a mechanical gate: an exit code, a named test, a grep that must return nothing), Refs — and complete one item at a time, ticking it only when its Accept gate and the full build pass. State "done when" before starting, including the honest negative arm ("works per-stream OR documents why it stays single-stream"). If a needed fixture doesn't exist, the honest terminal state is an explicit BLOCKED naming what a human must supply — never a fabricated number. (This is PORTING.md §3 discipline; it applies to feature work just as well.)
4.2 Run the gates that your change can affect¶
Fucina regresses in exactly two ways: it becomes wrong or it becomes
slow (CONTRIBUTING.md). Match the gate set to the blast radius — a doc
fix needs no benchmark; a tokenizer change needs the parity oracles but no
GEMM sweep; a kernel change needs both tracks, always.
| Gate | What it proves | Run when |
|---|---|---|
zig build test |
every test root, native backend, no assets needed (§7.1) | always |
zig build test-fucina -Dbackend=scalar |
native agrees with the reference backend on the kernel/spec surface (the fucina root; model-golden forwards are native-only by design) | src/backend/ or src/exec/ changed — once, on final code |
zig build test -Dblas=none |
pure-Zig kernels unbroken | anything numeric near GEMM dispatch |
zig build arch-check |
layering intact (zero SCCs) | new files / imports |
zig build doc-check |
the doc index (docs/README.md) matches the docs-nav set; intra-doc links, anchors, and src/<file>.zig:<line> citations resolve; README's fetch pin matches the manifest |
doc adds/moves/edits |
zig build snippet-check |
every runnable reference snippet still compiles and passes (§7.2) | any docs/reference edit; any public-API change |
zig build x86dot-check |
cross-ISA int8/quant dot parity + compile-only ISA legs | quant kernel / dot-arm changes |
zig build cuda-check |
CUDA provider still compiles (GPU-less machines) | exec/backend surface changes on GPU-adjacent code |
zig build metal-check |
Metal provider and its shim still compile (macOS hosts, no device needed) | exec/backend surface changes on GPU-adjacent code |
zig build bench-check |
every bench main still compiles (addBench registers each bench into the gate, so a new bench cannot land outside it) |
bench/ or op-signature changes |
Family parity oracles (--tokenize, logit parity, --compare batteries) |
model behavior unchanged | anything touching a family |
tools/bench_gate.py / tools/opbench_gate.py |
speed did not regress (paired, median, CV-guarded) | any kernel/perf/hot-path change |
Anything under src/backend/, src/exec/, or a family's forward path needs
both tracks: correct-but-slower and fast-but-wrong are both regressions,
and neither is accepted alone. The single exception — a speed cost that is
the unavoidable price of a real correctness fix — must be stated explicitly
with before/after numbers.
4.3 Benchmark before "done"¶
A kernel/perf change is not done until measured. Bench in
-Doptimize=ReleaseFast, built natively (no -Dtarget) on the benchmarking
machine; validate in Debug/ReleaseSafe. The protocol, thermal discipline,
and the paired-gate tooling live in BENCHMARK.md — one
command in practice:
tools/fetch_refs.sh --build
python3 tools/bench_gate.py --models qwen3-0.6b-q6_k --tasks prefill,decode
Perf work respects the parity ratchet: an optimization that flips a single token is reverted, not tolerance-adjusted. Scheduling-only changes are proven bitwise; a change that alters floating-point summation order needs a fresh tolerance argument against the pinned oracles. And remember the profiler is the completeness oracle parity cannot be: parity passes even when your fast arm is missing and dispatch fell to a slow path — profile-confirm the hot path is armed.
4.4 Docs are part of the change¶
A public-API change updates the reference, one
chapter file per chapter under docs/reference/, and its snippets are
tests (zig build snippet-check; the authoring contract is §7.2). A new doc gets
a <!-- docs-nav: ... --> header (the sidebar placement
tools/gen_docs_site.zig reads) and a row in docs/README.md,
the doc index; zig build doc-check verifies the index and the nav set
stay in sync. Write docs as timeless reference — what exists and how it
behaves; no dates (benchmark snapshots excepted), no development
narrative.
Tests follow the sibling convention: behavior in <name>_tests.zig, a
forwarding test { _ = @import("<name>_tests.zig"); } stanza in the
production file — and note the trap that a new src/ag/ submodule must be
referenced from ag.zig's test block or its sibling tests silently never
run. Always-passing tests must not print to stderr (route success-path
diagnostics through the root's testlog gate, e.g.
apps/facedetect/testlog.zig); asset-dependent suites skip cleanly
(error.FileNotFound → error.SkipZigTest, env-gated parity suites) so the
default zig build test is green with no assets.
4.5 Report what you did¶
In the PR or commit notes: the exact commands run, the machine and backend
configuration (CPU, OS, threads, -Dblas/-Dbackend flags), the model and
quantization used, and any failures or skipped suites. "Tests pass" without
the machine and the commands is not a report. If the change ports or adapts
third-party code, name the source, verify MIT compatibility, and credit it
in THIRD-PARTY-NOTICES.md — uncredited ports are
treated as bugs.
5. Honest completion¶
Three habits keep the project's claims trustworthy, and they are part of the method:
- Claims name their pipeline. Inference parity does not certify training parity; export interop is its own gate. Say which of the three a result covers.
- Negatives are results. A tried-and-reverted lever is recorded with its measured reason ("do not re-try without new evidence"); a documented negative can be a valid completion of a plan item. Symmetrically, re-verify any recorded premise against the current tree before building on it.
- BLOCKED beats fabricated. Missing fixture, missing hardware, missing reference — say so and name what a human must supply. The parity method only works because nobody invents its numbers.
6. API stability and deprecation¶
Fucina is pre-1.0 and versioned 0.MINOR.PATCH: a MINOR release may change
public API, and every change that does is listed in CHANGELOG.md under that
release with the one-line rewrite for each affected call form. There is no
alias step before 1.0: a rename or move lands directly, the tree is migrated
in the same commit, and the CHANGELOG entry is the migration guide. The
public roots therefore never carry a Deprecated: declaration; grep
'Deprecated:' src returning nothing is part of the contract.
Module stability tiers are declared in each module's doc comment and
summarized in the CHANGELOG: stable modules (tensor, ag, exec,
gguf, weights, the serving contract) always get a CHANGELOG rewrite;
experimental modules (es, ptqtp, speculative, research features
under models/) may change with a CHANGELOG entry only.
CHANGELOG entries describe consumer-observable changes: public spellings,
build options, environment variables, on-disk formats, behavior. Internal
layout changes (file moves, module splits, private renames) stay in
git log; when a release contains many, the changelog carries at most one
short "Internal reorganization" paragraph naming the areas.
7. Test layout, doc snippets, and CI¶
7.1 Test organization¶
The default home for tests is a sibling *_tests.zig file next to the
production file it covers: exec.zig ↔ exec_tests.zig,
src/models/text/tokenizer.zig ↔ src/models/text/tokenizer_tests.zig,
and so on. Two sanctioned variations exist, each with a stated reason:
- Directory suites for a surface too large for one sibling:
src/ag/tensor_tests/holds one file per op domain plus a sharedutil.zighelper, all forwarded fromsrc/ag/tensor.zig's test block.arch-checktreats every file under a<name>_tests/directory as a test file, forwarded transitively. - Inline
testblocks where the tests exercise file-private symbols that a sibling cannot reach (parallel.zigdocuments this split inparallel_tests.zig's header), or where the module is small enough that a sibling would be ceremony (caching_allocator.zig, the serving transport files).
A subdirectory facade forwards its children instead: src/optim.zig
forwards src/optim/, and its tests live in the root-level
optim_tests.zig covering the whole subtree. Whatever the shape, the
production file pulls its tests in with a forwarding stanza, so analyzing
the production file analyzes its tests:
test {
_ = @import("exec_tests.zig");
}
Module roots forward everything: src/fucina.zig ends in a test block
referencing every submodule (_ = dtype; _ = exec; …), and src/models.zig
does the same for every family and helper, so one addTest per root
reaches the whole tree. zig build arch-check enforces the forwarding: a
sibling test file that no non-test file imports fails the gate, so a
forgotten stanza cannot silently drop a test file from zig build test.
zig build test runs every test root, each compiled as its own test binary
with the same option set as the corresponding executable: src/fucina.zig
(the core, with build_options), src/models.zig (the LLM/ASR stack,
imports fucina), src/serving.zig (the serving transport, imports
fucina and fucina_models), and the example roots wired in build.zig
(lmserve, parakeet, omnivoice, locate_anything, facedetect,
voiceagent, nanochat), each also reachable alone through its solo step
(test-fucina, test-models, test-serving, test-<example>).
Every root passes with no model assets present. Suites that need external
material skip themselves cleanly rather than fail: the OmniVoice parity
suites gate on OMNIVOICE_PARITY
(§2.6); asset-dependent tests
(facedetect goldens, the GGUF re-emit byte-identity test, tokenizer-parity
fixtures) translate error.FileNotFound into
error.SkipZigTest; GPU-dependent tests (src/models/gemma/moe_tests.zig)
skip unless the build has a GPU provider and a device is actually
present. Tests for opt-in build features follow the same discipline
through the feature's comptime flag: every
src/models/text/llguidance_tests.zig case is guarded on the flag — the
enabled-path cases open with
if (!models.text.llguidance.enabled) return error.SkipZigTest;, and one
disabled-build case inverts the guard to assert error.LlguidanceNotEnabled
— so the same test root compiles and passes under any flag combination and
gains coverage — never failures — when the flag is on.
Per CONTRIBUTING.md, numeric changes must additionally be green under
-Dbackend=scalar and -Dblas=none — the scalar arms are the reference,
and the native arms must agree with them.
7.2 Doc snippets are tests too¶
zig build snippet-check extracts every runnable ``zig block from the
reference chapters underdocs/reference/— any fenced block containing a
column-0 namedtest "..."declaration — into a generated test root and
runs it against the realfucina/fucina_modelsmodules with the build's
option set (tools/gen_snippet_tests.zig). Authoring contract: snippets
assume an implicit prelude (std,fucina,models = @import("fucina_models"),optim = fucina.optim; entries a snippet declares itself are not
re-emitted); acomment on the line before a
non-test fence marks a definition block (an Op/Spec/fn the prose
introduces) prepended to every later snippet in the same chapter file; acomment excludes a test-shaped block that cannot
run hermetically. Illustrative fragments (signature blocks, baretest {stanzas, asset-dependentfnexamples) are ignored automatically. A
snippet for an opt-in build feature stays RUNNABLE, not skip-marked: it
opens with the feature's comptime-flag guard (e.g.if (!models.text.llguidance.enabled) return error.SkipZigTest;), sosnippet-checkcompiles it under every flag combination and executes it
exactly when the enabling-D` flag is passed.
7.3 Continuous integration (.github/workflows/ci.yml)¶
CI runs on pushes to main and on every pull request, on a two-OS matrix
(fail-fast: false): ubuntu-latest (x86-64) and macos-15 (arm64 —
pinned rather than -latest, bumped deliberately). Zig 0.16.0 is installed
via mlugg/setup-zig@v2. Steps, in order:
zig build test— native backend (Accelerate on macOS, no BLAS on Linux, per the-Dblasdefault);zig build— all executables compile;zig build bench-check— the bench-check set compiles (bench mains are reachable only through their run steps, so nothing else in the build graph exercises them);zig build arch-check— import-graph gate;zig build doc-check— doc index, link, and citation gate;zig build snippet-check— runnable-snippet gate (§7.2);zig build x86dot-check— dot-kernel parity on the host ISA (x86 on ubuntu, NEON/sdot on macOS) plus the compile-only bit-rot legs;zig build test -Dbackend=scalar— ubuntu only (the reference backend);zig build test -Dblas=none— macOS only (pure-Zig native kernels, complementing the Accelerate run in step 1);zig build test -Dllguidance=true+snippet-check -Dllguidance=true— ubuntu only (the runner image ships cargo): un-skips the flag-gated llguidance tests and snippets (§7.2), keeping the extern ABI, the cargo build, and the Rust-staticlib link from bit-rotting behind a green default build — and continuously proving the Linux link of that staticlib;zig build cuda-check— ubuntu only: the compile-only CUDA-provider legs (fucina + models test roots and the PTX generator, cross-compiled for x86_64-linux-gnu, not run; no CUDA SDK involved);zig build metal-check— macOS only: the compile-only Metal-provider legs (fucina + models test roots with the Objective-C shim and the Metal/Foundation frameworks linked, not run; no device needed).
Between the matrix and the conditional legs, every backend combination that
can run on stock CI hardware is covered: native+BLAS, native without BLAS,
and scalar, on both ISAs' unit-test surface, plus the opt-in llguidance
feature on Linux. Neither runner has a usable GPU, so both GPU providers are
covered at the compile level only: cuda-check on ubuntu and metal-check
on macOS keep the provider arms, their shims, and the model tier's provider
surface from bit-rotting behind a green -Dgpu=none build, while the
device-gated conformance suite (src/backend/gpu_conformance.zig) still
runs only on GPU hardware (docs/GPU-OFFLOAD.md). CPU dot ISA arms that CI
cannot execute (AVX-VNNI, AVX512-VNNI, smmla) are covered by the
compile-only legs and attestation records in src/x86dot_check.zig.