Status: Implemented Depends on: ADR-0005 (FFN-Service Memory Bounds), ADR-0006 (Q4_K Remote FFN) Relates to: ADR-0003 (FFN Router), ADR-0004 (FFN Grid), ADR-0008 (Embed Server)
ADR-0005 and ADR-0006 produced a split-tier inference topology: clients run attention locally and delegate FFN to a server, or to a router that fans out to a shard grid. For that topology to be usable by anyone who didn't extract the model themselves, the built artefacts have to be distributable — ideally as HuggingFace repos that a laptop can pull in pieces.
The minimum viable story is "upload one big vindex". That works for
INFER but wastes bandwidth: a client doesn't need FFN weights, a
server doesn't need attention weights, a browse-only consumer doesn't
need either. Pulling the full repo and discarding 60% of it on every
machine is the wrong shape.
Three concrete problems:
-
One extract, many shapes. The client/server split wants two disjoint subsets of the same source vindex, plus a third browse-only view for DESCRIBE/WALK users. Re-extracting from the safetensors source for each shape wastes compute and introduces drift.
-
Discovery across six repos. Publishing a full vindex + five slices (
client,attn,embed,server,browse) means six separate HF repos. Without a landing page a user hittinghf://chrishayuk/gemma-4-31b-it-vindexhas no way to discover the-client/-attn/-embedsiblings, and it's not obvious which one they need. -
Re-publishing is expensive. A 27 GB server slice uploaded via plain HTTP takes minutes. Most re-publishes are incremental — the gate vectors didn't change, only the index.json bumped a version. Re-transferring the whole payload every time is unnecessary.
Three layered primitives, each composable on its own:
-
larql slice— carve a built vindex into deployment variants without re-extracting. Pure file I/O plus anindex.jsonrewrite. -
larql publish— upload the full vindex and every sibling slice to HuggingFace as separate repos, then file them into collections so discovery works from a single landing page. -
Skip-if-unchanged — each upload compares the local SHA256 against the remote
lfs.oid. Files that already match skip the transfer entirely.
The three composition layers ship behind one command:
larql publish gemma4-31b.vindex --repo chrishayuk/gemma-4-31b-it-vindexOne invocation → six repos + three nested collections. Re-runs are near-free when nothing changed.
crates/larql-cli/src/commands/primary/slice_cmd.rs exposes
slice_vindex(src, dst, parts, force, dry_run) -> Result<SliceOutcome>
as the testable core; run() is a thin CLI wrapper with progress
prints.
Each part matches one or more filename patterns. index.json is always
copied regardless of the part set.
| Part | Files |
|---|---|
embed |
embeddings.bin |
norms |
norms.bin |
attn |
attn_weights*.bin (includes q4/q4k/q8 variants + manifests) |
gate |
gate_vectors.bin, gate_vectors_q4.bin |
down_meta |
down_meta.bin, down_meta.jsonl |
ffn |
interleaved*.bin + manifests, up_weights.bin, down_weights.bin, up_features.bin, down_features.bin |
lm_head |
lm_head*.bin |
router |
router_weights.bin |
tokenizer |
tokenizer.json |
manifest |
weight_manifest.json |
labels |
feature_labels.json, feature_clusters.jsonl, relation_clusters.json |
readme |
README.md |
Two topologies supported side-by-side. Pick the row that matches your deployment:
2-tier (default — client holds embed locally)
| Preset | Parts | Pairs with |
|---|---|---|
client |
embed + norms + attn + tokenizer + manifest + labels | larql run --ffn URL |
server |
embed + norms + gate + down_meta + ffn + tokenizer + manifest + labels | larql serve --ffn-only |
browse |
embed + gate + down_meta + tokenizer + labels + readme | DESCRIBE / WALK / SELECT (no forward pass) |
3-tier (client delegates embed + FFN; ADR-0008)
| Preset | Parts | Pairs with |
|---|---|---|
attn (alias: attention) |
norms + attn + manifest + labels | larql run --embed URL --ffn URL (3-tier client) |
embed (alias: embed-server) |
embed + tokenizer + labels | larql serve --embed-only (ADR-0008 embed-server) |
server |
— | same as 2-tier row |
The attn preset drops the embedding table entirely — ~2.7 GB saved on
Gemma 3 4B (310 MB attn slice vs 3 GB client slice), ~2.6 GB on 31B
Q4_K. Use when laptop RAM matters and you can run an embed server
(ADR-0008) alongside the FFN server.
Other
| Preset | Parts | Pairs with |
|---|---|---|
router |
router + tokenizer + manifest + labels + readme | MoE router (ADR-0003) |
all |
every part | full clone under a different name |
On every slice the destination's index.json gets rewritten so
extract_level and has_model_weights match what's on disk:
extract_levelis set to the strongest tier actually present (Browse/Attention/Inference/All), and never higher than the source level. A client slice from an Inference-tier source thus downgrades toAttention.has_model_weightsis true whenever attention OR FFN compute weights are kept. This is load-bearing: the Q4K loader (load_model_weights_q4k) refuses to open a vindex whose config advertiseshas_model_weights: false, so setting it correctly on an attention-only client slice is what letslarql run --ffn URLload it at all.
A client-preset slice contains no gate_vectors.bin and no
interleaved_q4k.bin — the client delegates gate-KNN to the server.
Before this change, VectorIndex::load_vindex rejected that layout
with:
parse error: neither gate_vectors.bin nor interleaved_q4k.bin present
crates/larql-vindex/src/format/load.rs now synthesises an empty
anonymous mmap with all-zero slices when both gate sources are absent.
gate_knn on that index returns an empty result — correct for
attention-only clients, which never call it. Tests in
crates/larql-vindex/tests/test_vindex.rs :: load_vindex_synthesises_empty_gate_when_both_sources_absent pin the
behaviour.
All on Gemma 4 31B Q4_K, macOS:
| Slice | On-disk | Pair command | Notes |
|---|---|---|---|
| full | 32 GB | larql run |
baseline |
client |
7.4 GB | larql run --ffn URL |
2-tier; 4.3× smaller than full |
attn |
4.8 GB | larql run --embed URL --ffn URL |
3-tier (ADR-0008); attn + norms only |
embed |
2.6 GB | larql serve --embed-only |
embed + tokenizer for ADR-0008 server |
server |
27 GB | larql serve --ffn-only |
no attention, still has embed+norms so the Q4K loader opens |
browse |
16 GB | larql lql 'DESCRIBE …' |
no FFN, no attention |
crates/larql-cli/src/commands/primary/publish_cmd.rs stages each
slice in a temp directory via slice_vindex, uploads via
larql_vindex::publish_vindex_with_opts, and finally calls
larql_vindex::ensure_collection for each requested collection level.
Default template: {repo}-{preset}. The full vindex goes to {repo}.
For chrishayuk/gemma-4-31b-it-vindex:
chrishayuk/gemma-4-31b-it-vindex (full)
chrishayuk/gemma-4-31b-it-vindex-client (2-tier client: attn + embed + norms)
chrishayuk/gemma-4-31b-it-vindex-attn (3-tier client: attn + norms only — ADR-0008)
chrishayuk/gemma-4-31b-it-vindex-embed (embed server: embed + tokenizer — ADR-0008)
chrishayuk/gemma-4-31b-it-vindex-server (FFN server)
chrishayuk/gemma-4-31b-it-vindex-browse (DESCRIBE / WALK only)
Override with --slice-repo-template "{repo}/{preset}" (folder-style)
or --slice-repo-template "{repo}_{preset}" (underscore separator).
The templating supports any layout HF accepts.
Three nested levels, all auto-derived from the vindex's model field:
| Level | Title | Holds |
|---|---|---|
model |
Gemma 4 31B It — LARQL Vindex |
all six sibling repos for this model |
family |
Gemma Family — LARQL Vindexes |
every model of this architecture you've published |
library |
LARQL Vindex Library |
every vindex you've ever published |
The hierarchy isn't enforced by HF — the same repo appears in all three collections. That's the point: someone landing on the family page sees every Gemma you've uploaded; someone on the model page sees the four deployment variants for one size.
crates/larql-vindex/src/format/huggingface.rs::ensure_collection:
pub fn ensure_collection(
namespace: &str,
title: &str,
description: Option<&str>,
items: &[CollectionItem],
) -> Result<String, VindexError> // returns collection URLGET /api/users/{namespace}/collections?limit=100— list existing collections.- Case-insensitive title match → reuse slug if found, otherwise
POST /api/collectionsto create. - For each item:
POST /api/collections/{slug}/item. HTTP 409 ("already in collection") is treated as success.
Re-publishing the same vindex is safe: the model collection is found
by title, the four items are already present and yield 409s, the
family and library collections accrete entries as new models land.
The model field in index.json can be:
google/gemma-4-31b-it(clean HF form)/Users/.../models--google--gemma-4-31B-it/snapshots/abc/(HF cache layout)gemma-3-4b-it(already short)
short_model_name handles all three, including the models--{owner}--{name}
prefix pattern that trips up a naive rsplit('/'). default_model_title
title-cases segments and default_family stops at the first digit-leading
segment (Gemma 4 31B It → family Gemma). Callers override with
--model-title / --family when the auto-derivation reads awkwardly
(e.g. "Gemma 4 31B Instruct" vs "...It").
PublishOptions { skip_unchanged: bool } drives per-file upload
decisions in publish_vindex_with_opts. When on (CLI default unless
--force-upload):
fetch_remote_lfs_oids(repo, token)hits/api/datasets/{repo}/tree/main?recursive=trueand extracts every entry'slfs.oid. This field exists iff the file is LFS-tracked — i.e. a "big" binary likegate_vectors.bin.- Per file about to upload: compute local SHA256 via
format::checksums::sha256_file. If the local hash equals the remotelfs.oid, callPublishCallbacks::on_file_skipped(name, size, sha)and move on. - Anything else (no remote entry, git-tracked without
lfs.oid, tree API errored): upload normally.
Small files (index.json, manifests) are git-tracked on HF. The git
blob SHA-1 format is blob {size}\0{content} hashed, which isn't
directly comparable to the file-content SHA256 without a separate
hash. Computing it is tractable but adds complexity for files that
total a few KB anyway — the win doesn't justify the code. Always
re-uploading them is cheap.
The practical payoff lands on the big stuff: re-publishing a 27 GB server slice where nothing changed transfers only the manifests, not the gate + interleaved FFN blobs.
fetch_remote_lfs_oids returns Ok(HashMap::new()) on:
- HTTP 404 (brand-new repo, just created, empty)
- JSON parse failure (HF API change, corruption)
- Network error
The upshot: if anything goes wrong with the index fetch, publish
silently falls back to "upload everything" rather than aborting.
Correctness is preserved at the cost of a wasted upload on a transient
failure.
The download half of the story mirrors publish. Four resolution paths,
symmetric with the four publish options:
| Pull flag | Publish counterpart | Resolves to |
|---|---|---|
plain pull <repo> |
plain publish --repo <repo> |
one repo |
pull <repo> --preset client |
publish --slices client |
{repo}-client via same template |
pull <repo> --all-slices |
publish with default slice set |
full + every default sibling |
pull --collection <slug> |
publish --collections … |
every dataset in the collection |
After a plain single-repo pull, pull_one calls
dataset_repo_exists(...) (HEAD /api/datasets/{repo}) for each
standard suffix on the same base. Matches are printed as an "Also
available" hint so the slice convention is self-announcing:
$ larql pull chrishayuk/gemma-4-31b-it-vindex
Pulling hf://chrishayuk/gemma-4-31b-it-vindex...
[per-file progress bars]
Cached at: /.../datasets--chrishayuk--gemma-4-31b-it-vindex/...
Also available on HuggingFace:
--preset client → hf://chrishayuk/gemma-4-31b-it-vindex-client
--preset attn → hf://chrishayuk/gemma-4-31b-it-vindex-attn
--preset embed → hf://chrishayuk/gemma-4-31b-it-vindex-embed
--preset server → hf://chrishayuk/gemma-4-31b-it-vindex-server
--preset browse → hf://chrishayuk/gemma-4-31b-it-vindex-browse
Use `larql pull <repo> --all-slices` to grab them all.
If the pulled repo itself ends in a known suffix (-client etc.),
split_sibling_suffix maps back to the base and probes the full repo
plus the other siblings, so someone who pulls a client slice still
discovers the full and the server companion.
larql_vindex::resolve_hf_vindex_with_progress(hf_path, factory) wraps
hf-hub 0.5's Repo::download_with_progress. The factory is called per
file with the filename and returns a fresh DownloadProgress — in the
CLI that's a BarProgress(indicatif::ProgressBar) backed by a shared
MultiProgress.
hf-hub handles .incomplete partial-file resume internally: an
interrupted pull restarts where it left off on the next run. No
additional code needed on our side.
hf-hub 0.5 pins indicatif 0.18 and provides impl Progress for indicatif::ProgressBar out of the box — but the CLI is on indicatif
0.17 (different types). Hence BarProgress in pull_cmd.rs with a
hand-rolled Progress impl over indicatif 0.17. Cheap and keeps the
workspace consistent on one indicatif version.
fetch_collection_items calls /api/collections/{slug} and filters
to type == "dataset" entries. Per-repo failures log a warning but
don't abort the batch — one unavailable sibling shouldn't fail the
whole collection pull. Summary at the end counts successes vs
failures.
| Flag | Default | Effect |
|---|---|---|
--full / --no-full |
--full |
Upload the full vindex to --repo |
--slices a,b,c |
client,attn,embed,server,browse |
Which presets to upload as siblings; none to skip. Covers both 2-tier and 3-tier (ADR-0008) topologies out of the box. |
--slice-repo-template T |
{repo}-{preset} |
Sibling naming; {repo} and {preset} substitute |
--collections a,b,c |
model,family,library |
Which collections to create/update; none to skip |
--model-title T |
derived | Override the per-model collection title |
--family F |
derived | Override the family collection's grouping |
--library-title T |
LARQL Vindex Library |
Override the top-level collection title |
--force-upload |
off | Bypass SHA256 skip; re-upload every file |
--tmp-dir D |
system temp | Where to stage intermediate slices |
--dry-run |
off | Print the plan; no repos created, no files uploaded |
| File | Role |
|---|---|
crates/larql-cli/src/commands/primary/slice_cmd.rs |
slice_vindex, Part, preset_parts, CLI wrapper |
crates/larql-cli/src/commands/primary/publish_cmd.rs |
larql publish: slice orchestration, collection composition, skip plumbing |
crates/larql-cli/src/commands/primary/pull_cmd.rs |
larql pull: --preset, --all-slices, --collection, sibling hints, indicatif progress bars (BarProgress) |
crates/larql-cli/src/commands/extraction/hf_cmd.rs |
larql hf publish (simpler one-repo publish); shares PublishCallbacks |
crates/larql-vindex/src/format/huggingface.rs |
publish_vindex, publish_vindex_with_opts, PublishOptions, fetch_remote_lfs_oids, ensure_collection, CollectionItem, dataset_repo_exists, fetch_collection_items, resolve_hf_vindex_with_progress, DownloadProgress, streaming CountingReader + poll-thread upload, PublishCallbacks::on_file_skipped + on_file_progress |
crates/larql-vindex/src/format/load.rs |
Empty-gate synthesis when both gate source files are absent |
crates/larql-vindex/src/format/checksums.rs |
sha256_file (reused from pre-existing checksum infra) |
-
Git SHA-1 parity for small files. Computing
git blob {size}\0{content}SHA-1 locally would let us skip re-uploads of unchangedindex.jsontoo. The win is ~KB per small file. Deferred until measurement shows it matters — a diff of the HF git tree between publishes is usually more useful than skipping them. -
Collection description drift.
ensure_collectionsets a description on create but doesn't reconcile on subsequent runs. If we want the description to track a field in the vindex config, the helper needs aPATCH /api/collections/{slug}call. Today's behaviour is fine for fire-and-forget publishes; the nit matters when collection descriptions evolve. -
Manifest-level skip. Instead of per-file SHA256 compares we could publish a single
manifest.jsonthat lists every file's SHA256; a re-publish that finds the manifest unchanged could skip the whole repo. That's belt-and-suspenders on top of the current scheme and only matters under heavy re-publish load. -
Slicing a router vindex. The
routerpreset produces a <1 GB artefact for a dense model (norouter_weights.binpresent) — essentially empty. It's harmless but not useful. Either auto-skip therouterslice when the source is dense, or keep the current explicit opt-in. Current decision: explicit opt-in (router isn't in the default slice list), but the slice itself shouldn't error if the source lacks router weights — an empty router slice is correct output for a dense model. -
Skip + collection ordering. Collections are always updated, even when every file in every repo was skipped. That's intentional — the title might have changed, or the notes on items might have shifted. If it ever becomes expensive, add a
--skip-collection-updateflag.