Skip to content

Architecture

jepa.cpp is an inference engine and nothing else: C++17, ggml (submodule at ggml/), no other runtime dependency, no training code. Python appears only in conversion and reference dumping.

The engine in one page

A model is a GGUF bundle: one file holding an encoder, optionally a predictor and optionally a head, together with the metadata that describes them — dimensions, depth, head count, patch and tubelet geometry, positional-encoding scheme, preprocessing recipe, class labels, licence. The GGUF schema is the contract; the converter, the loader and the graph builder all implement exactly it.

The graph is built from that metadata, not from per-model code. jepa_model_load reads the hyperparameters and the tensor table; jepa_context_new allocates a compute arena; jepa_encode builds a ggml graph for the requested shape and runs it on the context's backend. A new checkpoint of a known family therefore needs no C++ change — the converter writes the metadata and the loader picks the branches, which is why seven models of six families share one builder.

Around that sit four layers: preprocessing (pixels to a normalised tensor, host-side), the shared ViT graph, the optional heads, and pooling. The public surface is one C header, include/jepa.h (reference).

The shared ViT graph

Every family runs the same encoder graph:

tokens = patch_embed(rearranged pixels) [+ pos_embed] [+ cls/reg] [+ modality vec]
for each block:  x += attn(ln1(x)) ;  x += ffn(ln2(x))     # pre-LN, GELU(erf), qkv bias, optional layer-scale
x = norm(x)

The patch "convolution" is a host-side rearrangement of the pixels into [N, C·T·P·P] followed by one ggml_mul_mat — mathematically identical to the strided convolution of the reference implementations and far friendlier to a matmul-shaped backend. Attention always goes through ggml_flash_attn_ext unless it is explicitly disabled. Positions are added either as a baked table or as rotary factors applied inside the attention, depending on the family.

Heads are separate graphs over the encoder output: the attentive pooler plus 174-way classifier for SSv2, the masked predictor for V-JEPA 2 / 2.1, and the action-conditioned predictor for LeWorldModel.

Family matrix

family tokenizer positions extras
ijepa 2-D patch 14/16 sincos2d table, baked by the converter no CLS; the feature is the mean of the patch tokens
vjepa 2×16×16 tubelet sincos3d table
vjepa2 2×16×16 tubelet rope3d, tiled layout predictor 384-d / 12 L / 10 mask tokens; attentive-pool head
vjepa2_1 2×16×16 tubelet and 1×16×16 image embed rope3d, interleaved layout, plus interpolate_rope modality vectors; per-hierarchy-layer norms (inference uses the last); 8 mask tokens
hfvit 2-D patch learned, with CLS and optional registers DINOv2-style; optional layer-scale; the LeJEPA community checkpoint and the LeWM encoder
lewm hfvit encoder (ViT-Ti/14) inherited from hfvit predictor 192-d / 6 L over 3 frames plus an action embedding (10 → 192); BatchNorm MLPs folded at conversion
levjepa 1×16×16 tubelet (one token per frame per patch) rope3d, tiled layout; CLS gets none CLS token, block-causal attention mask; no predictor, no head

3-D RoPE

The V-JEPA 2 and 2.1 encoders and predictors use a three-axis rotary embedding that must match Hugging Face's VJEPA2RopeAttention exactly.

  • Per axis, d = 2 · ((head_dim // 3) // 2) dimensions are rotated — 20/20/20 for head_dim 64, with the remaining 4 dimensions untouched. The predictors have head_dim 32, so d = 10 per axis.
  • Token i maps to t = i // (gh·gw), h = (i % (gh·gw)) // gw, w = i % gw.
  • Frequencies are omega_k = 1 / 10000**(2k/d) for k in [0, d/2).
  • The rotation acts on interleaved pairs (x[2k], x[2k+1]): x·cos + rotate90(x)·sin, where rotate90(y1, y2) = (−y2, y1).
  • It is applied to q and k (not v), after the qkv projection and before scaling.

Two cos/sin table layouts exist, and the family selects one (jepa.enc.rope_freq_layout, jepa_rope3d_params::variant):

layout table used by
tiled table[j] = f(pos · omega_{j mod d/2}) — the two members of a pair see different angles V-JEPA 2 (both the Hugging Face and the Meta implementation)
interleaved table[2k] = table[2k+1] = f(pos · omega_k) — a true rotation V-JEPA 2.1

The tiled layout is a quirk Meta's own code documents and keeps for weight compatibility. It is not optional and not interchangeable: applying the wrong one gives per-token cosine ≈ 0.63 against the V-JEPA 2 reference and ≈ 0.91 against the 2.1 reference — the graph runs, the numbers look plausible, and the output is wrong. The full derivation with source excerpts is in the V-JEPA tensor and RoPE notes; the numpy reference is scripts/jepa_convert/vjepa2_numpy_ref.py.

The V-JEPA 2.1 encoder additionally rescales h and w (not t) by (rope_ref_grid − 1) / (grid − 1)interpolate_rope, with rope_ref_grid = 256 / patch_size, i.e. 16 for the released 384-px checkpoints and not img_size / patch. Its predictor does not rescale.

Implementation: ggml's ggml_rope_multi uses half-split rotation and a single theta scale and is therefore unusable here. src/rope3d.cpp precomputes [N, head_dim] cos/sin tables on the host and applies them with stock ops — one ggml_roll for the pair swap plus a multiply and an add, with the sign of the rotation carried in the sin table. jepa_rope3d_tables_ids produces rows for masked or subsampled token ids, which is what the predictors need. tests/test-ops.cpp checks both layouts against tests/vectors/rope3d/, generated by scripts/gen_rope_ref.py.

That five-node form replaced a thirteen-node one, bit-identically, and it is a deliberate trade: it costs +4.8 % on the CPU at the ViT-L 16-frame encoder shape (790.6 → 828.3 ms) and it is what keeps the chain resident on a GPU. The older form emitted a ggml_roll over a strided view of the fused qkv tensor, which no CUDA kernel accepts — see the graph-validation paragraph under GPU backend for what a backend does with such a node when nothing checks.

Block-causal attention

jepa.enc.attn_mode selects how the encoder's attention is masked. Every family but one leaves it at full, where the key is absent from the graph. LeVJEPA sets block_causal, and for that family the mask is part of the model, not a tuning knob: run the same weights unmasked and the CLS feature drops to cosine 0.945 against the reference with a worst patch token of 0.834.

The rule is one line — a patch query may attend a patch key iff frame_id(query) >= frame_id(key), where frame_id of a token is id // (gh·gw) — so attention is bidirectional inside a temporal slot and causal across slots. The CLS prefix is a read-only sink: its row is open (it reads the whole clip) and its column is closed to every patch query, which is what stops layer l information about the last frame from reaching a first-frame token at layer l+1. At the released 16-frame 224² shape that leaves 53.1 % of the 3137 × 3137 grid open.

jepa_block_causal_mask_f16 builds it host-side as an additive F16 [N, N] buffer of zeros and -INFINITY, once per jepa_encode call — shared by all 24 layers and by every clip of that call, and uploaded into each clip's graph — and passes it through jepa_attn_opts::mask. F16 serves both consumers — ggml_flash_attn_ext requires it and ggml_soft_max_ext accepts it — so one buffer covers the flash path, the --no-flash path and both backends. No padding is needed at this ggml commit: the CUDA MMA kernel wraps mask rows with fastmodulo(j0 + j, ne01) and clamps the key axis of its final tile, so an unpadded 3137-row buffer is read correctly there too (measured in parity).

The mask is the one term in this graph that grows with N². Everything else — activations, RoPE tables, the patch buffer — is linear in the token count, so the memory of a longer clip is otherwise predictable. The mask is N × N F16 held twice, once on the host and once in the graph arena: 39 MiB at the released 16-frame shape (3137 rows), 600 MiB at 64 frames (12 545 rows), 2.4 GiB at 128. jepa_encode therefore budgets a video graph the way it budgets a batched image graph — the $JEPA_MAX_GRAPH_MIB ceiling of the Runtime switches below, counting the mask — and refuses a clip that exceeds it instead of allocating; above 64 MiB of mask it also says so once. There is no batch to shrink on the video path, so over-budget is a refusal rather than a smaller graph.

A CLS token in a RoPE model raises a second question, and the reference answers it: RoPEAttention rotates q/k[..., 1:, :] only. The runtime therefore prepends an identity row (cos 1, sin 0) to the host cos/sin tables for every prefix token, and jepa_rope3d_apply is untouched.

A one-frame clip is a one-frame clip. jepa_encode runs in->n_frames frames as given for every video family; it never invents frames. LeVJEPA's model card feeds a still image as that frame repeated to jepa.enc.n_frames, and jepa-embed does the repeat (and says so on stderr) — the library entry point does not, so a caller handing it n_frames = 1 gets a 197-row clip whose mask spans one temporal slot. jepa-embed also notes any other clip length that differs from the trained one.

Preprocessing

src/preprocess.cpp turns pixels into the normalised tensor the graph expects, driven by the jepa.pre.* metadata: shortest-edge resize, centre crop, scale to [0, 1], then subtract the per-channel mean and divide by the per-channel std. jepa_resize_antialias_u8 is a faithful port of the integer antialiased path that torchvision.transforms.v2.functional.resize(antialias=True) runs on x86 CPUs, so the tensor entering the network is bit-exact against the reference pipeline given the same decoded pixels (measured).

The caveat is decoding, not resizing. jepa.cpp decodes JPEGs with stb_image, which differs from PIL/libjpeg by ±1–2 levels on about 2 % of pixels. That decoder floor is 1 − cos of 6.3e-6 to 1.7e-5 on the final features — larger than what f16 weights add.

Video ingest. The library is frames-in: jepa_encode() takes an NCTHW tensor and nothing in src/ opens a container. Turning a .mp4 into frames is the tools' job, and tools/video-decode.cpp does it by running ffmpeg as a subprocess —

ffmpeg -nostdin -v error -noautorotate -i CLIP -map 0:v:0 -an -sn -dn \
       -fps_mode passthrough -f rawvideo -pix_fmt rgb24 -

— and keeping n of the frames it writes, sampled uniformly over the whole clip with the formula the PyTorch side uses, idx = round(linspace(0, T_total − 1, n)) (numpy's ties-to-even rounding and its pinned last sample included; a clip shorter than n repeats frames rather than failing). T_total comes from ffprobe — the container's nb_frames where it has one, a -count_frames decode pass otherwise — and is re-checked against the frames the decode actually produced, so a container that lies about its length is resampled rather than mis-sampled.

Three details of that command line carry the parity. -fps_mode passthrough turns off ffmpeg's constant-frame-rate conversion, which would otherwise duplicate frames of a variable-rate file (a 5-frame Something-Something-v2 .webm comes out as 59 frames without it) and sample entirely different pixels; -noautorotate keeps ffmpeg from applying a display matrix PyAV's to_ndarray() ignores; and no -sws_flags is passed, because libswscale's default yuv420p → rgb24 conversion is already bit-identical to PyAV's reformatter (nothing is scaled on either side) while forcing full_chroma_int+accurate_rnd changes 86 % of pixels by up to 44 levels. ffmpeg older than 5.1 gets -vsync 0 instead, decided once per process by asking the binary.

The result is that jepa-embed --video clip.mp4 and jepa-embed --frames-npy on scripts/video_frames.py's output of the same clip build the same uint8 tensor, byte for byte — so the video path has no decoder floor at all, the way it did when frames could only arrive as a .npy. tests/test-video.cpp (ctest -R video) checks that against every video sample of the reference dumps; it was also measured over the six fixture clips and 40 SSv2 clips, 20 of them shorter than the requested 16 frames.

Attention and precision

Attention runs through ggml_flash_attn_ext. The K/V dtype is auto: F32 K/V for f32 model files, F16 K/V for f16 and quantized files.

  • The F16 cast alone costs roughly three digits of worst-token cosine on I-JEPA ViT-H at f32 (0.9910 against 1.000000, rel_max 3.5e-2 against 8.6e-5). The cause is F16's 10-bit mantissa, not its range: on the real forward the model's largest linear output is 95 and its largest residual value 151, against F16's ceiling of 65504.
  • For f16 and quantized files weight rounding dominates anyway, and F16 K/V is pure storage rounding — cosine ≥ 0.9999995 against a double-precision reference.
  • The attentive-pool cross-attention always uses F32 K/V: it has a single query row, so ggml takes its per-row kernel, which with F16 K/V would round q and the PV accumulator to F16. At one query row it costs nothing. The same reasoning applies to the LeWM predictor's short sequences.
  • --no-flash selects the naive mul_mat + soft_max_ext path. It is fully F32 and ~15–30 % slower on ViT-H, and its score matrix is 4·N²·H bytes — 15.3 GB at 18 432 tokens against ~0.05–0.1 GB for flash, so it is a debugging and short-sequence option on the CPU. On a GPU it is more than that: it is the only F32 attention path there, since CUDA flash attention converts K/V to F16 unconditionally. Its cost scales with the sequence — 16.5 ms against 15.5 for I-JEPA at 256 tokens, i.e. essentially free, but 109.4 ms against 46.5 for the ViT-L 16-frame clip, a factor of 2.4 — so it is affordable at image scale and progressively less so beyond it.

Overrides: JEPA_KV_F16 / JEPA_KV_F32 (--kv-f16 / --kv-f32 in the tools).

On the CUDA backend the picture differs and jepa.cpp cannot change it:

CPU CUDA
mul_mat, f32 weights strict FP32 TF32 — ggml passes CUBLAS_GEMM_DEFAULT_TENSOR_OP to every cublasGemmEx and never calls cublasSetMathMode, and ggml_mul_mat_set_prec only selects the compute type
mul_mat, f16 weights F32 accumulation F16 accumulation unless GGML_PREC_F32 is set, which jepa.cpp sets by default on a GPU
flash_attn_ext F32 K/V honoured K/V always converted to F16, PV accumulator half2; ggml_flash_attn_ext_set_prec is a no-op
ggml_norm centred two-pass variance one-pass E[x²] − mean²

GGML_PREC_F32 on every mul_mat is the GPU default and takes the f16 matmul error from 4.6e-03 to 2.6e-05, i.e. below the CPU's own 3.0e-04. It is not free, and what it costs depends on how much work there is to hide it behind: on one matmul, −21 % throughput at N = 2048 and +9 % at N = 8192; end to end, 1.76× at I-JEPA's 256 tokens against 1.06× at 8 192. Quantized weights never reach cuBLAS, so for them it costs nothing. $JEPA_GPU_PREC=f16 opts out; on the CPU the call is a no-op. The one-pass variance is a per-row scale error that cosine is structurally blind to, which is why every GPU parity tier gates rel_max as well; over 1.66 M real pre-LayerNorm rows the ratio that governs its failure peaks at 2.72, far from the 100–2000 where it degrades.

Batching

jepa_encode folds up to jepa_context_max_batch() image items (default 32, $JEPA_MAX_BATCH, jepa-embed --batch) into one graph on ggml's batch dimension: activations are [D, N, B], attention tensors [head_dim, n_head, N, B], and both ggml_flash_attn_ext and ggml_mul_mat walk ne[3] as an outer loop, so items never mix.

Guarantees:

  • On the CPU the batched rows are bit-identical to the per-item path at every dtype measured (tests/test-batch, ctest entry batch).
  • On CUDA batched and per-item runs agree to ~1e-7 cosine but are not bit-identical, because GEMM tiling varies with the batch shape.

Video is deliberately excluded. What batching buys is the fixed per-call cost, ~5 ms on the image models; a V-JEPA 2 clip runs 908–9 000 ms, so the saving is under 1 %, while a batched clip graph would multiply the largest arena in the engine. jepa_encode keeps a per-clip loop for video, and n_batch = 2 is two calls' worth and bit-identical to two n_batch = 1 calls (tests/test-batch --video). Inputs larger than the cap are encoded in several graphs rather than one giant one.

GPU backend

A CUDA build (-DJEPA_CUDA=ON) runs the same graphs on a device. The design has three load-bearing properties.

One backend, never a split graph. A context owns exactly one backend and the model's weights live on it; ggml_backend_sched is not used. Its job is to partition a graph, and a partitioned jepa.cpp graph is a defect, not a feature — one unsupported node per transformer block would shuttle the fused qkv tensor across PCIe four times per block. Device selection goes through the backend-agnostic ggml registry (ggml_backend_load_all, then the N-th device of type GPU), so no CUDA header appears in jepa.cpp and the same code path would light up another backend if one were built. A context whose device differs from its model's is rejected rather than silently split.

Graph validation is mandatory. A single CUDA backend performs no supports_op check of its own: it dispatches every node, and a kernel handed a tensor it cannot really run returns a wrong answer with no error and no warning — an answer that can still pass an f16 cosine gate. jepa_graph_validate() therefore walks the graph before every compute and refuses the first node for which ggml_backend_dev_supports_op is false, naming it. tests/test-backend.cpp builds a node known to have that property and checks the refusal fires. $JEPA_VALIDATE_GRAPH=0 disables the walk and is a debugging switch only.

The predictors take the naive attention path. No CUDA flash-attention kernel exists for head_dim 32, which is what the V-JEPA 2 and 2.1 masked predictors have, so src/predictor.cpp selects mul_mat + soft_max_ext there. That path is fully F32 and stays one graph split — it is more accurate than a CUDA flash kernel would be — but it runs at roughly 3 TFLOP/s against flash's 50–70, which is why the masked predictor gains 2.7–2.9× on a GPU where the encoder of the same model on the same clip gains 17.6×.

Its score matrix is 4·N²·H bytes over n_context + n_target rows, and that sets a hard ceiling: 0.80 GB for the ViT-L 16-frame clip and 4.1 GB for the V-JEPA 2.1 16-frame clip both fit comfortably on a 24 GB card, 12.9 GB for the ViT-L 64-frame clip is tight next to the weights, and the V-JEPA 2.1 64-frame predictor needs ~65 GB and cannot run on this card at all — that shape belongs on the CPU. The engine checks the requirement against free device memory before building and refuses with both sizes printed, rather than failing inside the allocator.

There is no f32 tier on a GPU. TF32 matmuls and F16-accumulating flash attention are backend properties, so an f32 GGUF on CUDA behaves like its f16 file and is judged with the f16 bars. f16 and quantized files hold their own bars there, and quantized weights are additionally the fastest GPU path, because ggml's CUDA mmq INT8 tensor-core kernel covers every type jepa.cpp ships. Use the CPU when f32 exactness is the requirement.

One device per process. --gpu N selects a single device; there is no tensor-parallel split across cards, and none is planned — splitting a ViT forward, which is one dependency chain, would mean a partitioned graph with peer copies per layer, the very thing the single-backend design exists to avoid. Two cards are two independent streams: one model and one context per device, each processing different items. That falls out of --gpu N with no engine work.

The first two calls are slower. ggml's CUDA backend captures a CUDA graph once it has seen the same topology at the same tensor addresses twice in a row. jepa.cpp rebuilds its graph on every call but with identical topology and a reused allocator, so from the third call the encoder is one graph launch instead of roughly 700 kernel launches. Capture requires an unsplit graph, which is another reason a single backend beats a scheduler; every measurement on this site warms up twice for it.

Runtime switches

variable tool flag effect
JEPA_DEVICE=cuda:N | cpu | N --gpu [N] select the compute device; default CPU
JEPA_GPU_PREC=f16 --gpu-prec f16 (jepa-bench) opt out of the default GGML_PREC_F32 matmuls on a GPU. Bench-only and not parity-gated: read its numbers as a measured upper bound, not a shipping configuration
JEPA_VALIDATE_GRAPH=0 disable the pre-compute graph validation. Debugging only — without it an unsupported node on a single CUDA backend computes a silently wrong answer
JEPA_MAX_BATCH --batch B image items per encoder graph; default 32
JEPA_MAX_GRAPH_MIB cap on the compute arena; larger inputs are split across graphs
JEPA_KV_F16 / JEPA_KV_F32 --kv-f16 / --kv-f32 override the automatic flash-attention K/V dtype (CPU; on CUDA F16 is forced and the request is logged once)

jepa_context_set_mul_mat_prec_f32(ctx, false) is the API form of JEPA_GPU_PREC=f16.

Robustness

A GGUF is a file someone downloaded, and an image is bytes off a network. Both are parsed before anything about them is known, so the loader and the preprocessor treat every number in them as hostile until it is in range. Nothing is clamped or defaulted silently: an input that does not describe a model this engine can run is refused, by name, on stderr and in jepa_error_text().

What the loader validates. The metadata and the tensor extents come first, before a byte of weight is allocated — that ordering is the point of the extent check, since the weight buffer is sized from the header's claims and not from the file's length. The dtype, shape and required-tensor checks run once the tensors are resident and before any graph is built:

check refused when
general.architecture, jepa.family not jepa; not one of the seven families
every jepa.* integer outside its range — embed_dim 1–65536, n_layer 1–1024, n_head 1–1024, ffn_dim 1–2²⁰, patch_size/tubelet_size 1–1024, img_size/n_frames 1–65536, in_chans 1–1024, token counts ≤ 2²² (JEPA_LIMIT_* in src/jepa-internal.h)
every jepa.* float not finite, or outside its range — ln_eps ∈ [0, 1], rope_theta > 0, jepa.pre.std ≠ 0
enum-valued strings unknown jepa.enc.act, jepa.enc.attn_mode, jepa.pred.act, jepa.pred.action_act, jepa.pred.proj_act
attn_mode = block_causal on a family whose graph has no mask, where it would run unmasked
derived invariants embed_dim % n_head; an odd head width on a 3-D RoPE family (it rotates pairs); rope_interpolate with no rope_ref_grid to rescale from; a masked predictor with no position grid; a LeWM predictor with no action_dim or n_frames
tensor extent any tensor whose bytes are not inside the file (a truncated download; the header alone can promise terabytes)
tensor dtype anything that is not f32, f16, bf16 or a quantized type; and, for the operands the graph adds, multiplies or concatenates — norms, biases, layer scales, CLS and register tokens, position tables, mask tokens, modality vectors — anything that is not f32
tensor shape every weight, bias and vector of every encoder, predictor and head block against the hparams; and every table a metadata count indexes into (pred.mask_tokens against n_mask_tokens, pred.pos_embed against n_frames)
required tensors a predictor or head the metadata promises and the tensors do not deliver

What is refused at call time. Non-positive or overflowing input shapes; an encoder output header that describes nothing; token ids off the predictor's grid; and any graph whose estimated activation bytes exceed $JEPA_MAX_GRAPH_MIB (8 GiB by default) — the encoder shrinks its batch first and only errors when a single item still will not fit, the video encoder, the masked predictor and the LeWM rollout refuse outright. The image pipeline additionally caps the intermediate of the shortest-edge resize at 64 megapixels, which is what stops a 16384×1 image from asking for gigabytes. The cap is on the resized intermediate, so in terms of the input it is an aspect-ratio limit of 64 Mpx / resize_short² — about 1337:1 for a 224-pixel model and 350:1 for V-JEPA 2.1 at 438 — and a refusal names the input geometry and the bound.

Thread contract. Stated in full in include/jepa.h and checked by tests/test-threads.cpp: a jepa_model is immutable after load and may be shared by any number of threads; a jepa_context belongs to one thread; jepa_error_reset / jepa_error_text are thread-local; preprocessing is re-entrant; the "said once" warnings are published atomically. Concurrent encodes through per-thread contexts are bit-identical to the same work run serially. n_threads is the width of the pool inside one graph — a context per thread parallelises calls, and doing both oversubscribes the machine. The suite forges its own model when given none, so it is a real check on a runner with no weights; run it under TSan with -fsanitize=thread -DGGML_OPENMP=OFF (TSan cannot see through libgomp's own synchronisation and reports its pool as a race in any program that uses it). On a kernel with 32-bit ASLR entropy TSan aborts at start-up with unexpected memory mapping; run the suite under setarch -R or set vm.mmap_rnd_bits=28.

Running the fuzzer. tests/fuzz/fuzz-gguf-load.cpp feeds arbitrary bytes to jepa_model_load and, when they load, to a shallow encode/pool/predict pass. It is off by default; CI builds it and never runs it.

scripts/make_fuzz_corpus.py                       # needs gguf-py; writes tests/fuzz/corpus/
cmake -S . -B build-fuzz -DJEPA_FUZZ=ON -DGGML_OPENMP=OFF \
      -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fsanitize-recover=undefined -g" \
      -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fsanitize-recover=undefined -g" \
      -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build build-fuzz -j --target fuzz-gguf-load
build-fuzz/fuzz-gguf-load --corpus tests/fuzz/corpus --out findings --fork --seconds 3600
build-fuzz/fuzz-gguf-load --file findings/crash-0000-sig11-exit-1.gguf     # replay one input

The seed corpus is generated from the two small GGUFs: a shrunk-but-loadable copy of each (D=8, 2 layers, 8×8 images), truncations of both those and the real files, and one-key mutations. With clang the same source is a libFuzzer target (-fsanitize=fuzzer, LLVMFuzzerTestOneInput); with any other compiler it is the deterministic mutation loop above, reproducible from --seed. --fork runs each input in a child, so a crash or a hang (--timeout, default 10 s) is written to --out and the run continues.

UBSan is recoverable in that build on purpose. The GGUF reader in the ggml submodule loads a type tag into an uninitialised gguf_type on its own failure path and computes tensor sizes before range-checking them (ggml/src/gguf.cpp:575, :584, :714, ggml/src/ggml.c:1288); those are upstream's and they fire on most malformed inputs. Making UBSan non-fatal there keeps every check enabled and turns triage into "which source file does the report name" — a report outside ggml/ is ours. The sanitizer CI job, which feeds only valid files, runs with halt_on_error=1 and no exclusions.

Repository layout

include/jepa.h        public C API (opaque handles, plain structs)
src/jepa.cpp          model struct, GGUF load, graph build, run (encoder / predictor / head)
src/jepa-gguf.cpp     GGUF metadata + tensor lookup helpers, hparam parsing
src/preprocess.cpp    image/video -> normalised float tensors (stb_image; resize matching PIL/torchvision)
src/rope3d.cpp        3-D RoPE: cos/sin table generation + graph application
src/predictor.cpp     masked predictors (V-JEPA 2 / 2.1)
src/lewm.cpp          LeWorldModel projector and action-conditioned predictor
src/npy.h             header-only .npy reader/writer (fixtures, tool output)
third_party/          nlohmann/json 3.12 (manifests, hparam dumps); stb_image*.h for decode/resize

tools/jepa-info       print GGUF hparams/tensors, list devices
tools/jepa-embed      image/video -> features (.npy / text)
tools/jepa-classify   video -> top-k labels (attentive-pool head)
tools/video-decode.*  --video: an ffmpeg subprocess -> sampled THWC uint8 frames (tools only)
tools/jepa-worldmodel LeWM: image -> state -> K-step action rollout; --ref-check against fixtures
tools/jepa-quantize   f32/f16 GGUF -> q8_0 / q4_k / ...
tools/jepa-bench      timing: encoder / head / predictor / lewm-step / lewm-rollout, --md / --json

tests/test-parity     replay the golden dumps: cosine / max-abs / top-k, non-zero exit on regression
tests/test-predictor  the same for the three predictors, against the reference encoder tokens
tests/test-batch      batched vs per-item bit-exactness
tests/test-attn       flash vs naive attention against a double-precision reference; K/V policy; timing
tests/test-ops        rope3d against tests/vectors/, and the block-causal mask on both attention paths
tests/test-backend    GPU graph validation and CPU/GPU agreement; skips cleanly without a GPU
tests/test-video      --video decode+sampling vs the reference dumps' PyAV frames; skips without ffmpeg
tests/test-errors     the failure paths: forged and truncated GGUFs, bad shapes, budget refusals
tests/test-threads    one model, N threads, a context each: outputs bit-identical to single-threaded
tests/forge-gguf.h    builds a tiny loadable GGUF, one knob per thing that can be wrong with it
tests/fuzz/           the GGUF loader fuzz target (-DJEPA_FUZZ=ON) and its corpus generator

scripts/convert.py          HF safetensors / torch.hub .pt -> GGUF
scripts/dump_reference.py   PyTorch golden outputs -> tests/fixtures/ref/<model>/
scripts/compare.py          .npy / ref-dir comparison (cosine, max-abs, rel, top-k)
scripts/knn_eval.py         the frozen-feature k-NN protocol shared by both accuracy benchmarks
scripts/make_fuzz_corpus.py the fuzz seed corpus: shrunk minis, truncations, one-key mutations
scripts/bench_all.sh + gen_benchmarks_md.py       the benchmark sweep and its document generator
scripts/bench_accuracy_{image,video}.py           the Imagenette / UCF-101 sweeps

Scratch files go in tmp/ and weights in models/; both are git-ignored.

Testing and parity methodology

Correctness is measured against PyTorch, per model, per dtype, per backend.

Golden dumps. scripts/dump_reference.py --model <name> runs the reference implementation and writes tests/fixtures/ref/<name>/manifest.json plus one float32 C-order .npy per tensor per sample: the preprocessed input (layout recorded in the manifest), last_hidden_state, the pooled feature, and where a head exists the pooler output and logits. Video samples also carry the raw sampled frames as uint8, so the engine's own preprocessing can be exercised from identical pixels. The per-model tensor lists are in fixtures.

Two passes per file. test-parity <model.gguf> <ref dir> first feeds the stored preprocessed tensor, bypassing jepa.cpp's preprocessor, and reports per-token cosine, rel_max and top-1/top-5 agreement; then it runs its own preprocessor from the stored pixels and reports the same. The first pass isolates the graph; the second adds the preprocessing and, for images, the JPEG-decoder floor.

Thresholds are a table, not a constant. POLICY in tests/test-parity.cpp is indexed by backend × family class × file-type tier, and test-parity prints the row it judged with. Family class matters because the long low-cosine token tail of the f16 and quantized video encoders is a property those checkpoints have and the image ViTs do not; tier comes from general.file_type, with anything below 8 bits per weight treated as advisory — reported, with only the derived tensors and the top-1 label gated. tests/test-predictor.cpp uses the image-family rows. The full tables, CPU and GPU, are in parity.

REL(N) = max(1e-3, 1e-3·√(N/2048)) is the f32 rel_max bound. rel_max is a max-abs difference, and max|a−b| grows with the length of the reductions feeding it (~√N over N tokens) while the cosine does not. The 8192-token V-JEPA 2 clip measures 1.22e-3 with cosine 1.000000 on every token; a flat 1e-3 bound would call that a failure. The widened bound still sits ~40× above the observed f32 noise floor: perturbing one encoder weight tensor by +1 % pushes a clean 4.0e-5 run to 2.09e-3 and fails.

Video tiers gate the median. For f16 and quantized video files the token map is judged on the median per-token cosine rather than the worst token, because the tail is a checkpoint property that no implementation can remove, while the median still collapses for a real graph defect — a wrong RoPE layout puts every token at ~0.63. The strict bars live on the pooled and logit outputs, which is where the tail does not reach. Every f32 file keeps the hard "every token ≥ 0.9999" bar, and every family ships one, so each family stays anchored at a configuration with no slack.

GPU tiers gate rel_max in every cell, not only at f32. A wrong ggml_norm variance is a per-row scale error, and cosine cannot see it: a relative error of 5.7 has been measured with per-row cosine still reading 1.0000000. The GPU bars are otherwise set just outside the worst measured fixture value, and two of the image-family bars are looser than their CPU counterparts, which makes the GPU image tier roughly half as sensitive to weight-level errors as the CPU one — the f32-on-CPU run stays the sensitive configuration for any real investigation.

What else the tests cover. test-batch checks batched-versus-per-item bit-exactness, test-ops the RoPE tables against generated vectors and the block-causal mask (entry by entry, then through jepa_build_attention on both attention paths against a double-precision masked softmax, with an unmasked control that must disagree), test-attn flash attention against a double-precision reference including the K/V dtype policy, and test-backend the GPU graph validation plus CPU/GPU agreement. test-predictor adds structural checks the reference cannot provide: causal-prefix equality and rollout-versus-predict identity on LeWorldModel, which are bit-exact on both backends.

Results: Accuracy for the curated view, parity for every row.