Guide contents

Lesson 32 of 73

Workshop: loop pull, list, show, rm, and fit

Before a local model can answer a question, its file has to be on your disk. A model file is large (several gigabytes), it lives on a website, and it comes in several versions that trade quality against size. This workshop builds the part of the loop tool that deals with those files: finding them, choosing a version that fits your machine, downloading them safely, and keeping track of what you have.

The workshop turns the pieces of sections 2 to 4 of the course into the first working commands of loop. Each command has a plain job:

  • loop pull looks at a Hugging Face repository (a folder of files on the Hugging Face website, the main place where models are shared) and finds its GGUF files. GGUF is the file format that llama.cpp uses to store a model. The command picks a quantization that fits the machine, downloads the files, and records them in a local registry. A quantization is a version of the model whose numbers are stored with fewer bits, which makes the file smaller and slightly less accurate.
  • loop list shows the models that were downloaded.
  • loop show prints facts about one model, such as its number of layers.
  • loop fit estimates how much memory a model needs.
  • loop rm removes a model and frees its disk space.

After the workshop, you have Projects 9 and 10: a model manager that behaves like ollama pull and ollama list (the commands of the Ollama tool) for GGUF files from the Hub.

The workshop is built in twelve stages. Each stage is one Example below, so "stage 3" and "Example 3" mean the same thing. Every stage reuses code from an earlier lesson, so the table names the source of each piece.

Stage What it builds, in plain words Reuses
1 Reading a model name such as repo[:quant][@revision] ModelRef from picocli Basics
2 Listing the files in a repository HubClient from The Hub HTTP API and Cache Layout in Java
3 Choosing a quantization that fits a memory budget Project 4 from Memory Math
4 Recognizing models that are split into several files llama.cpp's naming rule
5 Downloads that can resume and are checked for corruption Downloader from Downloading Large Files Reliably in Java
6 The registry: small description files plus a shared store of big files The cache layout of lesson 26
7 loop pull from start to finish Stages 1 to 6, picocli
8 loop list Example 8 of Subcommands, Configuration, and Packaging
9 loop show Project 7 from The GGUF Format
10 loop fit Projects 4 and 7
11 loop rm, which deletes a big file only when no model needs it Stage 6
12 The full program and a real run Everything above

The text refers to earlier lessons by their number in the course. These are the lessons it uses:

Every stage is a complete program that you can run. You cannot assume that the real Hugging Face website is reachable, and its answers change over time, so the stages that need the Hub start a fake Hub inside the program. The fake Hub is a small web server that listens on 127.0.0.1 (your own machine) on a port chosen by the operating system, as in lessons 26 and 31. It serves the refs, tree, and resolve endpoints (the three kinds of web address that the lesson 26 client uses), and a CDN path with range support. A CDN (content delivery network) is the fast file server that the Hub redirects large downloads to. Range support means the server can send a file starting from any byte, which resuming needs.

The repository example-org/Coder-7B-Instruct-GGUF and its file sizes are the invented examples of lesson 26. The repository example-org/Tiny-Coder-GGUF is a tiny model whose GGUF files the programs write themselves, so that the downloads finish in milliseconds.

Example 1: parsing a model reference

A user types a short text such as example-org/Coder-7B-Instruct-GGUF:q4_k_m. The tool has to turn that text into three facts: which repository to use, which quantization to fetch, and which revision (a version of the repository, such as a branch, a tag, or a commit) to fetch. It also needs names for its local registry, because the model will be stored on disk under some name.

The ModelRef record from lesson 29 (Example 12) already parses the format repo[:quant][@revision]. This stage adds two things that a model manager needs. First, it accepts the web address forms that users copy from a browser, such as https://huggingface.co/org/name. Second, it derives two local names from a reference: a display name for people, and a directory name that is safe for any file system.

Look at the output for the three lines each input produces (the record, the display name, and the directory name) and at how the last two inputs fail.

Java
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ModelRefs {
    /** repo[:quant][@revision], from lesson 26 (Example 12), plus URL prefixes and local names. */
    record ModelRef(String repo, String quant, String revision) {
        static final Pattern FORMAT = Pattern.compile("([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
        static final List<String> PREFIXES = List.of("https://huggingface.co/", "huggingface.co/", "hf.co/");

        static ModelRef parse(String text) {
            String t = text.strip();
            for (String p : PREFIXES) if (t.startsWith(p)) { t = t.substring(p.length()); break; }
            Matcher m = FORMAT.matcher(t);
            if (!m.matches() || m.group(1).contains(".."))
                throw new IllegalArgumentException("'" + text + "' is not a model reference of the form org/name[:QUANT][@REVISION]");
            String quant = m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT);
            return new ModelRef(m.group(1), quant, m.group(3) == null ? "main" : m.group(3));
        }

        /** The name shown by loop list once the quant is known, for example org/repo:Q4_K_M. */
        String displayName(String chosenQuant) { return repo + ":" + chosenQuant + (revision.equals("main") ? "" : "@" + revision); }

        /** A directory name without '/' or ':', unique per repo, quant, and revision. */
        String directoryName(String chosenQuant) {
            String rev = revision.equals("main") ? "" : "--" + revision.replace('/', '_');
            return repo.replace("/", "--") + "--" + chosenQuant + rev;
        }
    }

    public static void main(String[] args) {
        String[] inputs = {"example-org/Coder-7B-Instruct-GGUF", "example-org/Coder-7B-Instruct-GGUF:q4_k_m",
                "hf.co/example-org/Coder-7B-Instruct-GGUF:Q8_0", "https://huggingface.co/example-org/Coder-7B-Instruct-GGUF@refs/pr/3",
                "example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567", "Coder-7B", "org/../x"};
        for (String in : inputs) {
            try {
                ModelRef r = ModelRef.parse(in);
                String q = r.quant() == null ? "Q4_K_M" : r.quant();          // pretend the chooser picked Q4_K_M
                System.out.println(in);
                System.out.println("  " + r + "\n  name " + r.displayName(q) + "\n  dir  " + r.directoryName(q));
            } catch (IllegalArgumentException e) {
                System.out.println(in + "\n  error: " + e.getMessage());
            }
        }
    }
}
Output
example-org/Coder-7B-Instruct-GGUF
  ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=null, revision=main]
  name example-org/Coder-7B-Instruct-GGUF:Q4_K_M
  dir  example-org--Coder-7B-Instruct-GGUF--Q4_K_M
example-org/Coder-7B-Instruct-GGUF:q4_k_m
  ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q4_K_M, revision=main]
  name example-org/Coder-7B-Instruct-GGUF:Q4_K_M
  dir  example-org--Coder-7B-Instruct-GGUF--Q4_K_M
hf.co/example-org/Coder-7B-Instruct-GGUF:Q8_0
  ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q8_0, revision=main]
  name example-org/Coder-7B-Instruct-GGUF:Q8_0
  dir  example-org--Coder-7B-Instruct-GGUF--Q8_0
https://huggingface.co/example-org/Coder-7B-Instruct-GGUF@refs/pr/3
  ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=null, revision=refs/pr/3]
  name example-org/Coder-7B-Instruct-GGUF:Q4_K_M@refs/pr/3
  dir  example-org--Coder-7B-Instruct-GGUF--Q4_K_M--refs_pr_3
example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567
  ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q5_K_M, revision=0123456789abcdef0123456789abcdef01234567]
  name example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567
  dir  example-org--Coder-7B-Instruct-GGUF--Q5_K_M--0123456789abcdef0123456789abcdef01234567
Coder-7B
  error: 'Coder-7B' is not a model reference of the form org/name[:QUANT][@REVISION]
org/../x
  error: 'org/../x' is not a model reference of the form org/name[:QUANT][@REVISION]

parse removes one of three prefixes (https://huggingface.co/, huggingface.co/, hf.co/) and then applies the pattern of lesson 29. This means a user can paste a repository URL from the browser, or use the short hf.co/ form that the llama.cpp and Ollama documentation use. The quant (short for quantization name) is converted to upper case, so q4_k_m and Q4_K_M mean the same thing. The revision defaults to main, the usual name of the default branch.

Each input produces three lines: the record, the display name, and the directory name. When no quant was given, the program pretends that a chooser picked Q4_K_M. Stage 3 builds the real chooser.

The two derived names have different jobs:

  • Display name (name in the output): repo:QUANT, plus @revision when the revision is not main. This is the name loop list prints and loop show accepts. It names one quant of one revision, because two quants of the same repository are two different models on disk.
  • Directory name (dir in the output): the same information without / and :. Those characters are not allowed, or not safe, in directory names on every operating system. The / becomes --, as in the Hub cache's models--org--name (lesson 26). A revision such as refs/pr/3 (a pull request branch) has its slashes replaced with _, which gives refs_pr_3.

Compare the second and third inputs in the output. Both end up with the same display name and directory name, even though one was typed in lower case and one had a prefix. This is the point of normalizing: different spellings of one model lead to one place on disk.

The last two inputs are rejected. Coder-7B has no organization, so it does not have the form org/name. org/../x contains .., which means "go up one directory". Once the text becomes part of a directory name, .. could point outside the registry. Rejecting it here means no later code has to think about it.

Example 2: listing repo files with the HubClient

Once the tool knows the repository, it needs to know which files the repository offers and which of them are models. A repository usually holds more than model files: a README, a configuration file, and sometimes extra files that are not language models.

The program restates the read-only part of the HubClient from lesson 26 (Example 15). Its commit() method resolves main to a commit hash (a long identifier of one exact state of the repository), and its files() method reads the recursive tree listing (every file in every folder). Then the program classifies each file and groups the model files by quantization.

Look at the output for the file classification in the middle and at the summary of three groups at the end.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ListRepoFiles {
    record RepoFile(String path, long size, String sha256) {}            // sha256 is null for files stored in git

    /** The read-only part of the HubClient from lesson 23, Example 15. */
    static final class HubClient {
        private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        private final ObjectMapper json = new ObjectMapper();
        private final String endpoint;

        HubClient(String endpoint) { this.endpoint = endpoint; }

        JsonNode getJson(String path) throws Exception {
            HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
            return json.readTree(r.body());
        }

        String commit(String repo, String revision) throws Exception {
            if (revision.matches("[0-9a-f]{40}")) return revision;
            for (JsonNode b : getJson("/api/models/" + repo + "/refs").get("branches"))
                if (b.get("name").asText().equals(revision)) return b.get("targetCommit").asText();
            throw new IllegalArgumentException("no branch " + revision + " in " + repo);
        }

        List<RepoFile> files(String repo, String commit) throws Exception {
            List<RepoFile> out = new ArrayList<>();
            for (JsonNode e : getJson("/api/models/" + repo + "/tree/" + commit + "?recursive=true"))
                if (e.get("type").asText().equals("file"))
                    out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
            return out;
        }
    }

    static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");

    static String quantOf(String path) {
        Matcher m = QUANT.matcher(path.substring(path.lastIndexOf('/') + 1));
        return m.find() ? m.group(1).toUpperCase(Locale.ROOT) : null;
    }

    static final String COMMIT = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
    static final String TREE = """
        [{"type":"file","path":"README.md","size":2210,"oid":"6f2b"},
         {"type":"file","path":"config.json","size":663,"oid":"8a1c"},
         {"type":"file","path":"Coder-7B-Instruct-Q4_K_M.gguf","size":4683071008,"oid":"3d1f","lfs":{"oid":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","size":4683071008}},
         {"type":"file","path":"Coder-7B-Instruct-Q6_K.gguf","size":6254199584,"oid":"7c2e","lfs":{"oid":"1b4f0e9800d5d1f0b8a6c3e4f7a2b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6","size":6254199584}},
         {"type":"file","path":"Coder-7B-Instruct-Q8_0-00001-of-00002.gguf","size":4049262608,"oid":"5e4d","lfs":{"oid":"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae","size":4049262608}},
         {"type":"file","path":"Coder-7B-Instruct-Q8_0-00002-of-00002.gguf","size":4049262608,"oid":"5e4e","lfs":{"oid":"fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9","size":4049262608}},
         {"type":"file","path":"mmproj-Coder-7B-F16.gguf","size":1354163456,"oid":"9a8b","lfs":{"oid":"baa5a0964d3320fbc0c6a922140453c8513ea24ab8fd0577034804a967248096","size":1354163456}}]
        """;

    public static void main(String[] args) throws Exception {
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/api/models/", ex -> {                        // a fake Hub: refs and tree listing only
            String p = ex.getRequestURI().getPath();
            String body = p.endsWith("/refs") ? "{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + COMMIT + "\"}],\"tags\":[]}"
                        : p.contains("/tree/" + COMMIT) ? TREE : null;
            byte[] b = body == null ? "{\"error\":\"not found\"}".getBytes() : body.getBytes();
            ex.sendResponseHeaders(body == null ? 404 : 200, b.length);
            try (var out = ex.getResponseBody()) { out.write(b); }
        });
        hub.start();
        HubClient client = new HubClient("http://127.0.0.1:" + hub.getAddress().getPort());
        String repo = "example-org/Coder-7B-Instruct-GGUF";
        String commit = client.commit(repo, "main");
        List<RepoFile> files = client.files(repo, commit);
        System.out.println("main -> " + commit + ", " + files.size() + " files");

        Map<String, long[]> byQuant = new TreeMap<>();                   // quant -> {files, bytes}
        for (RepoFile f : files) {
            String q = quantOf(f.path());
            boolean model = f.path().endsWith(".gguf") && !f.path().contains("mmproj") && !f.path().contains("imatrix");
            System.out.printf(Locale.ROOT, "  %-44s %,15d  %s%n", f.path(), f.size(), !model ? "(not a model file)" : q);
            if (model && q != null) { long[] g = byQuant.computeIfAbsent(q, k -> new long[2]); g[0]++; g[1] += f.size(); }
        }
        byQuant.forEach((q, g) -> System.out.printf(Locale.ROOT, "%-7s %d file(s) %6.2f GiB%n", q, g[0], g[1] / 1073741824.0));
        hub.stop(0);
    }
}
Output
main -> 4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c, 7 files
  README.md                                              2,210  (not a model file)
  config.json                                              663  (not a model file)
  Coder-7B-Instruct-Q4_K_M.gguf                  4,683,071,008  Q4_K_M
  Coder-7B-Instruct-Q6_K.gguf                    6,254,199,584  Q6_K
  Coder-7B-Instruct-Q8_0-00001-of-00002.gguf     4,049,262,608  Q8_0
  Coder-7B-Instruct-Q8_0-00002-of-00002.gguf     4,049,262,608  Q8_0
  mmproj-Coder-7B-F16.gguf                       1,354,163,456  (not a model file)
Q4_K_M  1 file(s)   4.36 GiB
Q6_K    1 file(s)   5.82 GiB
Q8_0    2 file(s)   7.54 GiB

The fake Hub answers two endpoints. /api/models/{repo}/refs returns one branch, and /api/models/{repo}/tree/{commit} returns the listing stored in TREE. Any other path gets a 404 (not found) answer.

The output, line by line:

  • main -> 4b1c7d2e...: the branch resolved to a commit. Every later request uses the commit, not main. A branch can move when someone pushes to the repository, so using the commit means a push during the pull cannot change which files are downloaded.
  • One line per file. This time files() keeps every file, with sha256 set from lfs.oid for large files stored in LFS (Git Large File Storage, the system the Hub uses for big files) and null for small files stored directly in git. Large files have a SHA-256 checksum in the listing, which stage 5 uses to check the download. The program keeps non-model files too, because loop pull also needs config.json (stage 7). The sizes print with %,15d, a format that adds thousands separators and pads to 15 characters, so the 10-digit sizes line up.
  • README.md and config.json are not model files. mmproj-Coder-7B-F16.gguf is a GGUF file, but it is a vision projector, a helper that a multimodal model (one that also reads images) loads next to the language model. The filter skips names that contain mmproj or imatrix. An importance matrix is a file used while quantizing a model (k-quants, i-quants, and the Importance Matrix). This is the rule llama.cpp applies (The Hugging Face Hub, Example 7).
  • The quant name comes from the regular expression of lesson 26 (Example 4). A regular expression is a text pattern, and this one finds names such as Q4_K_M in a file name. Both Q8_0 parts match it.

The summary groups the model files by quant. Q4_K_M is one file of 4.36 GiB, Q6_K is one file of 5.82 GiB, and Q8_0 is two files that add up to 7.54 GiB. A GiB (gibibyte) is 1,073,741,824 bytes. For Q8_0, 4,049,262,608 bytes times 2 is 8,098,525,216 bytes, and dividing by 1,073,741,824 gives 7.54. The size of the whole group, not the size of one file, is what has to fit on disk and in memory.

Example 3: choosing a quantization that fits a memory budget

When a user runs loop pull org/repo without naming a quant, the tool should pick one that actually runs on the machine. A bigger quant is more accurate, but it needs more memory, and a model that does not fit in memory cannot run at all.

The memory a model needs has three parts:

  1. The weights, which are the model's numbers. Their size is close to the file size.
  2. The KV cache (key-value cache), the memory where the model keeps notes about the text it has already read. It grows with the context length (the number of tokens the model can hold at once).
  3. The compute buffer, scratch memory used during calculation.

The program adds the KV cache and compute buffer of Project 4 (lesson 19, Example 15) to each group's file size. It then picks the largest group whose total fits the budget. The choose method is the rule, and main tries it with five combinations of budget and context length.

Look at the table of totals first, then at how each budget picks the largest row that stays below it.

Java
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;

public class ChooseQuant {
    /** The parts of Project 4's ModelConfig (lesson 16) that the KV cache and compute estimate need. */
    record ModelConfig(long layers, long kvHeads, long headDim, long ffn, long vocab) {
        long kvBytes(long ctx, int bytesPerValue) { return 2 * layers * kvHeads * headDim * bytesPerValue * ctx; }
        long computeBytes() { return (vocab + 2 * ffn) * 512 * 4; }        // Project 4 estimate at -ub 512
    }

    static final double GIB = 1L << 30;

    /** The largest quant whose weights + KV cache + compute fit the budget. */
    static Optional<String> choose(Map<String, Long> quantBytes, ModelConfig cfg, long ctx, long budget) {
        long overhead = cfg.kvBytes(ctx, 2) + cfg.computeBytes();
        return quantBytes.entrySet().stream()
                .filter(e -> e.getValue() + overhead <= budget)
                .max(Comparator.comparingLong(Map.Entry::getValue))
                .map(Map.Entry::getKey);
    }

    public static void main(String[] args) {
        Map<String, Long> sizes = new LinkedHashMap<>();                  // summed per quant, as in Example 2
        sizes.put("Q3_K_M", 3_808_391_200L);
        sizes.put("IQ4_XS", 4_250_000_000L);
        sizes.put("Q4_K_M", 4_683_071_008L);
        sizes.put("Q5_K_M", 5_444_831_264L);
        sizes.put("Q6_K", 6_254_199_584L);
        sizes.put("Q8_0", 8_098_525_216L);
        ModelConfig cfg = new ModelConfig(28, 4, 128, 18_944, 152_064);   // a 7B shape with 4 KV heads

        System.out.printf(Locale.ROOT, "KV cache: %.0f MiB at 8K, %.0f MiB at 32K; compute %.0f MiB%n",
                cfg.kvBytes(8192, 2) / 1048576.0, cfg.kvBytes(32768, 2) / 1048576.0, cfg.computeBytes() / 1048576.0);
        sizes.forEach((q, b) -> System.out.printf(Locale.ROOT, "  %-7s file %5.2f GiB, total at 8K %5.2f GiB, at 32K %5.2f GiB%n", q, b / GIB,
                (b + cfg.kvBytes(8192, 2) + cfg.computeBytes()) / GIB, (b + cfg.kvBytes(32768, 2) + cfg.computeBytes()) / GIB));

        long[][] cases = {{4, 8192}, {6, 8192}, {8, 8192}, {8, 32768}, {12, 32768}};
        for (long[] c : cases) {
            Optional<String> pick = choose(sizes, cfg, c[1], c[0] << 30);
            System.out.printf("budget %2d GiB, ctx %5d -> %s%n", c[0], c[1], pick.orElse("nothing fits; use a smaller context or a smaller model"));
        }
    }
}
Output
KV cache: 448 MiB at 8K, 1792 MiB at 32K; compute 371 MiB
  Q3_K_M  file  3.55 GiB, total at 8K  4.35 GiB, at 32K  5.66 GiB
  IQ4_XS  file  3.96 GiB, total at 8K  4.76 GiB, at 32K  6.07 GiB
  Q4_K_M  file  4.36 GiB, total at 8K  5.16 GiB, at 32K  6.47 GiB
  Q5_K_M  file  5.07 GiB, total at 8K  5.87 GiB, at 32K  7.18 GiB
  Q6_K    file  5.82 GiB, total at 8K  6.62 GiB, at 32K  7.94 GiB
  Q8_0    file  7.54 GiB, total at 8K  8.34 GiB, at 32K  9.65 GiB
budget  4 GiB, ctx  8192 -> nothing fits; use a smaller context or a smaller model
budget  6 GiB, ctx  8192 -> Q5_K_M
budget  8 GiB, ctx  8192 -> Q6_K
budget  8 GiB, ctx 32768 -> Q6_K
budget 12 GiB, ctx 32768 -> Q8_0

The rule has three parts:

  1. Weights are taken as the file size. A GGUF file is its tensors (the arrays of numbers) plus a header of a few megabytes, mostly the tokenizer. The file size therefore slightly overestimates the weights in memory.
  2. KV cache is 2 * layers * kvHeads * headDim * 2 bytes * ctx for an f16 cache. The first 2 is for storing both a key and a value. The second 2 is the bytes of one f16 number (16 bits). For this 7B shape (28 layers, 4 KV heads, head dimension 128), the cost is 2 × 28 × 4 × 128 × 2 = 57,344 bytes per token. At 8,192 tokens that is 448 MiB, and at 32,768 tokens it is 1,792 MiB. These are the numbers on the first output line.
  3. Compute is the estimate of Project 4, (vocab + 2 * ffn) * 512 * 4 bytes. With a vocabulary of 152,064 and an FFN width of 18,944, this is (152,064 + 37,888) × 2,048 = 371 MiB.

The table shows the total for each quant at 8K and at 32K tokens. The quant sizes for Q3_K_M, IQ4_XS, and Q5_K_M are invented to complete the table. The other three are the sizes of stage 2.

To see how a total is built, take Q4_K_M at 8K: 4.36 GiB of weights, plus 448 MiB (0.4375 GiB) of KV cache, plus 371 MiB (0.36 GiB) of compute, gives about 5.16 GiB. That is the number in the table.

The decisions, one per case:

  • 4 GiB at 8K: even Q3_K_M needs 4.35 GiB, so nothing fits. The message names the two ways out: a smaller context or a smaller model.
  • 6 GiB at 8K: Q5_K_M needs 5.87 GiB and fits. Q6_K needs 6.62 GiB and does not.
  • 8 GiB at 8K: Q6_K (6.62 GiB) fits. Q8_0 (8.34 GiB) does not.
  • 8 GiB at 32K: the KV cache grows by 1.31 GiB, and Q6_K still fits at 7.94 GiB, with 0.06 GiB to spare.
  • 12 GiB at 32K: Q8_0 at 9.65 GiB fits.

Two other tools make different choices. llama.cpp's -hf repo without a quant picks Q4_K_M, then Q8_0 (The Hugging Face Hub, Example 7), whatever the machine. "Largest that fits" is loop's rule instead, because, within one model, a larger quant has a lower quantization error, which means it stays closer to the original model (Quantization Fundamentals).

This rule ignores speed. A smaller quant generates tokens faster, because fewer bytes are read from memory for each token (The Roofline Model). A user who prefers speed names the quant explicitly. The budget is also the user's choice: loop does not measure free memory in this workshop, and the --mem option states the budget.

Example 4: split GGUF files

Very large models do not always fit in one file, because some hosting services limit the size of a single file. Repositories therefore often publish a large GGUF model as several parts written by the llama-gguf-split tool (The Hugging Face Hub, Example 7). The tool must handle three questions: how to recognize the parts, how to check that all of them are present, and which one to give to llama.cpp.

llama.cpp names parts <prefix>-<NNNNN>-of-<NNNNN>.gguf. The rule is the function llama_split_path in src/llama.cpp, which uses the format "%s-%05d-of-%05d.gguf". For example, Coder-32B-Q8_0-00002-of-00003.gguf is part 2 of 3 of the model whose prefix is Coder-32B-Q8_0.

The program groups files that share a prefix and a part count. It reports whether each group is complete and which file to load. Look at the output for three cases: a complete split model, an incomplete one, and an ordinary single file.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SplitFiles {
    /** prefix-00002-of-00004.gguf, the naming rule of llama.cpp (llama_split_path and common/download.cpp). */
    static final Pattern SPLIT = Pattern.compile("(?i)^(.+)-(\\d{5})-of-(\\d{5})\\.gguf$");

    record Part(String path, int index, int count) {}
    record ModelFiles(String prefix, int count, List<String> paths) {
        boolean complete() { return paths.size() == count; }
        String first() { return paths.get(0); }                           // llama.cpp must be given the first part
    }

    static List<ModelFiles> group(List<String> paths) {
        Map<String, TreeMap<Integer, Part>> groups = new TreeMap<>();
        Map<String, Integer> counts = new TreeMap<>();
        for (String p : paths) {
            if (!p.endsWith(".gguf")) continue;
            Matcher m = SPLIT.matcher(p);
            Part part = m.matches() ? new Part(p, Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3))) : new Part(p, 1, 1);
            String prefix = m.matches() ? m.group(1) : p.substring(0, p.length() - 5);
            String key = prefix + "#" + part.count();                     // same prefix and same count, as llama.cpp checks
            groups.computeIfAbsent(key, k -> new TreeMap<>()).put(part.index(), part);
            counts.put(key, part.count());
        }
        List<ModelFiles> out = new ArrayList<>();
        groups.forEach((key, parts) -> out.add(new ModelFiles(key.substring(0, key.indexOf('#')), counts.get(key),
                parts.values().stream().map(Part::path).toList())));
        return out;
    }

    public static void main(String[] args) {
        List<String> listing = List.of(
                "README.md",
                "Coder-32B-Q4_K_M.gguf",
                "Q8_0/Coder-32B-Q8_0-00002-of-00003.gguf",                   // listed out of order on purpose
                "Q8_0/Coder-32B-Q8_0-00001-of-00003.gguf",
                "Q8_0/Coder-32B-Q8_0-00003-of-00003.gguf",
                "BF16/Coder-32B-BF16-00001-of-00002.gguf");                 // part 2 missing
        for (ModelFiles mf : group(listing)) {
            System.out.println(mf.prefix() + ": " + mf.paths().size() + " of " + mf.count() + " part(s)"
                    + (mf.complete() ? ", load with " + mf.first() : ", INCOMPLETE, missing " + missing(mf)));
        }
    }

    static TreeSet<Integer> missing(ModelFiles mf) {
        TreeSet<Integer> all = new TreeSet<>();
        for (int i = 1; i <= mf.count(); i++) all.add(i);
        for (String p : mf.paths()) { Matcher m = SPLIT.matcher(p); if (m.matches()) all.remove(Integer.parseInt(m.group(2))); }
        return all;
    }
}
Output
BF16/Coder-32B-BF16: 1 of 2 part(s), INCOMPLETE, missing [2]
Coder-32B-Q4_K_M: 1 of 1 part(s), load with Coder-32B-Q4_K_M.gguf
Q8_0/Coder-32B-Q8_0: 3 of 3 part(s), load with Q8_0/Coder-32B-Q8_0-00001-of-00003.gguf

The program groups files by prefix and part count, the same two values that llama.cpp's downloader compares (get_split_files in common/download.cpp). A TreeMap keyed by the part index puts the parts in order, even though the listing has part 2 before part 1.

The three groups in the output:

  • BF16/Coder-32B-BF16 has 1 of 2 parts. Either the repository is incomplete at this commit, or the listing was cut off. Downloading one part would produce a model that cannot load, so loop pull stops with an error for such a group.
  • Coder-32B-Q4_K_M is an ordinary single file. The program treats it as part 1 of 1, so one piece of code handles both cases.
  • Q8_0/Coder-32B-Q8_0 has all 3 parts. The model is loaded with part 1. llama.cpp reads split.count from that file's metadata and works out the names of the other parts from its path (llama_get_list_splits in src/llama-model-loader.cpp). The loader refuses to start from another part and reports "model must be loaded with the first split".

The last rule limits how the registry of stage 6 can be built. The parts must keep their original names and sit in one directory. Otherwise llama.cpp cannot find parts 2 and 3.

Example 5: download with resume, SHA-256 check, and progress

A model file is several gigabytes, so a download can take many minutes, and a network connection can break during that time. Starting over after every failure would waste time and bandwidth. A second risk is a file that arrives damaged: it looks complete but some bytes are wrong, and the model would then produce nonsense or crash.

Two ideas address these problems. Resuming means asking the server for only the bytes that are still missing, using an HTTP Range header. A checksum is a short fingerprint computed from a file's bytes. SHA-256 is a standard checksum function. If the fingerprint of the downloaded file equals the fingerprint the Hub published, the file is intact.

The program restates a shorter form of the Downloader from lesson 31 (Example 15). It downloads the two Q8_0 parts from a fake Hub that redirects each resolve URL to a CDN path, the way the real Hub does. The connection drops in the middle of the second part, so the resume path runs.

The Downloader class comes first. main then builds two random 300,000-byte parts and their SHA-256 values. It starts a fake Hub with a single handler that redirects resolve URLs and serves CDN paths with Range and If-Range support. Then it pulls both parts into a blobs directory. (If-Range makes the server send the partial range only if the file is still the same version, which the ETag identifies. An ETag is a version label that the server attaches to a file.)

Look at the output for the progress lines, the one failed attempt, and the final count of CDN requests.

Java
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import static java.nio.file.StandardOpenOption.*;

public class PullFiles {
    interface Progress { void update(long done, long total); }

    /** A shorter form of lesson 28's Downloader (Example 15): resume with If-Range, retries, SHA-256, atomic rename. */
    static final class Downloader {
        private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();

        void download(URI uri, Path target, long size, String sha256, Progress progress) throws Exception {
            if (Files.exists(target)) return;
            Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
            for (int attempt = 1; ; attempt++) {
                try { fetch(uri, part, etag, size, progress); break; }
                catch (IOException e) {
                    if (attempt == 5) throw e;
                    System.out.println("    attempt " + attempt + " failed: " + e.getMessage() + "; resuming");
                }                                                      // a real client waits with backoff here
            }
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
            if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch"); }
            Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
            Files.deleteIfExists(etag);
        }

        private void fetch(URI uri, Path part, Path etag, long size, Progress progress) throws Exception {
            long have = Files.exists(part) ? Files.size(part) : 0;
            HttpRequest.Builder b = HttpRequest.newBuilder(uri);
            if (have > 0) b.header("Range", "bytes=" + have + "-");
            if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
            HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
            if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
            if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
            if (r.statusCode() == 200) have = 0;
            if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
            try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
                byte[] buf = new byte[64 * 1024];
                for (int n; (n = in.read(buf)) >= 0; ) { out.write(buf, 0, n); have += n; progress.update(have, size); }
            }
            if (have != size) throw new IOException("incomplete");
        }
    }

    static String sha256(byte[] b) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b)); }

    public static void main(String[] args) throws Exception {
        Map<String, byte[]> blobs = new LinkedHashMap<>();                 // sha256 -> content, as the CDN stores it
        Map<String, String> paths = new LinkedHashMap<>();                 // file path in the repo -> sha256
        for (int i = 1; i <= 2; i++) {
            byte[] data = new byte[300_000];
            new Random(i).nextBytes(data);
            String sha = sha256(data);
            blobs.put(sha, data);
            paths.put("Coder-7B-Instruct-Q8_0-0000" + i + "-of-00002.gguf", sha);
        }
        AtomicInteger cdnRequests = new AtomicInteger();
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/", ex -> {
            String p = ex.getRequestURI().getPath();
            if (p.contains("/resolve/")) {                                 // the Hub redirects to the CDN, named by hash
                ex.getResponseHeaders().add("Location", "/cdn/" + paths.get(p.substring(p.lastIndexOf('/') + 1)));
                ex.sendResponseHeaders(302, -1);
                ex.close();
                return;
            }
            int n = cdnRequests.incrementAndGet();
            String sha = p.substring(5);
            byte[] data = blobs.get(sha);
            String range = ex.getRequestHeaders().getFirst("Range"), ifRange = ex.getRequestHeaders().getFirst("If-Range");
            int start = range != null && ("\"" + sha + "\"").equals(ifRange) ? Integer.parseInt(range.substring(6, range.length() - 1)) : 0;
            ex.getResponseHeaders().add("ETag", "\"" + sha + "\"");
            if (start > 0) ex.getResponseHeaders().add("Content-Range", "bytes " + start + "-" + (data.length - 1) + "/" + data.length);
            ex.sendResponseHeaders(start > 0 ? 206 : 200, data.length - start);
            OutputStream out = ex.getResponseBody();
            if (n == 2) { out.write(data, 0, 120_000); out.flush(); throw new RuntimeException("drop"); }  // second file: drop at 40%
            out.write(data, start, data.length - start);
            out.close();
        });
        hub.start();
        String base = "http://127.0.0.1:" + hub.getAddress().getPort() + "/example-org/Coder-7B-Instruct-GGUF/resolve/4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c/";
        Path blobDir = Files.createDirectories(Files.createTempDirectory("loop-home").resolve("blobs"));
        Downloader downloader = new Downloader();
        for (var e : paths.entrySet()) {
            System.out.println("pulling " + e.getKey());
            int[] last = {0};
            downloader.download(URI.create(base + e.getKey()), blobDir.resolve("sha256-" + e.getValue()), 300_000, e.getValue(), (done, total) -> {
                int q = (int) (done * 4 / total);
                if (q > last[0]) { last[0] = q; System.out.println("    " + q * 25 + "%"); }
            });
        }
        try (var s = Files.list(blobDir)) { s.map(p -> p.getFileName().toString().substring(0, 23) + "...").sorted().forEach(System.out::println); }
        System.out.println("CDN requests: " + cdnRequests.get());
        hub.stop(0);
    }
}
Output
pulling Coder-7B-Instruct-Q8_0-00001-of-00002.gguf
    25%
    50%
    75%
    100%
pulling Coder-7B-Instruct-Q8_0-00002-of-00002.gguf
    25%
    attempt 1 failed: closed; resuming
    50%
    75%
    100%
sha256-bc221fc49293bd81...
sha256-f26cc1572c98172e...
CDN requests: 3

The download path for each file:

  1. GET /{repo}/resolve/{commit}/{file} returns 302 (a redirect) with Location: /cdn/<sha256>. The client follows it (Redirect.NORMAL).
  2. The CDN path answers with an ETag equal to the quoted SHA-256 and supports Range with If-Range.
  3. The bytes go to blobs/sha256-<hex>.part. After a complete transfer, the program hashes the file, compares the result with the lfs.oid from the tree listing, and renames it to blobs/sha256-<hex>. The rename is atomic, which means other programs see either no file or the complete file, never half of it.

The output, in order:

  • The first part downloads in one request and reports 25%, 50%, 75%, and 100%.
  • The second part reaches 25%. The server sends 40% of the bytes and then drops the connection. The line attempt 1 failed: closed; resuming is the IOException from the broken stream.
  • The second attempt sends Range: bytes=120000- (start at byte 120,000, which is 40% of 300,000) and If-Range with the saved ETag. The server answers 206 (partial content) and the download continues: 50%, 75%, 100%. The 25% line is not repeated, because the callback prints each quarter only once.
  • The blob directory holds two files named by their hashes, printed with the first 16 hex digits. No .part or .etag file is left, because both are removed after a successful download.
  • CDN requests: 3: one for the first part and two for the second.

A blob is a stored file whose name is its content hash. Naming blobs by their SHA-256 has two consequences. First, a file is downloaded once even if two models list it, because the name tells whether the content is already present. Second, a blob name is also its checksum, so loop can verify any blob later by hashing it and comparing the result with its name.

This version does not wait between attempts. Lesson 31 showed backoff with jitter (waiting longer after each failure, by a slightly random amount). The full program in stage 12 waits a short time.

Example 6: the registry, manifest.json and a shared blob store

Downloaded blobs have names such as sha256-9f86d081..., which neither a person nor llama.cpp can use directly. The tool needs a way to map a readable model name to its files without copying multi-gigabyte blobs. Copying would also waste disk space when two models share files.

The solution has two directories. blobs/ holds content named by hash. models/<dir>/ holds a manifest.json (a small file that describes the model) and one symbolic link per file. A symbolic link is a file that only points to another file. Each link has the original file name, and it points to the blob that holds the content.

The program writes two blobs, registers two models that use them, and then prints the directory tree, one manifest, and a read through a link. Look at the tree to see that the two models share the same two blobs.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Stream;

public class Registry {
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}

    static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);

    /** home/blobs/sha256-<hex> holds content; home/models/<dir>/ holds manifest.json and one link per file. */
    static Path register(Path home, String dir, Manifest m) throws Exception {
        Path modelDir = Files.createDirectories(home.resolve("models").resolve(dir));
        for (FileEntry f : m.files()) {
            Path blob = home.resolve("blobs").resolve("sha256-" + f.sha256());
            if (!Files.exists(blob)) throw new IllegalStateException("missing blob for " + f.path());
            Path link = modelDir.resolve(Path.of(f.path()).getFileName().toString());   // original name: llama.cpp finds the other parts
            Files.deleteIfExists(link);
            Files.createSymbolicLink(link, modelDir.relativize(blob));
        }
        Path tmp = modelDir.resolve("manifest.json.tmp");
        JSON.writeValue(tmp.toFile(), m);
        Files.move(tmp, modelDir.resolve("manifest.json"), java.nio.file.StandardCopyOption.ATOMIC_MOVE);
        return modelDir;
    }

    static void tree(Path home) throws Exception {
        try (Stream<Path> s = Files.walk(home)) {
            for (Path p : s.sorted().toList()) {
                if (p.equals(home)) continue;
                String rel = home.relativize(p).toString().replaceAll("[0-9a-f]{56}", "...");
                String extra = Files.isSymbolicLink(p) ? " -> " + Files.readSymbolicLink(p).toString().replaceAll("[0-9a-f]{56}", "...")
                             : Files.isRegularFile(p) ? " (" + Files.size(p) + " bytes)" : "/";
                System.out.println("  " + rel + extra);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Path home = Files.createTempDirectory("loop-home");
        Files.createDirectories(home.resolve("blobs"));
        List<FileEntry> files = new java.util.ArrayList<>();
        for (int i = 1; i <= 2; i++) {                                      // the two blobs Example 5 downloaded
            byte[] data = ("part " + i).getBytes();
            String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
            Files.write(home.resolve("blobs/sha256-" + sha), data);
            files.add(new FileEntry("Coder-7B-Instruct-Q8_0-0000" + i + "-of-00002.gguf", data.length, sha));
        }
        String commit = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
        register(home, "example-org--Coder-7B-Instruct-GGUF--Q8_0", new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0",
                "example-org/Coder-7B-Instruct-GGUF", "main", commit, "Q8_0", files, "2026-10-03T12:00:00Z"));
        register(home, "example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0", new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0@v1.0",
                "example-org/Coder-7B-Instruct-GGUF", "v1.0", commit, "Q8_0", files, "2026-10-03T12:05:00Z"));
        tree(home);
        System.out.println(Files.readString(home.resolve("models/example-org--Coder-7B-Instruct-GGUF--Q8_0/manifest.json")));
        Path first = home.resolve("models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf");
        System.out.println("read through the link: " + Files.readString(first) + ", same file as the blob: "
                + Files.isSameFile(first, home.resolve("blobs/sha256-" + files.get(0).sha256())));
    }
}
Output
  blobs/
  blobs/sha256-...68decc4a (6 bytes)
  blobs/sha256-...7303a822 (6 bytes)
  models/
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0/
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf -> ../../blobs/sha256-...7303a822
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/Coder-7B-Instruct-Q8_0-00002-of-00002.gguf -> ../../blobs/sha256-...68decc4a
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/manifest.json (593 bytes)
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf -> ../../blobs/sha256-...7303a822
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00002-of-00002.gguf -> ../../blobs/sha256-...68decc4a
  models/example-org--Coder-7B-Instruct-GGUF--Q8_0/manifest.json (588 bytes)
{
  "name" : "example-org/Coder-7B-Instruct-GGUF:Q8_0",
  "repo" : "example-org/Coder-7B-Instruct-GGUF",
  "revision" : "main",
  "commit" : "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c",
  "quant" : "Q8_0",
  "files" : [ {
    "path" : "Coder-7B-Instruct-Q8_0-00001-of-00002.gguf",
    "size" : 6,
    "sha256" : "e21dcf13079a712a6ee683e2e6718de2bba2b8c11bc326e0c8fff2eb7303a822"
  }, {
    "path" : "Coder-7B-Instruct-Q8_0-00002-of-00002.gguf",
    "size" : 6,
    "sha256" : "427755e659b3584580205091b5c2c9ef91423a054ff05bfb50709fec68decc4a"
  } ],
  "pulledAt" : "2026-10-03T12:00:00Z"
}
read through the link: part 1, same file as the blob: true

The tree, with the 64-digit hashes shortened to their last 8 digits:

  • blobs/ holds two blobs. They are 6 bytes each here, standing in for the two Q8_0 parts.
  • models/ holds two models: ...--Q8_0 (revision main) and ...--Q8_0--v1.0 (a tag on the same commit). Each has a manifest and two links. The links of both models point to the same two blobs, so the second model costs no disk space beyond its manifest.
  • Each link is relative (../../blobs/sha256-...), as in the Hub cache of lesson 26. A relative link does not contain the full path of the target, so the whole ~/.loop directory can be moved or backed up without breaking links.
  • The link names are the original file names. This means llama-server -m models/.../Coder-7B-Instruct-Q8_0-00001-of-00002.gguf finds part 2 next to it (stage 4).

The manifest records what was pulled. Each field has a purpose:

Field Meaning
name Display name from stage 1, the key users type
repo, revision, commit Where the files came from; the commit makes the pull reproducible
quant The chosen quantization
files Path in the repository, size, and SHA-256 of each part, in part order
pulledAt When the pull finished (a fixed value here so that the output is stable; the real command uses the current time)

register writes the manifest last, and it does so through a temporary file plus an atomic rename (lesson 31, Example 12). The order matters. A model directory without manifest.json is therefore an unfinished pull, and loop list ignores it. The manifest is the signal that everything else in the directory is complete.

The last line of the output reads a file through its link. It confirms with Files.isSameFile that the link and the blob are the same file, so reading through the link gives the blob's content (part 1).

On Windows, creating symbolic links needs administrator rights or developer mode. A port of loop to Windows would store the blob path in the manifest and pass it to the engine directly, or use hard links, which work within one volume. A hard link is a second name for the same file data.

Example 7: the loop pull command end to end

Stages 1 to 6 are separate pieces. This stage joins them into one picocli command, so that a user can type loop pull some/model and get the whole sequence: parse, list, choose, check, download, and register.

LoopPull is the loop command with one subcommand, pull, and inherited --home and --hub options. An inherited option is defined once on the parent command and works on every subcommand (Subcommands, Configuration, and Packaging, Example 2). --home is the directory of the registry, and --hub is the address of the Hub, which the demo points at the fake Hub. The demo runs six pulls against a fake Hub that serves a tiny model in three quants.

The program is about 260 lines. Comments of the form // ---- ... ---- divide it into five parts, in this order:

  1. Options and model references. The two inherited options --home and --hub, the ModelRef record of stage 1 (now with the URL prefixes inside its pattern), and the FileEntry and Manifest records of stage 6.
  2. The Hub client and the downloader. Hub.commit and Hub.files are the HubClient methods of stage 2. Here commit also searches tags, so a user can pull a tagged version. Hub.download and Hub.fetch are the downloader of stage 5, with a short wait between attempts.
  3. Memory math and patterns. kvBytes and computeBytes are the Project 4 formulas of stage 3. QUANT is the quant pattern of stage 2, and SPLIT is the split-part pattern of stage 4. ByteSize is a shortened size converter from lesson 29 that turns text such as 3.5MiB into a number of bytes for the --mem option.
  4. The pull command. Pull.call() runs the steps that are listed after the output.
  5. The demo. fakeHub builds a tree listing, a config.json, and four random-byte "GGUF" files with their SHA-256 values, and it serves the refs, tree, resolve, and CDN paths. main runs six pulls in one temporary home directory and then prints the files it holds. With command-line arguments, main runs the real command instead.

When you read the program, start with Pull.call(). The other parts are the earlier stages in a compact form.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.ScopeType;
import static java.nio.file.StandardOpenOption.*;

/** Project 9: loop pull against a fake Hub. */
@Command(name = "loop", mixinStandardHelpOptions = true, version = "loop 0.1.0",
        subcommands = {LoopPull.Pull.class})
public class LoopPull {
    @Option(names = "--home", scope = ScopeType.INHERIT, defaultValue = "${sys:loop.home:-${sys:user.home}/.loop}") Path home;
    @Option(names = "--hub", scope = ScopeType.INHERIT, defaultValue = "${HF_ENDPOINT:-https://huggingface.co}") String hubUrl;

    // ---- model references (Example 1) and the registry (Example 6) ----
    record ModelRef(String repo, String quant, String revision) {
        static final Pattern FORMAT = Pattern.compile("(?:https://huggingface\\.co/|hf\\.co/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
        static ModelRef parse(String text) {
            Matcher m = FORMAT.matcher(text.strip());
            if (!m.matches() || m.group(1).contains("..")) throw new IllegalArgumentException("not a model reference: " + text);
            return new ModelRef(m.group(1), m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT), m.group(3) == null ? "main" : m.group(3));
        }
        String name(String q) { return repo + ":" + q + (revision.equals("main") ? "" : "@" + revision); }
        String dir(String q) { return repo.replace("/", "--") + "--" + q + (revision.equals("main") ? "" : "--" + revision.replace('/', '_')); }
    }
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
        long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
    }
    static final ObjectMapper JSON = new ObjectMapper();

    // ---- the Hub client (lesson 23) and the downloader (lesson 28) ----
    record RepoFile(String path, long size, String sha256) {}
    static final class Hub {
        final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        final String endpoint;
        Hub(String endpoint) { this.endpoint = endpoint; }
        String get(String path) throws Exception {
            HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
            return r.body();
        }
        String commit(String repo, String rev) throws Exception {
            if (rev.matches("[0-9a-f]{40}")) return rev;
            JsonNode refs = JSON.readTree(get("/api/models/" + repo + "/refs"));
            for (String kind : List.of("branches", "tags"))
                for (JsonNode b : refs.path(kind)) if (b.get("name").asText().equals(rev)) return b.get("targetCommit").asText();
            throw new IllegalArgumentException("no branch or tag " + rev);
        }
        List<RepoFile> files(String repo, String commit) throws Exception {
            List<RepoFile> out = new ArrayList<>();
            for (JsonNode e : JSON.readTree(get("/api/models/" + repo + "/tree/" + commit + "?recursive=true")))
                if (e.get("type").asText().equals("file"))
                    out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
            return out;
        }
        URI resolve(String repo, String commit, String path) { return URI.create(endpoint + "/" + repo + "/resolve/" + commit + "/" + path); }

        void download(URI uri, Path target, long size, String sha256) throws Exception {
            Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
            for (int attempt = 1; ; attempt++) {
                try { fetch(uri, part, etag, size); break; }
                catch (IOException e) { if (attempt == 5) throw e; Thread.sleep(Math.min(8000, 500L << attempt) / 100); }
            }
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
            if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch: " + target.getFileName()); }
            Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
            Files.deleteIfExists(etag);
        }
        void fetch(URI uri, Path part, Path etag, long size) throws Exception {
            long have = Files.exists(part) ? Files.size(part) : 0;
            HttpRequest.Builder b = HttpRequest.newBuilder(uri);
            if (have > 0) b.header("Range", "bytes=" + have + "-");
            if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
            HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
            if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
            if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
            if (r.statusCode() == 200) have = 0;
            if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
            try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
                have += in.transferTo(out);
            }
            if (have != size) throw new IOException("incomplete");
        }
    }

    // ---- memory math (Project 4) ----
    static long kvBytes(long layers, long kvHeads, long headDim, long ctx) { return 2 * layers * kvHeads * headDim * 2 * ctx; }
    static long computeBytes(long vocab, long ffn) { return (vocab + 2 * ffn) * 512 * 4; }
    static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");
    static String mib(long bytes) { return String.format(Locale.ROOT, "%.2f MiB", bytes / 1048576.0); }
    static final Pattern SPLIT = Pattern.compile("-(\\d{5})-of-(\\d{5})\\.gguf$");

    /** The size converter of lesson 26 (Example 11), shortened: 512MiB, 3.5MiB, 8GiB, 8GB. */
    static class ByteSize implements CommandLine.ITypeConverter<Long> {
        public Long convert(String v) {
            Matcher m = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*([KMGT])?(i)?B?").matcher(v.strip());
            if (!m.matches()) throw new CommandLine.TypeConversionException("'" + v + "' is not a size such as 8GiB");
            int exp = m.group(2) == null ? 0 : "KMGT".indexOf(Character.toUpperCase(m.group(2).charAt(0))) + 1;
            double unit = Math.pow(m.group(3) == null && exp > 0 ? 1000 : 1024, exp);
            return (long) (Double.parseDouble(m.group(1)) * unit);
        }
    }

    // ---- commands ----
    @Command(name = "pull", description = "Download a GGUF model from Hugging Face.")
    static class Pull implements Callable<Integer> {
        @ParentCommand LoopPull loop;
        @Parameters(paramLabel = "MODEL", description = "repo[:quant][@revision]") String model;
        @Option(names = "--mem", converter = ByteSize.class, description = "Memory budget; picks the largest quant that fits") Long mem;
        @Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;

        public Integer call() throws Exception {
            ModelRef ref = ModelRef.parse(model);
            Hub hub = new Hub(loop.hubUrl);
            String commit = hub.commit(ref.repo(), ref.revision());
            List<RepoFile> files = hub.files(ref.repo(), commit);
            Map<String, List<RepoFile>> byQuant = new TreeMap<>();
            for (RepoFile f : files) {
                Matcher m = QUANT.matcher(f.path().substring(f.path().lastIndexOf('/') + 1));
                if (f.path().endsWith(".gguf") && !f.path().contains("mmproj") && f.sha256() != null && m.find())
                    byQuant.computeIfAbsent(m.group(1).toUpperCase(Locale.ROOT), k -> new ArrayList<>()).add(f);
            }
            byQuant.values().forEach(l -> l.sort(Comparator.comparing(RepoFile::path)));     // split parts in order
            String quant = ref.quant();
            if (quant == null) {
                long overhead = 0;
                if (files.stream().anyMatch(f -> f.path().equals("config.json"))) {
                    JsonNode c = JSON.readTree(hub.get("/" + ref.repo() + "/resolve/" + commit + "/config.json"));
                    long heads = c.get("num_attention_heads").asLong();
                    overhead = kvBytes(c.get("num_hidden_layers").asLong(), c.get("num_key_value_heads").asLong(), c.get("hidden_size").asLong() / heads, ctx)
                            + computeBytes(c.get("vocab_size").asLong(), c.get("intermediate_size").asLong());
                }
                long need = overhead, budget = mem == null ? Long.MAX_VALUE : mem;   // no budget: the largest quant
                Map.Entry<String, Long> best = byQuant.entrySet().stream()
                        .map(e -> Map.entry(e.getKey(), e.getValue().stream().mapToLong(RepoFile::size).sum() + need))
                        .filter(e -> e.getValue() <= budget).max(Map.Entry.comparingByValue())
                        .orElseThrow(() -> new IllegalStateException("no quant fits in " + mib(budget) + " at -c " + ctx));
                quant = best.getKey();
                System.out.println("chose " + quant + ": needs " + mib(best.getValue()) + " (KV cache and compute " + mib(overhead) + ")");
            }
            List<RepoFile> parts = byQuant.get(quant);
            if (parts == null) throw new IllegalArgumentException("no " + quant + " files; available: " + byQuant.keySet());
            Matcher sp = SPLIT.matcher(parts.get(0).path());
            if (sp.find() && Integer.parseInt(sp.group(2)) != parts.size())
                throw new IllegalStateException(quant + " has " + parts.size() + " of " + Integer.parseInt(sp.group(2)) + " parts");
            Path dir = loop.home.resolve("models").resolve(ref.dir(quant));
            if (Files.exists(dir.resolve("manifest.json"))) { System.out.println(ref.name(quant) + " is already pulled"); return 0; }
            Path blobs = Files.createDirectories(loop.home.resolve("blobs"));
            List<FileEntry> entries = new ArrayList<>();
            for (RepoFile f : parts) {
                Path blob = blobs.resolve("sha256-" + f.sha256());
                boolean have = Files.exists(blob);
                if (!have) hub.download(hub.resolve(ref.repo(), commit, f.path()), blob, f.size(), f.sha256());
                System.out.println((have ? "  have     " : "  pulled   ") + f.path() + " (" + mib(f.size()) + ")");
                entries.add(new FileEntry(f.path(), f.size(), f.sha256()));
            }
            Files.createDirectories(dir);
            for (FileEntry e : entries) {
                Path link = dir.resolve(Path.of(e.path()).getFileName().toString());
                if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.createSymbolicLink(link, dir.relativize(blobs.resolve("sha256-" + e.sha256())));
            }
            Manifest m = new Manifest(ref.name(quant), ref.repo(), ref.revision(), commit, quant, entries, "2026-10-03T12:00:00Z");
            JSON.writeValue(dir.resolve("manifest.json.tmp").toFile(), m);
            Files.move(dir.resolve("manifest.json.tmp"), dir.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE);
            System.out.println("pulled " + m.name());
            return 0;
        }
    }

    // ---- demo: a fake Hub with a tiny model in three quants (random bytes stand in for GGUF files) ----
    static HttpServer fakeHub(String repo, String commit) throws Exception {
        Map<String, byte[]> files = new LinkedHashMap<>();
        files.put("config.json", "{\"num_hidden_layers\":2,\"hidden_size\":128,\"num_attention_heads\":4,\"num_key_value_heads\":2,\"intermediate_size\":256,\"vocab_size\":64}".getBytes());
        Random random = new Random(9);
        for (String[] f : new String[][] {{"Tiny-Coder-F16.gguf", "608064"}, {"Tiny-Coder-Q4_0.gguf", "171840"},
                {"Tiny-Coder-Q8_0-00001-of-00002.gguf", "168160"}, {"Tiny-Coder-Q8_0-00002-of-00002.gguf", "155744"}}) {
            byte[] b = new byte[Integer.parseInt(f[1])];
            random.nextBytes(b);
            files.put(f[0], b);
        }
        StringJoiner tree = new StringJoiner(",", "[", "]");
        Map<String, byte[]> cdn = new HashMap<>();
        Map<String, String> shaOf = new HashMap<>();
        for (var e : files.entrySet()) {
            String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(e.getValue()));
            boolean lfs = e.getKey().endsWith(".gguf");
            if (lfs) { cdn.put(sha, e.getValue()); shaOf.put(e.getKey(), sha); }
            tree.add("{\"type\":\"file\",\"path\":\"" + e.getKey() + "\",\"size\":" + e.getValue().length
                    + (lfs ? ",\"lfs\":{\"oid\":\"" + sha + "\",\"size\":" + e.getValue().length + "}" : "") + "}");
        }
        HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        s.createContext("/", ex -> {
            String p = ex.getRequestURI().getPath();
            byte[] body;
            if (p.equals("/api/models/" + repo + "/refs")) body = ("{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + commit + "\"}],"
                    + "\"tags\":[{\"name\":\"v1.0\",\"targetCommit\":\"" + commit + "\"}]}").getBytes();
            else if (p.equals("/api/models/" + repo + "/tree/" + commit)) body = tree.toString().getBytes();
            else if (p.startsWith("/" + repo + "/resolve/" + commit + "/")) {
                String name = p.substring(p.lastIndexOf('/') + 1);
                if (shaOf.containsKey(name)) {                                  // LFS files: redirect to the CDN
                    ex.getResponseHeaders().add("Location", "/cdn/" + shaOf.get(name)); ex.sendResponseHeaders(302, -1); ex.close(); return;
                }
                body = files.get(name);
            } else body = p.startsWith("/cdn/") ? cdn.get(p.substring(5)) : null;
            if (body == null) { ex.sendResponseHeaders(404, -1); ex.close(); return; }
            ex.sendResponseHeaders(200, body.length);
            try (var out = ex.getResponseBody()) { out.write(body); }
        });
        s.start();
        return s;
    }

    public static void main(String[] args) throws Exception {
        if (args.length > 0) { System.exit(new CommandLine(new LoopPull()).execute(args)); }
        HttpServer hub = fakeHub("example-org/Tiny-Coder-GGUF", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c");
        String[] common = {"--home", Files.createTempDirectory("loop-home").toString(), "--hub", "http://127.0.0.1:" + hub.getAddress().getPort()};
        String[][] demo = {{"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3.5MiB"}, {"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3MiB"},
                {"pull", "example-org/Tiny-Coder-GGUF:Q8_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q8_0@v1.0"},
                {"pull", "hf.co/example-org/Tiny-Coder-GGUF:q4_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q5_K_M"}};
        for (String[] d : demo) {
            CommandLine cl = new CommandLine(new LoopPull());
            cl.setErr(new java.io.PrintWriter(System.out, true));
            cl.setExecutionExceptionHandler((ex, c, r) -> { System.out.println("loop " + c.getCommandName() + ": " + ex.getMessage()); return 1; });
            System.out.println("$ loop " + String.join(" ", d));
            int code = cl.execute(Stream.concat(Arrays.stream(d), Arrays.stream(common)).toArray(String[]::new));
            if (code != 0) System.out.println("(exit code " + code + ")");
        }
        try (Stream<Path> files = Files.walk(Path.of(common[1]))) {
            files.map(p -> Path.of(common[1]).relativize(p).toString().replaceAll("[0-9a-f]{56}", "...")).filter(p -> !p.isEmpty()).sorted().forEach(System.out::println);
        }
        hub.stop(0);
    }
}
Output
$ loop pull example-org/Tiny-Coder-GGUF --mem 3.5MiB
chose Q8_0: needs 3.43 MiB (KV cache and compute 3.13 MiB)
  pulled   Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
  pulled   Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0
$ loop pull example-org/Tiny-Coder-GGUF --mem 3MiB
loop pull: no quant fits in 3.00 MiB at -c 4096
(exit code 1)
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 is already pulled
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0@v1.0
  have     Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
  have     Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0@v1.0
$ loop pull hf.co/example-org/Tiny-Coder-GGUF:q4_0
  pulled   Tiny-Coder-Q4_0.gguf (0.16 MiB)
pulled example-org/Tiny-Coder-GGUF:Q4_0
$ loop pull example-org/Tiny-Coder-GGUF:Q5_K_M
loop pull: no Q5_K_M files; available: [F16, Q4_0, Q8_0]
(exit code 1)
blobs
blobs/sha256-...421f5e71
blobs/sha256-...789d472a
blobs/sha256-...efad3d7c
models
models/example-org--Tiny-Coder-GGUF--Q4_0
models/example-org--Tiny-Coder-GGUF--Q4_0/Tiny-Coder-Q4_0.gguf
models/example-org--Tiny-Coder-GGUF--Q4_0/manifest.json
models/example-org--Tiny-Coder-GGUF--Q8_0
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/Tiny-Coder-Q8_0-00001-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/Tiny-Coder-Q8_0-00002-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/manifest.json
models/example-org--Tiny-Coder-GGUF--Q8_0/Tiny-Coder-Q8_0-00001-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0/Tiny-Coder-Q8_0-00002-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0/manifest.json

Pull.call() runs these steps:

  1. Parse the reference (stage 1) and resolve the revision to a commit. commit() now also looks at tags, so @v1.0 works.
  2. List the files and group the model files by quant (stage 2). The parts of each group are sorted by path, which puts split parts in order.
  3. If no quant was named, read config.json from the repository through its resolve URL. Compute the KV cache and compute buffer for --ctx-size (default 4096), and choose the largest group that fits --mem (stage 3). Without --mem, the largest group is chosen.
  4. Reject a missing quant or an incomplete split group (stage 4).
  5. Skip the work if the model's manifest already exists. Otherwise download each part that is not yet in blobs/ (stage 5), create the links, and write the manifest (stage 6).

The tiny model's config.json gives 2 layers, 2 KV heads, head dimension 32, FFN width 256, and a vocabulary of 64 tokens. At -c 4096 its KV cache is 2 MiB and its compute estimate is 1.13 MiB, together 3.13 MiB. That is large compared with the model's files (0.16 to 0.58 MiB), but the arithmetic is the same as for a real model.

As a check, the KV cache is 2 × 2 layers × 2 KV heads × 32 × 2 bytes × 4,096 tokens = 2,097,152 bytes, which is exactly 2 MiB. The compute buffer is (64 + 512) × 2,048 = 1,179,648 bytes, which is 1.13 MiB.

The six runs, one per $ loop pull command in the output:

  • --mem 3.5MiB chooses Q8_0. Its two parts (0.31 MiB) plus 3.13 MiB is 3.43 MiB, which fits. F16 would need 3.71 MiB, which does not fit. Both parts are downloaded, and the model is registered as example-org/Tiny-Coder-GGUF:Q8_0.
  • --mem 3MiB fails. Even Q4_0 needs 3.29 MiB. The exit code is 1 and nothing is written.
  • :Q8_0 again: the manifest exists, so the pull ends at once with is already pulled. Repeating a pull is cheap, which matters for scripts that run loop pull before every start.
  • :Q8_0@v1.0: this is a new name, because the revision differs. The tag points to the same commit, though, so both blobs are already present (have) and nothing is downloaded.
  • hf.co/...:q4_0: the URL prefix and the lower-case quant are normalized, and Q4_0 is downloaded.
  • :Q5_K_M: the repository has no such files, and the error lists the quants it does have.

The listing at the end of the output shows the result on disk. There are three blobs (two Q8_0 parts and one Q4_0 file) shared by three model directories. The size values in the fake Hub's tree come from the byte arrays it serves, so the size checks of the downloader pass.

Example 8: loop list, a table from manifests

After a few pulls, the user wants to see what is on disk. loop list answers by reading every models/*/manifest.json, sorting the models by name, and printing them. It can print a fixed-width table for people or JSON lines for scripts, as in lesson 30 (Example 8).

The program writes three manifests and one directory without a manifest (an interrupted pull). Then it runs list twice, once as a table and once with --json. Look at which models appear in the table and which one does not.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(name = "list", aliases = "ls", description = "List downloaded models.")
public class ListModels implements Runnable {
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
        long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
    }

    record Row(String name, String quant, long sizeBytes, int files, String pulledAt) {}    // the --json shape

    static final ObjectMapper JSON = new ObjectMapper();

    static List<Manifest> manifests(Path home) throws Exception {
        List<Manifest> out = new ArrayList<>();
        Path models = home.resolve("models");
        if (!Files.isDirectory(models)) return out;
        try (Stream<Path> dirs = Files.list(models)) {
            for (Path d : dirs.toList()) {
                Path f = d.resolve("manifest.json");
                if (Files.exists(f)) out.add(JSON.readValue(f.toFile(), Manifest.class));   // a dir without manifest is an unfinished pull
            }
        }
        out.sort(Comparator.comparing(Manifest::name));
        return out;
    }

    static String human(long bytes) {
        return bytes >= 1L << 30 ? String.format(Locale.ROOT, "%.2f GiB", bytes / (double) (1L << 30))
                                  : String.format(Locale.ROOT, "%.1f MiB", bytes / (double) (1L << 20));
    }

    @Option(names = "--home", required = true) Path home;
    @Option(names = "--json", description = "Print JSON for scripts") boolean json;

    @Override
    public void run() {
        try {
            List<Manifest> all = manifests(home);
            if (json) {
                for (Manifest m : all)                                        // one JSON object per line
                    System.out.println(JSON.writeValueAsString(new Row(m.name(), m.quant(), m.size(), m.files().size(), m.pulledAt())));
                return;
            }
            int width = Math.max(4, all.stream().mapToInt(m -> m.name().length()).max().orElse(0));
            String format = "%-" + width + "s  %-7s %10s  %5s  %s%n";
            System.out.printf(format, "NAME", "QUANT", "SIZE", "FILES", "PULLED");
            for (Manifest m : all)
                System.out.printf(format, m.name(), m.quant(), human(m.size()), m.files().size(), m.pulledAt().substring(0, 10));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        Path home = Files.createTempDirectory("loop-home");
        String c = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
        Manifest[] ms = {
            new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0", "example-org/Coder-7B-Instruct-GGUF", "main", c, "Q8_0",
                    List.of(new FileEntry("Coder-7B-Instruct-Q8_0-00001-of-00002.gguf", 4_049_262_608L, "2c26"),
                            new FileEntry("Coder-7B-Instruct-Q8_0-00002-of-00002.gguf", 4_049_262_608L, "fcde")), "2026-10-02T09:30:00Z"),
            new Manifest("example-org/Coder-7B-Instruct-GGUF:Q4_K_M", "example-org/Coder-7B-Instruct-GGUF", "main", c, "Q4_K_M",
                    List.of(new FileEntry("Coder-7B-Instruct-Q4_K_M.gguf", 4_683_071_008L, "9f86")), "2026-10-03T12:00:00Z"),
            new Manifest("example-org/Tiny-Coder-GGUF:Q8_0", "example-org/Tiny-Coder-GGUF", "main", c, "Q8_0",
                    List.of(new FileEntry("Tiny-Coder-Q8_0.gguf", 115_200, "77aa")), "2026-10-03T12:05:00Z")};
        for (Manifest m : ms) {
            Path d = Files.createDirectories(home.resolve("models").resolve(m.name().replace("/", "--").replace(":", "--")));
            JSON.writeValue(d.resolve("manifest.json").toFile(), m);
        }
        Files.createDirectories(home.resolve("models/example-org--Unfinished-GGUF--Q4_0"));   // a pull that was interrupted

        new CommandLine(new ListModels()).execute("--home", home.toString());
        new CommandLine(new ListModels()).execute("--home", home.toString(), "--json");
    }
}
Output
NAME                                       QUANT         SIZE  FILES  PULLED
example-org/Coder-7B-Instruct-GGUF:Q4_K_M  Q4_K_M    4.36 GiB      1  2026-10-03
example-org/Coder-7B-Instruct-GGUF:Q8_0    Q8_0      7.54 GiB      2  2026-10-02
example-org/Tiny-Coder-GGUF:Q8_0           Q8_0       0.1 MiB      1  2026-10-03
{"name":"example-org/Coder-7B-Instruct-GGUF:Q4_K_M","quant":"Q4_K_M","sizeBytes":4683071008,"files":1,"pulledAt":"2026-10-03T12:00:00Z"}
{"name":"example-org/Coder-7B-Instruct-GGUF:Q8_0","quant":"Q8_0","sizeBytes":8098525216,"files":2,"pulledAt":"2026-10-02T09:30:00Z"}
{"name":"example-org/Tiny-Coder-GGUF:Q8_0","quant":"Q8_0","sizeBytes":115200,"files":1,"pulledAt":"2026-10-03T12:05:00Z"}

The program writes three manifests and one directory without a manifest, then runs list twice.

The table has five columns:

  • The NAME column is as wide as the longest name (41 characters here). The width is computed from the data instead of being fixed. Long Hub names would otherwise push the other columns out of line.
  • SIZE is the sum of the parts. The Q8_0 model's two parts of 4,049,262,608 bytes each are 7.54 GiB together. The tiny model is 0.1 MiB.
  • FILES shows 2 for the split model.
  • PULLED shows the date part of pulledAt.
  • example-org--Unfinished-GGUF--Q4_0 has no manifest, so it does not appear. It stands for a pull that was interrupted before the last step of stage 6.

The JSON form prints one object per line, a format often called JSON Lines. The size is in bytes. A script can use it in two ways. loop list --json | jq -r .name gives the names one per line (jq is a command-line tool for JSON). A script can also add up sizeBytes without having to parse text such as GiB. The Row record decides the field names and their order, which Jackson (the Java JSON library) keeps. A Map.of would not keep a stable order.

Example 9: loop show, GGUF metadata through the Project 7 reader

A user often wants to know what a downloaded model is: how many layers it has, how long a context it supports, and which quantization types its tensors use. All of this is stored in the GGUF file itself, in a header at the start of the file.

loop show opens the model's first file through its link and reads the GGUF header with the reader of Project 7 (The GGUF Format, Example 17). This stage restates that reader and adds a small writer, so the program can create a test file to read.

The program has four parts:

  1. The records and the TYPES table come first. The table maps a ggml type id (the number that GGUF uses for a number format) to its name, block size, and bytes per block. bytes uses it to compute a tensor's size.
  2. writeGguf and the first str method write a test file: header, metadata, tensor infos, padding to 32 bytes, and a zero-filled data section.
  3. value, the second str, and readGguf are the Project 7 reader.
  4. show prints the report, and main builds the tiny model's metadata and tensors, registers the file as stage 6 does, and calls show.

Look at the output for the model facts from the header and for the list of tensor types at the bottom.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;

public class ShowModel {
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}
    record TensorInfo(String name, long[] dims, int type, long offset) {}
    record Gguf(int version, Map<String, Object> kv, List<TensorInfo> tensors) {}

    // ggml type id -> {name, block size, type size}, from GGML_QUANT_SIZES in gguf-py (lesson 25)
    static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
            2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});

    static long bytes(TensorInfo t) { long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); Object[] ty = TYPES.get(t.type()); return n / (Integer) ty[1] * (Integer) ty[2]; }

    /** Writes a GGUF v3 file: metadata (UINT32, FLOAT32, STRING, string arrays), tensor infos, and zero-filled data. */
    static byte[] writeGguf(Map<String, Object> kv, List<TensorInfo> tensors) {
        ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
        b.put("GGUF".getBytes(StandardCharsets.US_ASCII)).putInt(3).putLong(tensors.size()).putLong(kv.size());
        for (var e : kv.entrySet()) {
            str(b, e.getKey());
            switch (e.getValue()) {
                case Integer i -> b.putInt(4).putInt(i);
                case Float f -> b.putInt(6).putFloat(f);
                case String s -> { b.putInt(8); str(b, s); }
                case List<?> l -> { b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> str(b, (String) x)); }
                default -> throw new IllegalArgumentException(e.getKey());
            }
        }
        long offset = 0;
        for (TensorInfo t : tensors) {
            str(b, t.name()); b.putInt(t.dims().length);
            for (long d : t.dims()) b.putLong(d);
            b.putInt(t.type()).putLong(offset);
            offset += (bytes(t) + 31) / 32 * 32;
        }
        while (b.position() % 32 != 0) b.put((byte) 0);
        byte[] out = Arrays.copyOf(b.array(), b.position() + (int) offset);   // data section: zeros
        return out;
    }

    static void str(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }
    static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }

    static Object value(ByteBuffer b, int type) {                       // the reader of Project 7 (lesson 25)
        return switch (type) {
            case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
            case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
            case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>();
                        for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
            default -> throw new IllegalArgumentException("bad value type " + type);
        };
    }

    static Gguf readGguf(Path file) throws Exception {
        try (FileChannel ch = FileChannel.open(file)) {
            ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
            if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file: " + file.getFileName());
            int version = b.getInt();
            long nTensors = b.getLong(), nKv = b.getLong();
            Map<String, Object> kv = new LinkedHashMap<>();
            for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
            List<TensorInfo> tensors = new ArrayList<>();
            for (long i = 0; i < nTensors; i++) {
                String name = str(b); long[] dims = new long[b.getInt()];
                for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
                tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
            }
            return new Gguf(version, kv, tensors);
        }
    }

    static void show(Path home, String dir) throws Exception {
        Path modelDir = home.resolve("models").resolve(dir);
        Manifest m = new ObjectMapper().readValue(modelDir.resolve("manifest.json").toFile(), Manifest.class);
        Gguf g = readGguf(modelDir.resolve(m.files().get(0).path()));
        String arch = (String) g.kv().get("general.architecture");
        System.out.println(m.name() + "  (commit " + m.commit().substring(0, 7) + ", pulled " + m.pulledAt() + ")");
        System.out.println("  file      " + m.files().get(0).path() + ", GGUF v" + g.version());
        System.out.println("  arch      " + arch + ", name " + g.kv().get("general.name"));
        for (String k : List.of("context_length", "embedding_length", "block_count", "feed_forward_length", "attention.head_count", "attention.head_count_kv"))
            System.out.printf("  %-24s %s%n", k, g.kv().get(arch + "." + k));
        System.out.println("  vocabulary " + ((List<?>) g.kv().get("tokenizer.ggml.tokens")).size() + " tokens");
        Map<String, long[]> mix = new TreeMap<>();
        long params = 0;
        for (TensorInfo t : g.tensors()) {
            long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b);
            long[] x = mix.computeIfAbsent((String) TYPES.get(t.type())[0], k -> new long[3]);
            x[0]++; x[1] += n; x[2] += bytes(t); params += n;
        }
        System.out.printf(Locale.ROOT, "  parameters %,d%n", params);
        mix.forEach((ty, x) -> System.out.printf(Locale.ROOT, "  %-5s %d tensors, %,d params, %,d bytes%n", ty, x[0], x[1], x[2]));
    }

    public static void main(String[] args) throws Exception {
        Map<String, Object> kv = new LinkedHashMap<>();                  // a tiny Llama-architecture model
        kv.put("general.architecture", "llama"); kv.put("general.name", "Tiny-Coder"); kv.put("general.file_type", 7);
        kv.put("llama.context_length", 2048); kv.put("llama.embedding_length", 128); kv.put("llama.block_count", 2);
        kv.put("llama.feed_forward_length", 256); kv.put("llama.attention.head_count", 4); kv.put("llama.attention.head_count_kv", 2);
        List<String> tokens = new ArrayList<>();
        for (int i = 0; i < 64; i++) tokens.add("t" + i);
        kv.put("tokenizer.ggml.tokens", tokens);
        List<TensorInfo> tensors = new ArrayList<>(List.of(new TensorInfo("token_embd.weight", new long[] {128, 64}, 8, 0)));
        for (int l = 0; l < 2; l++)
            for (String n : List.of("attn_q", "attn_k", "attn_v", "attn_output"))
                tensors.add(new TensorInfo("blk." + l + "." + n + ".weight", new long[] {128, n.equals("attn_k") || n.equals("attn_v") ? 64 : 128}, 8, 0));
        tensors.add(new TensorInfo("output_norm.weight", new long[] {128}, 0, 0));
        byte[] file = writeGguf(kv, tensors);

        Path home = Files.createTempDirectory("loop-home");                // register it as Example 6 does
        String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(file));
        Path blob = Files.write(Files.createDirectories(home.resolve("blobs")).resolve("sha256-" + sha), file);
        Path dir = Files.createDirectories(home.resolve("models/example-org--Tiny-Coder-GGUF--Q8_0"));
        Files.createSymbolicLink(dir.resolve("Tiny-Coder-Q8_0.gguf"), dir.relativize(blob));
        new ObjectMapper().writeValue(dir.resolve("manifest.json").toFile(), new Manifest("example-org/Tiny-Coder-GGUF:Q8_0",
                "example-org/Tiny-Coder-GGUF", "main", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c", "Q8_0",
                List.of(new FileEntry("Tiny-Coder-Q8_0.gguf", file.length, sha)), "2026-10-03T12:00:00Z"));
        show(home, "example-org--Tiny-Coder-GGUF--Q8_0");
    }
}
Output
example-org/Tiny-Coder-GGUF:Q8_0  (commit 4b1c7d2, pulled 2026-10-03T12:00:00Z)
  file      Tiny-Coder-Q8_0.gguf, GGUF v3
  arch      llama, name Tiny-Coder
  context_length           2048
  embedding_length         128
  block_count              2
  feed_forward_length      256
  attention.head_count     4
  attention.head_count_kv  2
  vocabulary 64 tokens
  parameters 106,624
  F32   1 tensors, 128 params, 512 bytes
  Q8_0  9 tensors, 106,496 params, 113,152 bytes

writeGguf produces a GGUF v3 file with ten metadata keys and ten tensors. The tensors are the token embedding (128 × 64), the four attention projections of each of the 2 layers, and an F32 norm. The data section is zeros. The file was checked with the GGUFReader of gguf-py (the Python package of the GGUF project), which reads the same 10 tensors with the same types, sizes, and offsets. The program registers the file as in stage 6 and calls show.

The output, line by line:

  • The first line comes from the manifest: the name, the short commit, and the time of the pull.
  • GGUF v3 and the architecture come from the header. All model keys are prefixed with the architecture, llama., as lesson 28 (Example 10) explained.
  • The six hyperparameters are the settings that fit needs (stage 10), and they are also the facts a user checks first: context length 2048, width 128, 2 layers, FFN width 256, 4 query heads, and 2 KV heads.
  • vocabulary 64 tokens is the length of the list tokenizer.ggml.tokens.
  • The parameter count, 106,624, is the sum of the tensor element counts: 8,192 for the embedding, 2 × (16,384 + 8,192 + 8,192 + 16,384) for the attention weights, and 128 for the norm.
  • The quant mix groups tensors by type. There is one F32 tensor of 512 bytes (128 values × 4 bytes) and nine Q8_0 tensors. Each Q8_0 block holds 32 values in 34 bytes, so 106,496 values take 106,496 / 32 × 34 = 113,152 bytes.

show reads only the header, through a memory-mapped file. Memory mapping lets the program treat a file as if it were an array in memory, while the operating system loads only the pages (small chunks of the file) that the reader touches. This makes show fast even for a 40 GB model.

There is one limit to know. A single MappedByteBuffer covers at most 2 GiB (Integer.MAX_VALUE bytes), and FileChannel.map throws IllegalArgumentException for a larger size. The reader therefore maps Math.min(ch.size(), Integer.MAX_VALUE) bytes, as Project 7 does. The header of any model fits in that range, because even a large vocabulary takes tens of megabytes.

Example 10: loop fit, memory for a context length and slot count

Before starting a model, a user wants to know whether it will fit in memory, and for a GPU, how many of its layers can be placed there. A slot is one independent conversation that the server handles at the same time. Several slots share the context that the server is configured with.

loop fit reads the GGUF header for the hyperparameters and the tensor sizes. It applies Project 4 (lesson 19, Example 15), including llama.cpp's rule for slots. It answers the question before the engine starts, so a user finds out about a problem before waiting for a model to load.

The writer and reader at the top of the program are the ones of Example 9. The writer has one new parameter, withData, which leaves out the data section, because fit needs only the header. The new code is run(). It reads the hyperparameters and adds up tensor bytes in three groups: the repeating blocks, output.weight, and everything else. Then it computes the KV cache and the compute buffer, and it searches for the largest -ngl that fits --gpu-mib. The -ngl option of llama.cpp sets how many layers are placed on the GPU.

main writes a header for the 7B shape of stage 3 and runs fit three times. Look at how the KV cache and the number of GPU layers change between the runs.

Java
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "fit", description = "Estimate the memory a model needs.")
public class FitModel implements Runnable {
    record TensorInfo(String name, long[] dims, int type, long offset) {}
    record Gguf(int version, Map<String, Object> kv, List<TensorInfo> tensors) {}

    // ggml type id -> {name, block size, type size}, from GGML_QUANT_SIZES in gguf-py (lesson 25)
    static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
            2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});

    static long bytes(TensorInfo t) { long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); Object[] ty = TYPES.get(t.type()); return n / (Integer) ty[1] * (Integer) ty[2]; }

    /** Writes a GGUF v3 file (Example 9); withData = false leaves out the data section. */
    static byte[] writeGguf(Map<String, Object> kv, List<TensorInfo> tensors, boolean withData) {
        ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
        b.put("GGUF".getBytes(StandardCharsets.US_ASCII)).putInt(3).putLong(tensors.size()).putLong(kv.size());
        for (var e : kv.entrySet()) {
            str(b, e.getKey());
            switch (e.getValue()) {
                case Integer i -> b.putInt(4).putInt(i);
                case Float f -> b.putInt(6).putFloat(f);
                case String s -> { b.putInt(8); str(b, s); }
                case List<?> l -> { b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> str(b, (String) x)); }
                default -> throw new IllegalArgumentException(e.getKey());
            }
        }
        long offset = 0;
        for (TensorInfo t : tensors) {
            str(b, t.name()); b.putInt(t.dims().length);
            for (long d : t.dims()) b.putLong(d);
            b.putInt(t.type()).putLong(offset);
            offset += (bytes(t) + 31) / 32 * 32;
        }
        while (b.position() % 32 != 0) b.put((byte) 0);
        return Arrays.copyOf(b.array(), b.position() + (withData ? (int) offset : 0));   // data: zeros, or none
    }

    static void str(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }
    static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }

    static Object value(ByteBuffer b, int type) {                       // the reader of Project 7 (lesson 25)
        return switch (type) {
            case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
            case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
            case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>();
                        for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
            default -> throw new IllegalArgumentException("bad value type " + type);
        };
    }

    static Gguf readGguf(Path file) throws Exception {
        try (FileChannel ch = FileChannel.open(file)) {
            ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
            if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file: " + file.getFileName());
            int version = b.getInt();
            long nTensors = b.getLong(), nKv = b.getLong();
            Map<String, Object> kv = new LinkedHashMap<>();
            for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
            List<TensorInfo> tensors = new ArrayList<>();
            for (long i = 0; i < nTensors; i++) {
                String name = str(b); long[] dims = new long[b.getInt()];
                for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
                tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
            }
            return new Gguf(version, kv, tensors);
        }
    }

    static final double MIB = 1 << 20;
    static long pad256(long x) { return (x + 255) / 256 * 256; }
    static long num(Map<String, Object> kv, String key) { return ((Number) kv.get(key)).longValue(); }

    @Parameters(paramLabel = "GGUF") Path file;
    @Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
    @Option(names = {"-np", "--parallel"}, defaultValue = "1") int slots;
    @Option(names = "--gpu-mib", defaultValue = "0", description = "GPU memory budget in MiB (0: CPU only)") long gpuMiB;

    @Override
    public void run() {
        try {
            Gguf g = readGguf(file);
            String a = (String) g.kv().get("general.architecture");
            long layers = num(g.kv(), a + ".block_count"), heads = num(g.kv(), a + ".attention.head_count");
            long kvHeads = num(g.kv(), a + ".attention.head_count_kv"), headDim = num(g.kv(), a + ".embedding_length") / heads;
            long ffn = num(g.kv(), a + ".feed_forward_length"), vocab = num(g.kv(), a + ".vocab_size");
            double layerW = 0, output = 0, other = 0;
            for (TensorInfo t : g.tensors()) {
                if (t.name().startsWith("blk.")) layerW += bytes(t) / MIB;
                else if (t.name().equals("output.weight")) output += bytes(t) / MIB;
                else other += bytes(t) / MIB;                              // token_embd and output_norm stay on the CPU
            }
            layerW /= layers;
            long perSlot = pad256(pad256(ctx) / slots), cells = perSlot * slots;   // llama.cpp's split KV cache (lesson 16)
            double kvLayer = 2.0 * kvHeads * headDim * 2 * cells / MIB;            // K and V at f16
            double compute = (vocab + 2 * ffn) * 512 * 4 / MIB;                     // Project 4 estimate at -ub 512
            double weights = layers * layerW + output + other, kv = layers * kvLayer;
            System.out.printf(Locale.ROOT, "%s, -c %d -np %d: %d cells, %d per slot%n", file.getFileName(), ctx, slots, cells, perSlot);
            System.out.printf(Locale.ROOT, "  weights %8.0f MiB%n  KV cache %7.0f MiB%n  compute %8.0f MiB%n  total %10.0f MiB%n",
                    weights, kv, compute, weights + kv + compute);
            if (gpuMiB > 0) {
                int ngl = 0;
                for (int n = 1; n <= layers + 1; n++) if (compute + output + (n - 1) * (layerW + kvLayer) <= gpuMiB) ngl = n;
                System.out.println("  GPU " + gpuMiB + " MiB -> -ngl " + ngl + (ngl == layers + 1 ? " (all layers)" : " (" + Math.max(0, ngl - 1) + " of " + layers + " blocks)"));
            }
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        long d = 3584, kvDim = 512, ffn = 18_944, vocab = 152_064;              // the 7B shape of Example 3, 28 layers
        Map<String, Object> kv = new LinkedHashMap<>();
        kv.put("general.architecture", "llama"); kv.put("general.file_type", 15);
        kv.put("llama.block_count", 28); kv.put("llama.embedding_length", (int) d); kv.put("llama.feed_forward_length", (int) ffn);
        kv.put("llama.attention.head_count", 28); kv.put("llama.attention.head_count_kv", 4); kv.put("llama.vocab_size", (int) vocab);
        List<TensorInfo> t = new ArrayList<>();
        int q4k = 12, q6k = 14, f32 = 0;
        t.add(new TensorInfo("token_embd.weight", new long[] {d, vocab}, q4k, 0));
        for (int l = 0; l < 28; l++) {
            String p = "blk." + l + ".";
            t.add(new TensorInfo(p + "attn_q.weight", new long[] {d, d}, q4k, 0));
            t.add(new TensorInfo(p + "attn_k.weight", new long[] {d, kvDim}, q4k, 0));
            t.add(new TensorInfo(p + "attn_v.weight", new long[] {d, kvDim}, q6k, 0));
            t.add(new TensorInfo(p + "attn_output.weight", new long[] {d, d}, q4k, 0));
            t.add(new TensorInfo(p + "ffn_gate.weight", new long[] {d, ffn}, q4k, 0));
            t.add(new TensorInfo(p + "ffn_up.weight", new long[] {d, ffn}, q4k, 0));
            t.add(new TensorInfo(p + "ffn_down.weight", new long[] {ffn, d}, l % 2 == 0 ? q6k : q4k, 0));
            t.add(new TensorInfo(p + "attn_norm.weight", new long[] {d}, f32, 0));
            t.add(new TensorInfo(p + "ffn_norm.weight", new long[] {d}, f32, 0));
        }
        t.add(new TensorInfo("output_norm.weight", new long[] {d}, f32, 0));
        t.add(new TensorInfo("output.weight", new long[] {d, vocab}, q6k, 0));
        Path file = Files.write(Files.createTempDirectory("fit").resolve("Coder-7B-Q4_K_M.gguf"), writeGguf(kv, t, false));
        System.out.println("header-only test file: " + Files.size(file) + " bytes, " + t.size() + " tensor infos");

        new CommandLine(new FitModel()).execute(file.toString(), "-c", "8192", "--gpu-mib", "6144");
        new CommandLine(new FitModel()).execute(file.toString(), "-c", "32768", "--gpu-mib", "6144");
        new CommandLine(new FitModel()).execute(file.toString(), "-c", "32768", "-np", "4", "--gpu-mib", "8192");
    }
}
Output
header-only test file: 15488 bytes, 255 tensor infos
Coder-7B-Q4_K_M.gguf, -c 8192 -np 1: 8192 cells, 8192 per slot
  weights     4466 MiB
  KV cache     448 MiB
  compute      371 MiB
  total       5285 MiB
  GPU 6144 MiB -> -ngl 29 (all layers)
Coder-7B-Q4_K_M.gguf, -c 32768 -np 1: 32768 cells, 32768 per slot
  weights     4466 MiB
  KV cache    1792 MiB
  compute      371 MiB
  total       6629 MiB
  GPU 6144 MiB -> -ngl 28 (27 of 28 blocks)
Coder-7B-Q4_K_M.gguf, -c 32768 -np 4: 32768 cells, 8192 per slot
  weights     4466 MiB
  KV cache    1792 MiB
  compute      371 MiB
  total       6629 MiB
  GPU 8192 MiB -> -ngl 29 (all layers)

The test file is a GGUF header for the 7B shape of stage 3, with 255 tensor infos and no data section, 15,488 bytes in all. fit never reads tensor data, so a header is enough. The tensor types follow a Q4_K_M-like mix, with Q6_K for attn_v, for every second ffn_down, and for output. The vocabulary size comes from the llama.vocab_size key (gguf-py's {arch}.vocab_size). This saves writing 152,064 token strings into the test file.

What fit computes:

  • Weights: the byte sizes of all tensors from their types and dimensions (lesson 28, Example 12), which gives 4,466 MiB. That is close to the 4.36 GiB Q4_K_M file of stage 2, as expected for this mix.
  • KV cache: llama.cpp pads the context to a multiple of 256 and, with a split cache, gives each slot pad256(ctx / slots) cells (lesson 19, Example 7). A cell holds the notes for one token. Each layer stores K and V for every cell at 2 bytes per value.
  • Compute: the estimate of Project 4, 371 MiB.
  • GPU layers: the largest -ngl for which the compute buffer, the output layer, and n - 1 blocks with their KV cache fit the GPU budget. The output layer is offloaded first, as llama.cpp does (lesson 19, Example 10).

The three runs:

  • -c 8192 on a 6 GiB GPU: the KV cache is 448 MiB and the total is 5,285 MiB (4,466 + 448 + 371). Every layer fits, so -ngl 29 means all 28 blocks plus the output layer.
  • -c 32768: the KV cache grows to 1,792 MiB and the total to 6,629 MiB. The 6 GiB GPU has 6,144 MiB, so 27 of the 28 blocks fit, and one block stays on the CPU.
  • -c 32768 -np 4: four slots of 8,192 tokens share the same 32,768 cells, so the memory is unchanged. This is the point of lesson 19 that slots divide the context rather than add to it. On an 8 GiB GPU everything fits.

fit answers the question before the engine starts. Memory Math compared the same arithmetic with real llama.cpp logs. Section 5 passes the -ngl value to llama-server when loop serve starts it.

Example 11: loop rm, confirmation and blob reference counting

Removing a model looks easy: delete its files. In this registry it is not, because two models can share the same blob. In Example 6, the main model and the v1.0 model use the same two blobs. If rm deleted the blobs of one model, the other model would point to files that no longer exist.

The solution is reference counting: count how many models still use each blob, and delete a blob only when no remaining manifest lists its hash. This stage also adds a confirmation question, because deleting gigabytes of data cannot be undone.

The referenced method collects the hashes used by every manifest except the one being removed. The call method asks for confirmation, deletes the manifest and links, and then deletes only the blobs that are not in that set. main builds three models, two of which share blobs, and removes them in four steps. It replaces System.in to simulate the user's typed answers.

Look at which blobs are kept and deleted at each step.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "rm", description = "Remove a downloaded model.")
public class RemoveModel implements Callable<Integer> {
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}

    static final ObjectMapper JSON = new ObjectMapper();

    @Option(names = "--home", required = true) Path home;
    @Option(names = {"-y", "--yes"}, description = "Do not ask for confirmation") boolean yes;
    @Parameters(paramLabel = "DIR") String dir;

    /** sha256 values referenced by every manifest except the one in skip. */
    static Set<String> referenced(Path home, Path skip) throws Exception {
        Set<String> refs = new HashSet<>();
        try (Stream<Path> dirs = Files.list(home.resolve("models"))) {
            for (Path d : dirs.toList()) {
                if (d.equals(skip) || !Files.exists(d.resolve("manifest.json"))) continue;
                JSON.readValue(d.resolve("manifest.json").toFile(), Manifest.class).files().forEach(f -> refs.add(f.sha256()));
            }
        }
        return refs;
    }

    @Override
    public Integer call() throws Exception {
        Path modelDir = home.resolve("models").resolve(dir);
        if (!Files.exists(modelDir.resolve("manifest.json"))) { System.out.println("loop rm: no model " + dir); return 3; }
        Manifest m = JSON.readValue(modelDir.resolve("manifest.json").toFile(), Manifest.class);
        if (!yes) {
            System.out.print("Remove " + m.name() + "? [y/N] ");
            String answer = new BufferedReader(new InputStreamReader(System.in)).readLine();
            System.out.println(answer);                                          // echo, as a terminal would show it
            if (answer == null || !answer.strip().equalsIgnoreCase("y")) { System.out.println("kept"); return 1; }
        }
        Set<String> stillUsed = referenced(home, modelDir);
        Files.delete(modelDir.resolve("manifest.json"));                         // first: the model disappears from list
        try (Stream<Path> s = Files.walk(modelDir)) {
            for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);   // links, then the directory
        }
        long freed = 0;
        for (FileEntry f : m.files()) {
            Path blob = home.resolve("blobs/sha256-" + f.sha256());
            if (stillUsed.contains(f.sha256())) { System.out.println("  kept blob " + f.sha256().substring(0, 8) + " (used by another model)"); continue; }
            freed += Files.size(blob);
            Files.delete(blob);
            System.out.println("  deleted blob " + f.sha256().substring(0, 8));
        }
        System.out.println("removed " + m.name() + ", freed " + freed + " bytes");
        return 0;
    }

    static void add(Path home, String dir, String name, String... shas) throws Exception {
        Path d = Files.createDirectories(home.resolve("models").resolve(dir));
        List<FileEntry> files = new java.util.ArrayList<>();
        for (String sha : shas) {
            Path blob = home.resolve("blobs/sha256-" + sha);
            if (!Files.exists(blob)) Files.write(blob, new byte[1000]);
            files.add(new FileEntry(sha + ".gguf", 1000, sha));
            Files.createSymbolicLink(d.resolve(sha + ".gguf"), d.relativize(blob));
        }
        JSON.writeValue(d.resolve("manifest.json").toFile(), new Manifest(name, "example-org/Coder", "main", "4b1c7d2", "Q8_0", files, "2026-10-03T12:00:00Z"));
    }

    static void run(Path home, String input, String... args) {
        System.setIn(new ByteArrayInputStream(input.getBytes()));
        String[] all = Stream.concat(Stream.of("--home", home.toString()), Stream.of(args)).toArray(String[]::new);
        System.out.println("exit code " + new CommandLine(new RemoveModel()).execute(all));
    }

    public static void main(String[] args) throws Exception {
        Path home = Files.createTempDirectory("loop-home");
        Files.createDirectories(home.resolve("blobs"));
        add(home, "coder--Q8_0", "example-org/Coder:Q8_0", "aaaa1111", "bbbb2222");           // two parts
        add(home, "coder--Q8_0--v1.0", "example-org/Coder:Q8_0@v1.0", "aaaa1111", "bbbb2222");  // same content, other name
        add(home, "coder--Q4_0", "example-org/Coder:Q4_0", "cccc3333");

        run(home, "n\n", "coder--Q4_0");
        run(home, "y\n", "coder--Q8_0");
        run(home, "", "coder--Q8_0--v1.0", "--yes");
        run(home, "", "coder--Q8_0", "--yes");
        try (Stream<Path> s = Files.list(home.resolve("blobs"))) { System.out.println("blobs left: " + s.map(p -> p.getFileName().toString()).sorted().toList()); }
    }
}
Output
Remove example-org/Coder:Q4_0? [y/N] n
kept
exit code 1
Remove example-org/Coder:Q8_0? [y/N] y
  kept blob aaaa1111 (used by another model)
  kept blob bbbb2222 (used by another model)
removed example-org/Coder:Q8_0, freed 0 bytes
exit code 0
  deleted blob aaaa1111
  deleted blob bbbb2222
removed example-org/Coder:Q8_0@v1.0, freed 2000 bytes
exit code 0
loop rm: no model coder--Q8_0
exit code 3
blobs left: [sha256-cccc3333]

The registry starts with three models. coder--Q8_0 and coder--Q8_0--v1.0 both use blobs aaaa1111 and bbbb2222, and coder--Q4_0 uses cccc3333. The program simulates the user's answers by replacing System.in, and it echoes the answer as a terminal would.

The four calls:

  • rm coder--Q4_0, answer n: nothing changes. The command prints kept and returns 1. The default answer is no ([y/N], where the capital letter marks the default), so pressing Enter keeps the model.
  • rm coder--Q8_0, answer y: the manifest and links are deleted, but both blobs are kept because coder--Q8_0--v1.0 still lists them. Zero bytes are freed.
  • rm coder--Q8_0--v1.0 --yes: no prompt. Now no manifest references the two blobs, so both are deleted and 2,000 bytes are freed.
  • rm coder--Q8_0 --yes again: the model no longer exists, so the command prints an error and returns 3.

The last line shows that only sha256-cccc3333 is left, which belongs to the Q4_0 model that was never removed.

The order of the steps matters when the program stops unexpectedly. rm follows this order:

  1. Collect the hashes that the other models use.
  2. Delete the manifest. From this moment the model is gone for list.
  3. Delete the links and the directory.
  4. Delete the blobs that no other model uses.

If the process stops in the middle, the worst case is a blob that no manifest uses. A later rm, or a cleanup command that deletes every blob no manifest lists, removes it. No model is ever left pointing to a missing blob.

The full program in stage 12 asks for confirmation only when System.console() exists, which means a person is typing at a terminal. Otherwise it requires --yes, so a script never waits for an answer that cannot come.

Example 12: the full Project 9 and 10 program, and a real run

This stage puts everything together. The full program, LoopModels, combines the stages into one picocli application with five subcommands: pull, list (alias ls), show, fit, and rm.

Run without arguments, it starts a fake Hub that serves the tiny model as real GGUF files in three quants, and it runs a scripted session of twelve commands. Run with arguments, it is the real command line and talks to --hub, which defaults to https://huggingface.co or to the HF_ENDPOINT environment variable if that is set.

The program is about 420 lines. It reuses the parts of Example 7 and adds the other commands and a GGUF reader. In reading order:

  1. Options, model references, and the registry. --home, --hub, ModelRef, FileEntry, and Manifest as in Example 7. There are also three helpers that every command after pull uses. modelDir maps a display name to its directory and rejects a name without a quant. manifest reads a model's manifest.json. modelDirs lists the directories that have a manifest.
  2. The Hub client and the downloader. Unchanged from Example 7.
  3. GGUF reading and memory math. The type table, elements, bytes, and a readGguf that reads the metadata and tensor infos of one file into a shared map and list. Calling it for every part of a split model therefore collects all tensors (the reader of stages 9 and 10). The Project 4 formulas, the patterns, and ByteSize follow.
  4. The commands. Pull is the class of Example 7. ListCmd prints the table or JSON lines of stage 8. Show prints the metadata and quant mix of stage 9. Fit computes the memory and -ngl of stage 10. Rm deletes with reference counting as in stage 11, and it prompts only on a terminal.
  5. The demo. tinyGguf writes a real GGUF file for the tiny model in a given type, either whole or as one part of a split. fakeHub serves config.json and four such files, as in Example 7. main runs twelve commands against it in one temporary home directory.

Because the program is long, read the output first, and then use the list above to find the code that produced each part.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.ScopeType;
import static java.nio.file.StandardOpenOption.*;

/** Projects 9 and 10: loop pull, list, show, fit, and rm. Without arguments, runs a demo against a fake Hub. */
@Command(name = "loop", mixinStandardHelpOptions = true, version = "loop 0.1.0",
        subcommands = {LoopModels.Pull.class, LoopModels.ListCmd.class, LoopModels.Show.class, LoopModels.Fit.class, LoopModels.Rm.class})
public class LoopModels {
    @Option(names = "--home", scope = ScopeType.INHERIT, defaultValue = "${sys:loop.home:-${sys:user.home}/.loop}") Path home;
    @Option(names = "--hub", scope = ScopeType.INHERIT, defaultValue = "${HF_ENDPOINT:-https://huggingface.co}") String hubUrl;

    // ---- model references (Example 1) and the registry (Example 6) ----
    record ModelRef(String repo, String quant, String revision) {
        static final Pattern FORMAT = Pattern.compile("(?:https://huggingface\\.co/|hf\\.co/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
        static ModelRef parse(String text) {
            Matcher m = FORMAT.matcher(text.strip());
            if (!m.matches() || m.group(1).contains("..")) throw new IllegalArgumentException("not a model reference: " + text);
            return new ModelRef(m.group(1), m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT), m.group(3) == null ? "main" : m.group(3));
        }
        String name(String q) { return repo + ":" + q + (revision.equals("main") ? "" : "@" + revision); }
        String dir(String q) { return repo.replace("/", "--") + "--" + q + (revision.equals("main") ? "" : "--" + revision.replace('/', '_')); }
    }
    record FileEntry(String path, long size, String sha256) {}
    record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
        long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
    }
    static final ObjectMapper JSON = new ObjectMapper();

    Path modelDir(String model) {
        ModelRef r = ModelRef.parse(model);
        if (r.quant() == null) throw new IllegalArgumentException("name the quant, for example " + r.repo() + ":Q4_K_M");
        return home.resolve("models").resolve(r.dir(r.quant()));
    }
    Manifest manifest(Path dir) throws IOException {
        if (!Files.exists(dir.resolve("manifest.json"))) throw new IllegalArgumentException("no such model: " + dir.getFileName());
        return JSON.readValue(dir.resolve("manifest.json").toFile(), Manifest.class);
    }
    List<Path> modelDirs() throws IOException {
        if (!Files.isDirectory(home.resolve("models"))) return List.of();
        try (Stream<Path> s = Files.list(home.resolve("models"))) { return s.filter(d -> Files.exists(d.resolve("manifest.json"))).sorted().toList(); }
    }

    // ---- the Hub client (lesson 23) and the downloader (lesson 28) ----
    record RepoFile(String path, long size, String sha256) {}
    static final class Hub {
        final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        final String endpoint;
        Hub(String endpoint) { this.endpoint = endpoint; }
        String get(String path) throws Exception {
            HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
            return r.body();
        }
        String commit(String repo, String rev) throws Exception {
            if (rev.matches("[0-9a-f]{40}")) return rev;
            JsonNode refs = JSON.readTree(get("/api/models/" + repo + "/refs"));
            for (String kind : List.of("branches", "tags"))
                for (JsonNode b : refs.path(kind)) if (b.get("name").asText().equals(rev)) return b.get("targetCommit").asText();
            throw new IllegalArgumentException("no branch or tag " + rev);
        }
        List<RepoFile> files(String repo, String commit) throws Exception {
            List<RepoFile> out = new ArrayList<>();
            for (JsonNode e : JSON.readTree(get("/api/models/" + repo + "/tree/" + commit + "?recursive=true")))
                if (e.get("type").asText().equals("file"))
                    out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
            return out;
        }
        URI resolve(String repo, String commit, String path) { return URI.create(endpoint + "/" + repo + "/resolve/" + commit + "/" + path); }

        void download(URI uri, Path target, long size, String sha256) throws Exception {
            Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
            for (int attempt = 1; ; attempt++) {
                try { fetch(uri, part, etag, size); break; }
                catch (IOException e) { if (attempt == 5) throw e; Thread.sleep(Math.min(8000, 500L << attempt) / 100); }
            }
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
            if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch: " + target.getFileName()); }
            Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
            Files.deleteIfExists(etag);
        }
        void fetch(URI uri, Path part, Path etag, long size) throws Exception {
            long have = Files.exists(part) ? Files.size(part) : 0;
            HttpRequest.Builder b = HttpRequest.newBuilder(uri);
            if (have > 0) b.header("Range", "bytes=" + have + "-");
            if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
            HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
            if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
            if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
            if (r.statusCode() == 200) have = 0;
            if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
            try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
                have += in.transferTo(out);
            }
            if (have != size) throw new IOException("incomplete");
        }
    }

    // ---- GGUF reading (Project 7) and memory math (Project 4) ----
    record TensorInfo(String name, long[] dims, int type, long offset) {}
    static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
            2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});
    static long elements(TensorInfo t) { return Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); }
    static long bytes(TensorInfo t) { Object[] ty = TYPES.get(t.type()); return elements(t) / (Integer) ty[1] * (Integer) ty[2]; }
    static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }
    static Object value(ByteBuffer b, int type) {
        return switch (type) {
            case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
            case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
            case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>(); for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
            default -> throw new IllegalArgumentException("bad value type " + type);
        };
    }
    /** Reads metadata into kv and appends tensor infos to tensors. */
    static void readGguf(Path file, Map<String, Object> kv, List<TensorInfo> tensors) throws IOException {
        try (FileChannel ch = FileChannel.open(file)) {
            ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
            if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file");
            b.getInt();
            long nT = b.getLong(), nKv = b.getLong();
            for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
            for (long i = 0; i < nT; i++) {
                String name = str(b); long[] dims = new long[b.getInt()];
                for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
                tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
            }
        }
    }
    static long num(Map<String, Object> kv, String key) { return ((Number) kv.get(key)).longValue(); }
    static long kvBytes(long layers, long kvHeads, long headDim, long ctx) { return 2 * layers * kvHeads * headDim * 2 * ctx; }
    static long computeBytes(long vocab, long ffn) { return (vocab + 2 * ffn) * 512 * 4; }
    static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");
    static String mib(long bytes) { return String.format(Locale.ROOT, "%.2f MiB", bytes / 1048576.0); }
    static final Pattern SPLIT = Pattern.compile("-(\\d{5})-of-(\\d{5})\\.gguf$");

    /** The size converter of lesson 26 (Example 11), shortened: 512MiB, 3.5MiB, 8GiB, 8GB. */
    static class ByteSize implements CommandLine.ITypeConverter<Long> {
        public Long convert(String v) {
            Matcher m = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*([KMGT])?(i)?B?").matcher(v.strip());
            if (!m.matches()) throw new CommandLine.TypeConversionException("'" + v + "' is not a size such as 8GiB");
            int exp = m.group(2) == null ? 0 : "KMGT".indexOf(Character.toUpperCase(m.group(2).charAt(0))) + 1;
            double unit = Math.pow(m.group(3) == null && exp > 0 ? 1000 : 1024, exp);
            return (long) (Double.parseDouble(m.group(1)) * unit);
        }
    }

    // ---- commands ----
    @Command(name = "pull", description = "Download a GGUF model from Hugging Face.")
    static class Pull implements Callable<Integer> {
        @ParentCommand LoopModels loop;
        @Parameters(paramLabel = "MODEL", description = "repo[:quant][@revision]") String model;
        @Option(names = "--mem", converter = ByteSize.class, description = "Memory budget; picks the largest quant that fits") Long mem;
        @Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;

        public Integer call() throws Exception {
            ModelRef ref = ModelRef.parse(model);
            Hub hub = new Hub(loop.hubUrl);
            String commit = hub.commit(ref.repo(), ref.revision());
            List<RepoFile> files = hub.files(ref.repo(), commit);
            Map<String, List<RepoFile>> byQuant = new TreeMap<>();
            for (RepoFile f : files) {
                Matcher m = QUANT.matcher(f.path().substring(f.path().lastIndexOf('/') + 1));
                if (f.path().endsWith(".gguf") && !f.path().contains("mmproj") && f.sha256() != null && m.find())
                    byQuant.computeIfAbsent(m.group(1).toUpperCase(Locale.ROOT), k -> new ArrayList<>()).add(f);
            }
            byQuant.values().forEach(l -> l.sort(Comparator.comparing(RepoFile::path)));     // split parts in order
            String quant = ref.quant();
            if (quant == null) {
                long overhead = 0;
                if (files.stream().anyMatch(f -> f.path().equals("config.json"))) {
                    JsonNode c = JSON.readTree(hub.get("/" + ref.repo() + "/resolve/" + commit + "/config.json"));
                    long heads = c.get("num_attention_heads").asLong();
                    overhead = kvBytes(c.get("num_hidden_layers").asLong(), c.get("num_key_value_heads").asLong(), c.get("hidden_size").asLong() / heads, ctx)
                            + computeBytes(c.get("vocab_size").asLong(), c.get("intermediate_size").asLong());
                }
                long need = overhead, budget = mem == null ? Long.MAX_VALUE : mem;   // no budget: the largest quant
                Map.Entry<String, Long> best = byQuant.entrySet().stream()
                        .map(e -> Map.entry(e.getKey(), e.getValue().stream().mapToLong(RepoFile::size).sum() + need))
                        .filter(e -> e.getValue() <= budget).max(Map.Entry.comparingByValue())
                        .orElseThrow(() -> new IllegalStateException("no quant fits in " + mib(budget) + " at -c " + ctx));
                quant = best.getKey();
                System.out.println("chose " + quant + ": needs " + mib(best.getValue()) + " (KV cache and compute " + mib(overhead) + ")");
            }
            List<RepoFile> parts = byQuant.get(quant);
            if (parts == null) throw new IllegalArgumentException("no " + quant + " files; available: " + byQuant.keySet());
            Matcher sp = SPLIT.matcher(parts.get(0).path());
            if (sp.find() && Integer.parseInt(sp.group(2)) != parts.size())
                throw new IllegalStateException(quant + " has " + parts.size() + " of " + Integer.parseInt(sp.group(2)) + " parts");
            Path dir = loop.home.resolve("models").resolve(ref.dir(quant));
            if (Files.exists(dir.resolve("manifest.json"))) { System.out.println(ref.name(quant) + " is already pulled"); return 0; }
            Path blobs = Files.createDirectories(loop.home.resolve("blobs"));
            List<FileEntry> entries = new ArrayList<>();
            for (RepoFile f : parts) {
                Path blob = blobs.resolve("sha256-" + f.sha256());
                boolean have = Files.exists(blob);
                if (!have) hub.download(hub.resolve(ref.repo(), commit, f.path()), blob, f.size(), f.sha256());
                System.out.println((have ? "  have     " : "  pulled   ") + f.path() + " (" + mib(f.size()) + ")");
                entries.add(new FileEntry(f.path(), f.size(), f.sha256()));
            }
            Files.createDirectories(dir);
            for (FileEntry e : entries) {
                Path link = dir.resolve(Path.of(e.path()).getFileName().toString());
                if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.createSymbolicLink(link, dir.relativize(blobs.resolve("sha256-" + e.sha256())));
            }
            Manifest m = new Manifest(ref.name(quant), ref.repo(), ref.revision(), commit, quant, entries, "2026-10-03T12:00:00Z");
            JSON.writeValue(dir.resolve("manifest.json.tmp").toFile(), m);
            Files.move(dir.resolve("manifest.json.tmp"), dir.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE);
            System.out.println("pulled " + m.name());
            return 0;
        }
    }

    record Row(String name, String quant, long sizeBytes, int files) {}

    @Command(name = "list", aliases = "ls", description = "List downloaded models.")
    static class ListCmd implements Callable<Integer> {
        @ParentCommand LoopModels loop;
        @Option(names = "--json") boolean json;
        public Integer call() throws Exception {
            List<Manifest> all = new ArrayList<>();
            for (Path d : loop.modelDirs()) all.add(loop.manifest(d));
            int w = Math.max(4, all.stream().mapToInt(m -> m.name().length()).max().orElse(0));
            if (!json) System.out.printf("%-" + w + "s  %-6s %11s  %s%n", "NAME", "QUANT", "SIZE", "FILES");
            for (Manifest m : all)
                System.out.println(json ? JSON.writeValueAsString(new Row(m.name(), m.quant(), m.size(), m.files().size()))
                        : String.format("%-" + w + "s  %-6s %11s  %d", m.name(), m.quant(), mib(m.size()), m.files().size()));
            return 0;
        }
    }

    @Command(name = "show", description = "Show a model's metadata.")
    static class Show implements Callable<Integer> {
        @ParentCommand LoopModels loop;
        @Parameters(paramLabel = "MODEL") String model;
        public Integer call() throws Exception {
            Path dir = loop.modelDir(model);
            Manifest m = loop.manifest(dir);
            Map<String, Object> kv = new LinkedHashMap<>();
            List<TensorInfo> tensors = new ArrayList<>();
            for (FileEntry f : m.files()) readGguf(dir.resolve(Path.of(f.path()).getFileName().toString()), kv, tensors);
            String a = (String) kv.get("general.architecture");
            System.out.println(m.name() + " (" + m.files().size() + " file(s), commit " + m.commit().substring(0, 7) + ")");
            System.out.println("  arch " + a + ", layers " + kv.get(a + ".block_count") + ", embedding " + kv.get(a + ".embedding_length")
                    + ", heads " + kv.get(a + ".attention.head_count") + "/" + kv.get(a + ".attention.head_count_kv") + ", context " + kv.get(a + ".context_length"));
            Map<String, long[]> mix = new TreeMap<>();
            for (TensorInfo t : tensors) { long[] x = mix.computeIfAbsent((String) TYPES.get(t.type())[0], k -> new long[2]); x[0]++; x[1] += elements(t); }
            mix.forEach((ty, x) -> System.out.printf(Locale.ROOT, "  %-5s %2d tensors %,9d params%n", ty, x[0], x[1]));
            return 0;
        }
    }

    @Command(name = "fit", description = "Estimate the memory a model needs.")
    static class Fit implements Callable<Integer> {
        @ParentCommand LoopModels loop;
        @Parameters(paramLabel = "MODEL") String model;
        @Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
        @Option(names = {"-np", "--parallel"}, defaultValue = "1") int slots;
        @Option(names = "--gpu", converter = ByteSize.class, description = "GPU memory budget") Long gpu;
        public Integer call() throws Exception {
            Path dir = loop.modelDir(model);
            Map<String, Object> kv = new LinkedHashMap<>();
            List<TensorInfo> tensors = new ArrayList<>();
            for (FileEntry f : loop.manifest(dir).files()) readGguf(dir.resolve(Path.of(f.path()).getFileName().toString()), kv, tensors);
            String a = (String) kv.get("general.architecture");
            long layers = num(kv, a + ".block_count"), heads = num(kv, a + ".attention.head_count");
            long perSlot = (((ctx + 255) / 256 * 256) / slots + 255) / 256 * 256, cells = perSlot * slots;
            long weights = tensors.stream().mapToLong(LoopModels::bytes).sum();
            long kvCache = kvBytes(layers, num(kv, a + ".attention.head_count_kv"), num(kv, a + ".embedding_length") / heads, cells);
            long compute = computeBytes(((List<?>) kv.get("tokenizer.ggml.tokens")).size(), num(kv, a + ".feed_forward_length"));
            System.out.printf("%s at -c %d -np %d (%d per slot): weights %s + KV %s + compute %s = %s%n", model, ctx, slots, perSlot,
                    mib(weights), mib(kvCache), mib(compute), mib(weights + kvCache + compute));
            if (gpu != null) {                                            // Project 4's -ngl rule: output layer first, then blocks
                long output = tensors.stream().filter(t -> t.name().equals("output.weight")).mapToLong(LoopModels::bytes).sum();
                long perLayer = (tensors.stream().filter(t -> t.name().startsWith("blk.")).mapToLong(LoopModels::bytes).sum() + kvCache) / layers;
                int ngl = 0;
                for (int n = 1; n <= layers + 1; n++) if (compute + output + (n - 1) * perLayer <= gpu) ngl = n;
                System.out.println("  GPU " + mib(gpu) + " -> -ngl " + ngl + (ngl == layers + 1 ? " (all layers)" : ""));
            }
            return 0;
        }
    }

    @Command(name = "rm", description = "Remove a downloaded model.")
    static class Rm implements Callable<Integer> {
        @ParentCommand LoopModels loop;
        @Parameters(paramLabel = "MODEL") String model;
        @Option(names = {"-y", "--yes"}) boolean yes;
        public Integer call() throws Exception {
            Path dir = loop.modelDir(model);
            Manifest m = loop.manifest(dir);
            if (!yes) {                                                   // ask only when a person can answer
                if (System.console() == null) { System.out.println("loop rm: not a terminal; add --yes to remove " + m.name()); return 1; }
                String answer = System.console().readLine("Remove %s? [y/N] ", m.name());
                if (answer == null || !answer.strip().equalsIgnoreCase("y")) return 1;
            }
            Set<String> used = new HashSet<>();
            for (Path d : loop.modelDirs()) if (!d.equals(dir)) loop.manifest(d).files().forEach(f -> used.add(f.sha256()));
            Files.delete(dir.resolve("manifest.json"));
            try (Stream<Path> s = Files.walk(dir)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
            long freed = 0;
            for (FileEntry f : m.files())
                if (!used.contains(f.sha256())) { Path blob = loop.home.resolve("blobs/sha256-" + f.sha256()); freed += Files.size(blob); Files.delete(blob); }
            System.out.println("removed " + m.name() + ", freed " + mib(freed));
            return 0;
        }
    }

    // ---- demo: a fake Hub with a tiny model in three quants ----
    static byte[] tinyGguf(int type, int fromLayer, int toLayer, boolean header) {
        ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
        List<TensorInfo> t = new ArrayList<>();
        if (header) t.add(new TensorInfo("token_embd.weight", new long[] {128, 64}, type, 0));
        for (int l = fromLayer; l < toLayer; l++)
            for (String[] s : new String[][] {{"attn_q", "128", "128"}, {"attn_k", "128", "64"}, {"attn_v", "128", "64"}, {"attn_output", "128", "128"},
                    {"ffn_gate", "128", "256"}, {"ffn_up", "128", "256"}, {"ffn_down", "256", "128"}})
                t.add(new TensorInfo("blk." + l + "." + s[0] + ".weight", new long[] {Long.parseLong(s[1]), Long.parseLong(s[2])}, type, 0));
        Map<String, Object> kv = new LinkedHashMap<>();
        if (header) {
            kv.put("general.architecture", "llama"); kv.put("llama.context_length", 2048); kv.put("llama.embedding_length", 128);
            kv.put("llama.block_count", 2); kv.put("llama.feed_forward_length", 256); kv.put("llama.attention.head_count", 4);
            kv.put("llama.attention.head_count_kv", 2); kv.put("tokenizer.ggml.tokens", Collections.nCopies(64, "t"));
        }
        b.put("GGUF".getBytes()).putInt(3).putLong(t.size()).putLong(kv.size());
        for (var e : kv.entrySet()) {
            put(b, e.getKey());
            if (e.getValue() instanceof Integer i) b.putInt(4).putInt(i);
            else if (e.getValue() instanceof String s) { b.putInt(8); put(b, s); }
            else { List<?> l = (List<?>) e.getValue(); b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> put(b, (String) x)); }
        }
        long off = 0;
        for (TensorInfo x : t) { put(b, x.name()); b.putInt(2).putLong(x.dims()[0]).putLong(x.dims()[1]).putInt(type).putLong(off); off += (bytes(x) + 31) / 32 * 32; }
        while (b.position() % 32 != 0) b.put((byte) 0);
        return Arrays.copyOf(b.array(), b.position() + (int) off);
    }
    static void put(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }

    static HttpServer fakeHub(String repo, String commit) throws Exception {
        Map<String, byte[]> files = new LinkedHashMap<>();
        files.put("config.json", "{\"num_hidden_layers\":2,\"hidden_size\":128,\"num_attention_heads\":4,\"num_key_value_heads\":2,\"intermediate_size\":256,\"vocab_size\":64}".getBytes());
        files.put("Tiny-Coder-F16.gguf", tinyGguf(1, 0, 2, true));
        files.put("Tiny-Coder-Q4_0.gguf", tinyGguf(2, 0, 2, true));
        files.put("Tiny-Coder-Q8_0-00001-of-00002.gguf", tinyGguf(8, 0, 1, true));
        files.put("Tiny-Coder-Q8_0-00002-of-00002.gguf", tinyGguf(8, 1, 2, false));
        StringJoiner tree = new StringJoiner(",", "[", "]");
        Map<String, byte[]> cdn = new HashMap<>();
        Map<String, String> shaOf = new HashMap<>();
        for (var e : files.entrySet()) {
            String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(e.getValue()));
            boolean lfs = e.getKey().endsWith(".gguf");
            if (lfs) { cdn.put(sha, e.getValue()); shaOf.put(e.getKey(), sha); }
            tree.add("{\"type\":\"file\",\"path\":\"" + e.getKey() + "\",\"size\":" + e.getValue().length
                    + (lfs ? ",\"lfs\":{\"oid\":\"" + sha + "\",\"size\":" + e.getValue().length + "}" : "") + "}");
        }
        HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        s.createContext("/", ex -> {
            String p = ex.getRequestURI().getPath();
            byte[] body;
            if (p.equals("/api/models/" + repo + "/refs")) body = ("{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + commit + "\"}],"
                    + "\"tags\":[{\"name\":\"v1.0\",\"targetCommit\":\"" + commit + "\"}]}").getBytes();
            else if (p.equals("/api/models/" + repo + "/tree/" + commit)) body = tree.toString().getBytes();
            else if (p.startsWith("/" + repo + "/resolve/" + commit + "/")) {
                String name = p.substring(p.lastIndexOf('/') + 1);
                if (shaOf.containsKey(name)) {                                  // LFS files: redirect to the CDN
                    ex.getResponseHeaders().add("Location", "/cdn/" + shaOf.get(name)); ex.sendResponseHeaders(302, -1); ex.close(); return;
                }
                body = files.get(name);
            } else body = p.startsWith("/cdn/") ? cdn.get(p.substring(5)) : null;
            if (body == null) { ex.sendResponseHeaders(404, -1); ex.close(); return; }
            ex.sendResponseHeaders(200, body.length);
            try (var out = ex.getResponseBody()) { out.write(body); }
        });
        s.start();
        return s;
    }

    public static void main(String[] args) throws Exception {
        if (args.length > 0) { System.exit(new CommandLine(new LoopModels()).execute(args)); }
        HttpServer hub = fakeHub("example-org/Tiny-Coder-GGUF", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c");
        String[] common = {"--home", Files.createTempDirectory("loop-home").toString(), "--hub", "http://127.0.0.1:" + hub.getAddress().getPort()};
        String pinned = "example-org/Tiny-Coder-GGUF:Q8_0@v1.0";              // a tag on the same commit
        String[][] demo = {{"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3.5MiB"}, {"pull", "example-org/Tiny-Coder-GGUF:Q8_0"},
                {"pull", pinned}, {"pull", "hf.co/example-org/Tiny-Coder-GGUF:q4_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q5_K_M"},
                {"list"}, {"show", "example-org/Tiny-Coder-GGUF:Q8_0"},
                {"fit", "example-org/Tiny-Coder-GGUF:Q8_0", "-c", "2048", "-np", "2", "--gpu", "2MiB"},
                {"rm", pinned}, {"rm", pinned, "--yes"}, {"rm", "example-org/Tiny-Coder-GGUF:Q4_0", "--yes"}, {"list", "--json"}};
        for (String[] d : demo) {
            CommandLine cl = new CommandLine(new LoopModels());
            cl.setErr(new java.io.PrintWriter(System.out, true));
            cl.setExecutionExceptionHandler((ex, c, r) -> { System.out.println("loop " + c.getCommandName() + ": " + ex.getMessage()); return 1; });
            System.out.println("$ loop " + String.join(" ", d));
            int code = cl.execute(Stream.concat(Arrays.stream(d), Arrays.stream(common)).toArray(String[]::new));
            if (code != 0) System.out.println("(exit code " + code + ")");
        }
        hub.stop(0);
    }
}
Output
$ loop pull example-org/Tiny-Coder-GGUF --mem 3.5MiB
chose Q8_0: needs 3.43 MiB (KV cache and compute 3.13 MiB)
  pulled   Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
  pulled   Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 is already pulled
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0@v1.0
  have     Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
  have     Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0@v1.0
$ loop pull hf.co/example-org/Tiny-Coder-GGUF:q4_0
  pulled   Tiny-Coder-Q4_0.gguf (0.16 MiB)
pulled example-org/Tiny-Coder-GGUF:Q4_0
$ loop pull example-org/Tiny-Coder-GGUF:Q5_K_M
loop pull: no Q5_K_M files; available: [F16, Q4_0, Q8_0]
(exit code 1)
$ loop list
NAME                                   QUANT         SIZE  FILES
example-org/Tiny-Coder-GGUF:Q4_0       Q4_0      0.16 MiB  1
example-org/Tiny-Coder-GGUF:Q8_0       Q8_0      0.31 MiB  2
example-org/Tiny-Coder-GGUF:Q8_0@v1.0  Q8_0      0.31 MiB  2
$ loop show example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 (2 file(s), commit 4b1c7d2)
  arch llama, layers 2, embedding 128, heads 4/2, context 2048
  Q8_0  15 tensors   303,104 params
$ loop fit example-org/Tiny-Coder-GGUF:Q8_0 -c 2048 -np 2 --gpu 2MiB
example-org/Tiny-Coder-GGUF:Q8_0 at -c 2048 -np 2 (1024 per slot): weights 0.31 MiB + KV 1.00 MiB + compute 1.13 MiB = 2.43 MiB
  GPU 2.00 MiB -> -ngl 2
$ loop rm example-org/Tiny-Coder-GGUF:Q8_0@v1.0
loop rm: not a terminal; add --yes to remove example-org/Tiny-Coder-GGUF:Q8_0@v1.0
(exit code 1)
$ loop rm example-org/Tiny-Coder-GGUF:Q8_0@v1.0 --yes
removed example-org/Tiny-Coder-GGUF:Q8_0@v1.0, freed 0.00 MiB
$ loop rm example-org/Tiny-Coder-GGUF:Q4_0 --yes
removed example-org/Tiny-Coder-GGUF:Q4_0, freed 0.16 MiB
$ loop list --json
{"name":"example-org/Tiny-Coder-GGUF:Q8_0","quant":"Q8_0","sizeBytes":323904,"files":2}

Every class and rule comes from an earlier stage or lesson. What is new is the wiring:

  • --home and --hub are inherited options with defaults taken from system properties and the environment (lesson 30, Examples 2 and 4). --home defaults to ~/.loop.
  • Commands that take a model name (show, fit, rm) map it to a directory with ModelRef.dir, so users type the display name that list prints. A name without a quant is rejected with a hint, because one repository can have several pulled quants.
  • show and fit read every part of a split model: metadata from the first part and tensor infos from all of them.
  • fit accepts --gpu with the size converter of lesson 29, shortened.
  • rm prompts only on a terminal.
  • The demo sets an execution exception handler that prints loop <command>: <message> (lesson 29, Example 7).

The scripted session, in the order of the output:

  • Pull with a budget: --mem 3.5MiB chooses Q8_0 and downloads both parts, as in stage 7. This time the files are real GGUF files written by tinyGguf. Part 1 has the metadata, the embedding, and layer 0. Part 2 has layer 1.
  • Repeat: :Q8_0 is already pulled.
  • Tag: :Q8_0@v1.0 reuses both blobs.
  • Explicit quant through a URL prefix: Q4_0 is downloaded.
  • Missing quant: an error with the list of available quants, and exit code 1.
  • List: three models, the tag's name included.
  • Show: the metadata from part 1, and 15 Q8_0 tensors from both parts, 303,104 parameters.
  • Fit: at -c 2048 -np 2, each of the 2 slots gets 1,024 tokens. The weights are 0.31 MiB, the KV cache 1.00 MiB, and the compute buffer 1.13 MiB, so the total is 2.43 MiB. With a 2 MiB GPU, the result is -ngl 2. The compute buffer (1.13 MiB) plus one block with its share of the KV cache (0.65 MiB) fits, and two blocks would not. The tiny model has no separate output.weight tensor, so the output layer costs nothing here.
  • Remove without --yes: the demo has no terminal, so rm refuses and returns 1.
  • Remove the tag with --yes: its blobs are shared with :Q8_0, so 0 MiB are freed.
  • Remove Q4_0: its blob was used only by that model, so 0.16 MiB are freed.
  • List as JSON: one model is left.

The last part shows how the program would be used for real. The command runs against huggingface.co, with the program on the class path (lib/ holding picocli and Jackson) and the Hub reachable. The repository name is the placeholder used throughout this section. The numbers are computed from the file sizes and config.json values of stages 2 and 3. A real repository has its own names and sizes, and downloading takes minutes rather than milliseconds.

Terminal
java -cp "lib/*" LoopModels.java pull example-org/Coder-7B-Instruct-GGUF --mem 8GiB
java -cp "lib/*" LoopModels.java list

Representative output (values follow from the sizes and configuration used in this lesson; a real repository differs):

Output
chose Q6_K: needs 6559.47 MiB (KV cache and compute 595.00 MiB)
  pulled   Coder-7B-Instruct-Q6_K.gguf (5964.47 MiB)
pulled example-org/Coder-7B-Instruct-GGUF:Q6_K
NAME                                     QUANT         SIZE  FILES
example-org/Coder-7B-Instruct-GGUF:Q6_K  Q6_K   5964.47 MiB  1

At the default -c 4096, the KV cache is 224 MiB and the compute estimate is 371 MiB, together 595 MiB. Q6_K needs 5,964.47 + 595 = 6,559.47 MiB and fits in 8 GiB (8,192 MiB). Q8_0 would need 8,318 MiB and does not fit. The Q6_K file is the largest one that fits, which is why chose Q6_K is the first line.

loop show and loop fit on the downloaded file print the real model's metadata and tensor mix in the formats of the demo.

The real run also shows what this workshop leaves out. Each item is a few lines of code on top of the classes here:

  • A progress bar for downloads that last minutes (lesson 31, Example 14).
  • A disk space check before the download starts (lesson 31, Example 13).
  • The HF_TOKEN header for gated repositories, which are repositories that require a logged-in user (lesson 26, Example 8).

Interview points

  • A model manager separates content from names. Content is stored as blobs named by SHA-256, and names are kept in manifests plus links. This stores identical files once, and every blob can be verified by its name.
  • A branch or tag should be resolved to a commit first, and the files should be downloaded by commit. This way a push to the repository during the pull cannot mix versions.
  • To choose a quant, add the file size, the KV cache, and the compute buffer for the requested context, and pick the largest total that fits. The file size slightly overestimates the weights, because it includes the header.
  • Split GGUF parts are named <prefix>-NNNNN-of-NNNNN.gguf. All parts must be present, they must keep their names, and llama.cpp must be given part 1.
  • The manifest is written last and atomically, so a directory without a manifest is an unfinished pull.
  • rm collects the references from the other manifests before it deletes anything. It deletes the manifest first and the blobs last, and it deletes only blobs that no manifest lists.
  • A program should prompt for confirmation only on a terminal, and it should require --yes otherwise, so that a script never waits for input.
  • show and fit need only the GGUF header. A memory-mapped reader parses the header without touching the tensor data.
  • llama.cpp slots divide the context instead of adding KV cache memory, so fit reports cells per slot.
  • --json output with raw byte counts, one object per line, is the stable interface for scripts.

Summary

  • The reference hf.co/org/repo:q4_k_m parses to repository org/repo, quant Q4_K_M, and revision main.
  • The display name org/repo:QUANT[@rev] maps to the directory name org--repo--QUANT[--rev].
  • GGUF files with mmproj or imatrix in the name are not language models, so the tool skips them.
  • The 7B shape of this lesson needs 57,344 bytes of f16 KV cache per token, which is 448 MiB at 8K tokens and 1,792 MiB at 32K tokens.
  • With an 8 GiB budget, Q6_K fits at 8K and at 32K tokens, while Q8_0 needs 8.34 GiB at 8K.
  • llama.cpp's split name format is "%s-%05d-of-%05d.gguf", and loading starts from part 1.
  • A connection that drops at 40% resumes with Range and If-Range, and the finished file still passes the SHA-256 check.
  • A tag that points to an already-pulled commit costs no downloads.
  • loop list ignores model directories without manifest.json.
  • The tiny test model has 106,624 parameters, and its 106,496 Q8_0 values take 113,152 bytes.
  • fit on the 7B header gives 4,466 MiB of weights. At 32K tokens on a 6 GiB GPU, 27 of 28 blocks fit on the GPU.
  • Removing one of two models that share blobs frees nothing, and removing the second one frees the blobs.
  • The full program has five subcommands, and it runs a demo against a fake Hub when it is started without arguments.