Skip to content

Getting started

From a clean checkout to a feature vector: build the engine, create the one-time Python environment that converts checkpoints, convert a model, run a tool.

Build

Requirements: CMake ≥ 3.16, Ninja, a C++17 compiler. ggml is a submodule, so clone recursively. Nothing else is needed to build; ffmpeg on the PATH is an optional run-time extra that lets jepa-embed and jepa-classify take a video file directly (--video, below).

git clone --recursive https://github.com/aselimc/jepa.cpp && cd jepa.cpp
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

That produces build/jepa-{info,embed,classify,worldmodel,quantize,bench} and the static library libjepa.a. The relevant CMake options:

option default effect
JEPA_BUILD_TOOLS ON build the command-line tools
JEPA_BUILD_TESTS ON build the test binaries and register the ctest suites
JEPA_NATIVE ON -march=native; turn off for a portable binary
JEPA_CUDA OFF build the ggml CUDA backend and add --gpu to the tools
GGML_LLAMAFILE forced ON llamafile's accelerated sgemm — 1.3–3.2× faster mul_mat on AVX-512

CUDA build

The CUDA backend is optional and off by default: libggml-cuda.so is 45 MB for a single GPU architecture, thirty times the CPU backend. Configure it in its own directory so the CPU build stays intact.

cmake -S . -B build-cuda -G Ninja -DCMAKE_BUILD_TYPE=Release -DJEPA_CUDA=ON
cmake --build build-cuda -j 16
build-cuda/jepa-info --devices          # list what the ggml backend registry can see

JEPA_NATIVE=ON resolves CMAKE_CUDA_ARCHITECTURES to native, which is right for a from-source build and wrong for a distributed binary — pass an explicit -DCMAKE_CUDA_ARCHITECTURES=89-real;80-virtual for one of those. Under CUDA 13 the toolkit no longer offers the 50/61/70 virtual architectures, so a non-native build starts at 75-virtual.

Budget for the build: 138 CUDA translation units, about 171 s wall at -j8 for a single architecture, 114 MB of build tree, and the 45 MB libggml-cuda.so at the end of it.

Every tool then takes --gpu [N] (or $JEPA_DEVICE=cuda:N); see Architecture → GPU backend for what changes on a device, and Accuracy for which dtype to use there. --gpu N picks one device per process — there is no split across cards, so two GPUs means two processes.

Other backends. The device lookup is the backend-agnostic ggml registry, so a Vulkan, Metal or ROCm build would appear in jepa-info --devices with no engine change. Vulkan is not buildable on this box as it stands: -DGGML_VULKAN=ON needs glslc (shaderc) and the Vulkan/SPIRV headers, which are a prerequisite to install rather than a code change.

Python, once

Python is needed only to convert checkpoints yourself. Downloading the already-converted GGUFs needs nothing but curl, and nothing in the inference path uses Python at all.

uv venv .venv && source .venv/bin/activate
uv pip install --index-url https://download.pytorch.org/whl/cpu torch torchvision
uv pip install transformers safetensors numpy gguf pillow huggingface_hub av

torch and transformers are needed only by the converters that read Hugging Face checkpoints and by the reference-dump scripts; gguf and numpy are the minimum for conversion itself.

Get the models

The converted files are published on Hugging Face under jepacpp — one repository per model, five types each (f32, f16, q8_0, q4_0, q4_k), named exactly as the converter and jepa-quantize write them. scripts/download_models.sh fetches them into models/gguf/ (git-ignored) with hf download, or plain curl if the hf CLI is neither on PATH nor in .venv:

scripts/download_models.sh small                # LeJEPA + LeWorldModel + V-JEPA 2.1 at f16 (290 MiB)
scripts/download_models.sh all                  # all seven at f16 (3.3 GiB)
scripts/download_models.sh --ftype q8_0 ijepa   # one model, one type
scripts/download_models.sh --ftype all levjepa  # f32, f16, q8_0, q4_0 and q4_k of one model
scripts/download_fixtures.sh                    # golden dumps + media for the parity tests (665 MB)
model name repository f16 q8_0 q4_k
LeJEPA ViT-S/16 lejepa lejepa-vits16-pretrain-in1k-GGUF 42.2 MiB 23.2 13.0
LeWorldModel Push-T lewm lewm-pusht-GGUF 37.7 MiB 23.1 15.3
V-JEPA 2.1 ViT-B/16 @384 vjepa21 vjepa2_1-vitb-384-GGUF 209.7 MiB 113.3 61.9
LeVJEPA ViT-L/16 levjepa levjepa-vitl16-GGUF 578.7 MiB 310.3 166.3
V-JEPA 2 ViT-L/16 (fpc64) vjepa2 vjepa2-vitl-fpc64-256-GGUF 622.5 MiB 332.8 178.3
V-JEPA 2 ViT-L SSv2 vjepa2-ssv2 vjepa2-vitl-fpc16-256-ssv2-GGUF 717.1 MiB 383.2 205.1
I-JEPA ViT-H/14 ijepa ijepa_vith14_1k-GGUF 1206.2 MiB 643.7 343.7
V-JEPA 2 ViT-g/16 (fpc64) vjepa2-vitg vjepa2-vitg-fpc64-256-GGUF 1974.9 MiB 1056.7 564.8
V-JEPA 2-AC ViT-g (world model) vjepa2-ac vjepa2-ac-vitg-GGUF 2519.0 MiB 1344.1 717.4

A single file without the script, either way round:

hf download jepacpp/lewm-pusht-GGUF lewm-pusht-f16.gguf --local-dir models/gguf
curl -L -o models/gguf/lewm-pusht-f16.gguf \
     https://huggingface.co/jepacpp/lewm-pusht-GGUF/resolve/main/lewm-pusht-f16.gguf

Every model card lists the sha256 of all five files, so sha256sum -c verifies a download. Re-run cmake once models/gguf/ and tests/fixtures/ref/ are populated — the parity tests register at configure time. Which type to use: Accuracy → which dtype; f16 is the default, q8_0 halves it again for pooled work, f32 is the bit-exact tier, and the two 4-bit files sit below the parity bars. The other types jepa-quantize can produce (q4_1, q5_0, q5_1, q5_k, q6_k, measured in quantization) are not published; make them locally.

The golden reference dumps live in the companion dataset jepacpp/jepa.cpp-fixtures, which scripts/download_fixtures.sh pulls into tests/fixtures/ref/. The COCO images and Kinetics clips the dumps were computed on are not redistributed there; the same script fetches those from their own sources into tests/fixtures/media/.

Licences

Each GGUF carries its source checkpoint's licence verbatim in general.license and the origin in general.source_url (jepa-info --kv prints both). I-JEPA and LeVJEPA are non-commercial. jepa.cpp's own code is MIT.

model licence source of that statement redistribution of the converted weights
I-JEPA ViT-H/14 CC BY-NC 4.0 model card + the full CC text as LICENSE permitted for non-commercial use, with attribution to Meta and a note that the file is modified
LeJEPA ViT-S/16 Apache-2.0 model card metadata (the repo ships no LICENSE file) permitted, with attribution
LeWorldModel Push-T MIT model card + le-wm permitted, keep the notice
H-JEPA MIT model card + H-JEPA permitted, keep the notice
V-JEPA 2 ViT-L/16 (fpc64) MIT model card + LICENSE permitted, keep the notice
V-JEPA 2 ViT-L SSv2 MIT model card + the same LICENSE permitted, keep the notice
V-JEPA 2.1 ViT-B/16 @384 MIT the checkpoint table of facebookresearch/vjepa2; the .pt on dl.fbaipublicfiles.com carries no metadata of its own, so this is the repository-level grant permitted, keep the notice
LeVJEPA ViT-L/16 CC BY-NC 4.0 model card metadata (the repo ships no LICENSE file); the weights are trained from scratch, so the restriction is the publisher's own permitted for non-commercial use, with attribution to galilai-group and a note that the file is modified

None of the seven is gated and none carries an acceptable-use policy. The jepacpp repositories mirror these terms one for one; the fixtures dataset is CC BY-NC 4.0, because it stores outputs of the two non-commercial models.

scripts/hf_publish.py is what maintains those repositories: cards regenerates every model card from the GGUF metadata and the artifacts behind this site, upload pushes a set one repository at a time, and check compares the hub's sha256 and size for every published file — and every card — against the local ones, exiting non-zero on any drift.

Converting instead of downloading

scripts/download_models.sh --convert fetches the source checkpoints into models/ (git-ignored) instead of the GGUFs. small takes LeJEPA, LeWorldModel and V-JEPA 2.1 (~1.8 GB); all adds I-JEPA, both V-JEPA 2 files and LeVJEPA (~8 GB); a single name fetches one. This is the path that needs the Python environment above.

scripts/download_models.sh --convert small   # or: all | ijepa | vjepa2 | vjepa2-ssv2 | vjepa21 | levjepa | lewm | lejepa

Conversion writes models/gguf/<name>-<ftype>.gguf. --ftype f16 stores the attention, FFN, projection and classifier matrices as F16 and everything else (patch embeddings, norms, biases, position tables, tokens) as F32; --ftype f32 stores everything as F32. Quantized files come from jepa-quantize, never from the converter.

model download name convert
LeJEPA ViT-S/16 lejepa python scripts/convert.py --family hfvit --src models/OK-AI/lejepa-vits16-pretrain-in1k --ftype f16
LeWorldModel Push-T lewm python scripts/convert.py --family lewm --src models/quentinll/lewm-pusht --ftype f16
H-JEPA (any released run) hjepa python scripts/convert.py --family hjepa --src models/jepa-world-models/h-jepa/ant/ant_hjepa_l3/seed42/ant_hjepa_l3_object.ckpt --ftype f16
V-JEPA 2.1 ViT-B/16 @384 vjepa21 python scripts/convert.py --family vjepa2_1 --src models/vjepa2_1/vjepa2_1_vitb_dist_vitG_384.pt --out models/gguf/vjepa2_1-vitb-384-f16.gguf
I-JEPA ViT-H/14 ijepa python scripts/convert.py --family ijepa --src models/facebook/ijepa_vith14_1k --ftype f16
V-JEPA 2 ViT-L/16 (fpc64) vjepa2 python scripts/convert.py --family vjepa2 --src models/facebook/vjepa2-vitl-fpc64-256 --ftype f16
V-JEPA 2 ViT-L SSv2 vjepa2-ssv2 python scripts/convert.py --family vjepa2 --src models/facebook/vjepa2-vitl-fpc16-256-ssv2 --ftype f16
LeVJEPA ViT-L/16 levjepa python scripts/convert.py --family levjepa --src models/galilai-group/LeVJEPA-VideoMix-Large --out models/gguf/levjepa-vitl16-f16.gguf --ftype f16
V-JEPA 2 ViT-g/16 (fpc64) vjepa2-vitg python scripts/convert.py --family vjepa2 --src models/facebook/vjepa2-vitg-fpc64-256 --ftype f16
V-JEPA 2-AC ViT-g vjepa2-ac python scripts/convert.py --family vjepa2_ac --src models/vjepa2_ac/vjepa2-ac-vitg.pt --out models/gguf/vjepa2-ac-vitg-f16.gguf --ftype f16

Planning with V-JEPA 2-AC

The world model answers "what would the scene look like if I did this?", and a planner turns that into "what should I do?". jepa-worldmodel --plan is the loop from the V-JEPA 2-AC paper: it encodes the current frame and a goal frame, samples action sequences, rolls them all out in one batched graph per step and keeps the ones whose predicted frame lands closest to the goal.

scripts/download_models.sh vjepa2-ac                     # 2.5 GiB, the f16 bundle

# ctx.png is what the robot sees now, goal.png is where it should end up (256x256 RGB)
build/jepa-worldmodel --plan -m models/gguf/vjepa2-ac-vitg-f16.gguf \
    --image ctx.png --goal goal.png \
    --state '0.580,-0.002,0.248,-3.067,0.031,-1.913,0.997' \
    --samples 64 --topk 8 --cem-steps 10 --horizon 2 --gpu 0

--state is the arm's current 7-d pose (xyz, three Euler angles, gripper); it defaults to zeros, which is fine for exploring but not for a real arm. The output is the best energy per iteration and the chosen action sequence:

CEM: K=64 topk=8 iterations=10 horizon=2 maxnorm=0.050 momentum(mean/std)=0.25/0.95

iteration     best energy
0                0.463301
...
plan (2 steps, 7-d actions):
  step 0:  0.03974  0.03481  0.04139  0.00000  0.00000  0.00000  0.39948

The three zeros are the rotation dimensions, which this planner does not sample — that is the reference's action space, not a limitation of the port. A demo you can run without a robot is in the V-JEPA 2 repository: notebooks/franka_example_traj.npz holds a real Franka trajectory, and scripts/dump_reference.py --model vjepa2-ac-vitg writes its first and last frames into the parity fixtures. --noise-npy replays recorded random draws so a run can be compared against a PyTorch one byte for byte.

Use f16. A rollout compounds, and at q8_0 and below the plan degrades — at q4_k on a GPU the planner picks a different action (quantization).

The published files come from exactly these commands plus jepa-quantize; every model card records the one that produced it and the jepa.cpp commit it ran at, so a local conversion is reproducible against the published sha256.

Running the tools

jepa-info — what is in a file

build/jepa-info models/gguf/vjepa2_1-vitb-384-f16.gguf          # hparams + tensor table
build/jepa-info models/gguf/vjepa2_1-vitb-384-f16.gguf --kv     # every general.* / jepa.* key verbatim
build/jepa-info --devices                                       # GPU devices the registry can see

jepa-embed — images and clips to feature vectors

# one image -> CLS feature, timed
build/jepa-embed -m models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf \
    -i tests/fixtures/media/coco_000000000139.jpg --pool cls -t 32 --time

# many images in one graph (bit-identical to one at a time on the CPU, 1.7-2.2x faster on the small models)
build/jepa-embed -m models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf \
    -i a.jpg -i b.jpg -i c.jpg --batch 32 -o feats.npy -t 32

# a video file -> pooled feature: 16 frames sampled uniformly over the whole clip
build/jepa-embed -m models/gguf/vjepa2_1-vitb-384-f16.gguf \
    --video tests/fixtures/media/archery.mp4 --frames 16 --pool mean -t 32

# the same clip from a THWC uint8 .npy that scripts/video_frames.py wrote (identical output)
build/jepa-embed -m models/gguf/vjepa2_1-vitb-384-f16.gguf \
    --frames-npy tests/fixtures/ref/vjepa2_1-vitb-384/archery_f16.frames_u8.npy --pool mean -t 32

# frames given as images, in order
build/jepa-embed -m models/gguf/vjepa2_1-vitb-384-f16.gguf --as-video -i f0.jpg -i f1.jpg -t 32

# LeVJEPA reads a clip through its CLS token; a still image is repeated to the model's 16 frames,
# which is how its model card feeds one, and jepa-embed says so on stderr when it does. The repeat
# is jepa-embed's: jepa_encode() itself runs n_frames frames as given, so a library caller passing
# one frame gets a one-frame clip. Any clip length other than the trained 16 also gets a note.
build/jepa-embed -m models/gguf/levjepa-vitl16-f16.gguf -i cat.jpg --pool cls -t 32

# a whole list of clips, one model load, output [n_clips, D] in list order
build/jepa-embed -m models/gguf/vjepa2-vitl-fpc64-256-f16.gguf \
    --video-list clips.txt -o feats.npy -t 32       # or --frames-list for .npy clips

--pool selects mean (mean of the patch tokens), cls, lewm (the world-model state projection) or none (the full [N, D] token map). Clip frames may have different source sizes: each is resized and centre-cropped on its own before the planes are concatenated.

Where the frames come from

There are two routes into a video model, and they produce the same tensor:

  • --video clip.mp4 (also --video-list list.txt) decodes the container by running ffmpeg and keeps --frames frames sampled uniformly over the whole clip, endpoints included — idx = round(linspace(0, T_total - 1, n)). --frames defaults to the model's own jepa.enc.n_frames. Any container ffmpeg can read works (mp4, webm, mkv, avi, mov …).
  • --frames-npy clip.npy (also --frames-list list.txt) reads a THWC uint8 array that scripts/video_frames.py wrote — the scripted route the accuracy harnesses use, because they want one decode shared by both backends and a cached, indexed frame set.

ffmpeg is a run-time dependency of jepa-embed and jepa-classify only: nothing links against it, the build never looks for it, and a checkout without it loses --video and nothing else (the error names the binary and how to install it). Both routes decode with libswscale and sample with the same formula, so --video clip.mp4 and --frames-npy on that script's output of the same clip agree byte for byte — measured over the fixture clips and 40 Something-Something-v2 clips, ctest -R video. --dump-frames out.npy writes the sampled frames in that same THWC uint8 layout if you want to diff the two yourself.

jepa-classify — a clip to labels

build/jepa-classify -m models/gguf/vjepa2-vitl-fpc16-256-ssv2-f16.gguf \
    --video tests/fixtures/media/archery.mp4 -k 5 -t 32 --time     # or --frames-npy clip.npy
1.  59.36%  [ 90] Pulling two ends of [something] but nothing happens
2.  14.87%  [162] Trying to bend [something unbendable] so nothing happens
...  preprocess 26 ms | encoder 968 ms | head 96 ms | 2115 tokens/s

The 174 Something-Something-v2 label strings come out of the GGUF, so no side file is needed.

jepa-worldmodel — image to latent state to rollout

build/jepa-worldmodel -m models/gguf/lewm-pusht-f16.gguf \
    --image tests/fixtures/media/coco_000000000139.jpg --random-actions 8 -t 32

It encodes the image, projects the CLS token to the world-model state, rolls the predictor out over the actions and prints the L2 and cosine drift per step. --actions '0.1,0.2,…;0.3,…' supplies real actions; --ref-check tests/fixtures/ref/lewm-pusht replays the PyTorch reference and exits non-zero on a mismatch.

jepa-plan — from two images to actions (H-JEPA)

scripts/download_models.sh hjepa                       # ant_hjepa_l3-seed42-f16.gguf
build/jepa-plan -m models/gguf/ant_hjepa_l3-seed42-f16.gguf \
    --obs now.png --goal goal.png --horizons 2,2,5 --samples 100,100,400 -t 32

One call of the paper's top-down planner. The observation and the goal are encoded at every level; the top level optimises its macro-actions towards the goal by gradient descent through its world model, and each level below is asked to reach the first state the level above predicted. It prints what every level did (horizon, iterations, cost, the latent space the cost was measured in) and the level-1 plan, in the model's units and as raw environment actions. Per-level lists (--horizons, --samples, --lr, --cost-last-n, --action-cost, --subgoals, …) take level 1 first; the values in the authors' h_jepa/config/eval/*.yaml map onto them one to one. --budget N --step K --interval R plans as call K / R of an N-step episode, with the horizons the reference's schedule gives that call. A controller executes the first actions, observes, and calls again.

The planner's gradient runs on the CPU threads whatever device the model is on, and its result does not depend on the thread count.

jepa-quantize — make a file smaller

build/jepa-quantize models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf \
                    models/gguf/lejepa-vits16-pretrain-in1k-q8_0.gguf q8_0 -t 32

Types: f16, q8_0, q6_k, q5_k, q5_1, q5_0, q4_1, q4_k, q4_0. Only the 2-D weight matrices change type; K-quants need row counts that are multiples of 256 and fall back per tensor (q4_k → q4_0) where they are not. The file is written in two passes, so peak memory is one tensor rather than the whole model. Sizes and per-type accuracy: quantization; which one to ship: Accuracy → which dtype.

jepa-bench — timing any graph

build/jepa-bench -m models/gguf/vjepa2-vitl-fpc64-256-f16.gguf --frames 64 --threads 32,96 --md
build/jepa-bench -m models/gguf/ijepa_vith14_1k-f16.gguf --gpu 0 --md

Modes are encoder (default), head, predictor, lewm-step and lewm-rollout. The input is synthetic but deterministic — a seeded uint8 stream put through the model's own jepa.pre.* normalisation — so the tool runs wherever a GGUF is, with no fixtures and no Python.

jepa-server — the same engine over HTTP

build/jepa-server -m models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf \
    --threads 8 --workers 4 --max-batch 8            # http://127.0.0.1:8080
jepa-server 0.3.0: lejepa-vits16-pretrain-in1k-f16 (hfvit, f16, CPU) on http://127.0.0.1:8080
  workers 4 x 8 threads | max-batch 8 | max-wait 5 ms | local files refused
curl -s localhost:8080/health
curl -s localhost:8080/v1/embeddings -H 'Content-Type: application/json' \
     -d "{\"input\": {\"b64\": \"$(base64 -w0 tests/fixtures/media/coco_000000000139.jpg)\"}}"

POST /v1/embeddings is OpenAI-shaped, POST /classify returns top-k labels, POST /rollout runs a world-model rollout or the V-JEPA 2-AC planner, and GET /health, GET /metrics and GET /v1/models describe the process. Requests of the same shape are folded into one encoder graph, which changes the scheduling and not the arithmetic. The server binds 127.0.0.1 unless --host says otherwise and has neither TLS nor authentication, so it is a component to put behind something. Endpoints, flags, the Python client and the measured throughput: Serving.

Running on a GPU

With a -DJEPA_CUDA=ON build, jepa-embed, jepa-classify, jepa-worldmodel, jepa-bench and jepa-server take --gpu [N]:

build-cuda/jepa-embed -m models/gguf/ijepa_vith14_1k-f16.gguf -i img.jpg --gpu 0
JEPA_DEVICE=cuda:0 build-cuda/jepa-classify -m models/gguf/vjepa2-vitl-fpc16-256-ssv2-f16.gguf --frames-npy clip.npy

Weights and activations live entirely on the card and --threads becomes irrelevant. Use f16 or a quantized file there: there is no f32 parity tier on a GPU, and quantized weights are the fastest GPU path.

The C API

One header, include/jepa.h — opaque handles, plain structs, no C++ types in the interface. Load a model, make a context, jepa_encode, then pool, predict or classify. The complete reference is on the C API page.

jepa_model   * m = jepa_model_load("models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf", false);
jepa_context * c = jepa_context_new(m, jepa_context_default_params());

int h = 0, w = 0;
float * x = jepa_preprocess_image_file(m, "img.jpg", &h, &w);   // NCTHW, N = T = 1
jepa_input  in  = { x, 1, 3, 1, h, w };
jepa_output enc = {0}, cls = {0};
jepa_encode(c, &in, &enc);        // enc.data = [n_tokens, embed_dim]
jepa_pool_cls(m, &enc, &cls);     // cls.data = [embed_dim]
// every .data above is the caller's: jepa_free(cls.data), jepa_free(enc.data), jepa_free(x)

Python bindings

pip install jepa-cpp puts the same engine behind numpy — a thin wrapper over include/jepa.h, with the preprocessing, the graph and every floating-point operation still on the C side. The wheel carries one self-contained shared library, so it needs neither ggml nor a compiler. A source build, pip install ./python from a recursive clone, is the route to a -march=native or CUDA-enabled library, and $JEPA_CPP_LIB points an installed package at one built by hand.

import jepa_cpp

with jepa_cpp.Model("models/gguf/lejepa-vits16-pretrain-in1k-f16.gguf", threads=32) as m:
    tokens  = m.encode("img.jpg")                 # [197, 384] float32
    feature = m.encode("img.jpg", pool="cls")     # [384]
    batch   = m.encode(["a.jpg", "b.jpg"], pool="cls")   # [2, 384], one encoder graph

with jepa_cpp.Model("models/gguf/vjepa2-vitl-fpc16-256-ssv2-f16.gguf", device="cuda:0") as m:
    print(m.classify(frames_thwc_uint8).top(5))   # [(index, label, probability), ...]

Model also carries pool, predict, classify, lewm_project, lewm_predict and lewm_rollout, and the file's metadata as properties; jepa_cpp._api is the unwrapped header, one Python name per C name. On a CPU the bindings reproduce jepa-embed's output bit for bit, which python/tests/test_parity.py gates against the tools and the golden dumps alike. The package's own reference is python/README.md.

Tests

ctest --test-dir build

Nineteen suites with every asset present: eleven parity and predictor replays of the PyTorch golden dumps — nine on the CPU, plus parity-lejepa-vits16-gpu and parity-levjepa-vitl16-gpu, which run the two families whose GPU mul_mat accumulation defaults to f16 (hfvit and levjepa, parity) at that default and judge them with the GPU threshold column. Then batch (batched vs per-item bit-exactness), ops (3-D RoPE against generated vectors), attn (flash vs naive attention against a double-precision reference), video (--video decode and sampling against the reference dumps' PyAV frames), backend (GPU graph validation and CPU/GPU agreement, which exits 0 with a skip line when the build has no GPU, so one ctest invocation covers both kinds of machine — the two parity-*-gpu entries skip the same way), server (a real jepa-server on a loopback port, its embeddings compared bit for bit against the jepa-embed binary of the same build), errors (malformed GGUFs it forges itself, and the NULL / oversized / budget guards of the inference entry points) and threads (the thread contract, N threads sharing one model). The parity suites register at CMake configure time and need models/gguf/ and tests/fixtures/ref/ populated, so re-run cmake once after downloading and converting; video additionally needs the clips under tests/fixtures/media/, and server needs the small LeJEPA file and the fixture images. A checkout missing an asset registers fewer suites rather than failing. errors and threads need neither and always register. The methodology is in Architecture → testing and parity, and what the loader validates is in Architecture → robustness.

CI runs on every push to main and every pull request (.github/workflows/ci.yml): a build and ctest on Ubuntu 22.04, Ubuntu 24.04 and macOS arm64, a Metal build on macOS, an MSVC build on Windows, and an ASAN+UBSAN build with detect_leaks=1. A runner has no converted weights, so those jobs run the four suites that need none — ops, attn, errors and threads — and build (never run) the fuzz target. A separate parity job fetches the LeJEPA and LeWorldModel GGUFs and their reference dumps from the Hugging Face hub and runs the parity-*, predictor-lewm, batch and backend suites on top; while those files are unpublished it says so and stops. The same workflow gates the generated documentation — gen_api_md.py --check, both figure scripts, render_accuracy_md.py --check, mkdocs build --strict and shellcheck.

The CUDA build is not in CI, because GitHub-hosted runners have no NVIDIA device: -DJEPA_CUDA=ON and the backend suite on a GPU stay a developer-machine check. docs/benchmarks.md has no CI gate either — it is rebuilt from a sweep directory measured on a known-idle box, and scripts/gen_benchmarks_md.py --check is the local gate for it.

Documentation

pip install -r docs/requirements.txt
mkdocs serve          # live preview at http://127.0.0.1:8000

The site deploys to GitHub Pages on every push to main (.github/workflows/docs.yml). docs/api.md is generated from include/jepa.h by scripts/gen_api_md.py; docs/benchmarks.md and the generated blocks of docs/accuracy-image.md / docs/accuracy-video.md come from their measurement scripts and are not edited by hand.