Checking a large model file in pieces
A local AI model is one very large file. NAOMS is built to move it between people as a signed pack over a peer-to-peer connection, and the receiver checks every byte against the signature before it keeps anything. The receiving side used to read the whole file into memory to do that, which capped models at 256 MiB. It now streams: in a measured import, a 5 GiB file needed about 20 MiB of extra memory.
A local AI model is, physically, one very large file. Even a small useful chat model is a few hundred megabytes, and the capable ones run to several gigabytes. If NAOMS is going to let a group share models the way it shares everything else, directly between members with no store in the middle, then moving a very large file has to be as trustworthy as moving a short message.
"Trustworthy" has a specific meaning here. You are about to run this file on your own machine, so you need to know it is exactly what your friend published, every byte of it. You also need your machine to stay usable while it arrives.
How a model travels
flowchart LR A["Publisher's NAOMS
builds a signed pack
(file list + hashes + signature)"] -->|"encrypted
peer-to-peer link"| B["Receiver fetches
small descriptions
into memory, checks them"] B --> C["Model file streams
to disk in pieces;
kept only if its hash matches"] C --> D["Import: verify signatures,
re-hash the file in pieces,
store it under the signed hash"] D --> E["Installed"]
The publisher builds a signed pack. A pack is a small signed record that describes the files it carries. For each file it records a fingerprint: a BLAKE3 hash, a short value computed from every byte of the file. Change one byte of the file and the fingerprint changes completely. The record is signed with the publisher's key, so the fingerprints can't be swapped either. Whoever holds the pack knows exactly which bytes they should end up with. The model itself travels beside the pack, not inside the signed record.
The receiver fetches it directly from the publisher. The transfer runs over the same encrypted peer-to-peer connection NAOMS already uses between devices: the two machines talk to each other, not through a download server. It happens in three layers. The small descriptive parts come first. They are held in memory and checked against their fingerprints. The model file comes last and is written straight to disk in pieces, so memory use stays around a megabyte no matter how large the file is. It is moved into its final place only if its fingerprint matches, so a half-arrived or corrupted file never shows up as a real one.
The receiver checks everything again before keeping it. Importing the pack is a second, independent step. The signatures on the pack are checked first, layer by layer. Then the model file on disk is fingerprinted from scratch and compared with the signed value. A mismatch at any layer refuses the import by name, and nothing is stored. Last, the file goes into the receiver's own content-addressed store, which files everything by its fingerprint. That store is given the expected fingerprint too and refuses the file on its own if the two disagree.
Big files are streamed, and a gap is refused. The storage layer has a ceiling on what it will read into memory at once: 256 MiB by default, adjustable by configuration. Files under that ceiling are checked in memory as usual. Files above it are fingerprinted and stored in pieces. Their integrity check is not relaxed; it runs over every byte, one piece at a time. If a caller asks to import a large file without giving the importer a way to stream it, the importer stops with an error that names the problem. It does not quietly fall back to loading the whole thing.
What was wrong
The fetch already streamed the model to disk. Then the import step, one line later, read the whole file back into memory to check and store it. That undid the streaming.
It also ran into the storage ceiling. At the default setting anything over 256 MiB was refused, so no larger model could be installed this way. Even below the limit, the old path held about three copies of the file in memory at once. The refusal said only that the file was "too large"; the limit it hit was the receiving side's storage ceiling, not anything wrong with the pack.
How it was fixed
The import now streams large files. It fingerprints each one in pieces, using a small native routine added for this, and ingests it from disk straight into the content-addressed store, with the signed fingerprint as a second check.
The figures below were measured on one Linux build machine with 8 GB of RAM, with the storage ceiling at its real default:
| file size | import time | extra peak memory |
|---|---|---|
| 1 GiB | 4.9 s | 3.6 MiB |
| 5 GiB | 32.2 s | 20.1 MiB |
In both runs the stored file was read back whole under its signed fingerprint. The 5 GiB case is not a speed-up over the old path, because the old path could not have finished it at all. The JavaScript runtime caps a single in-memory buffer at about 4 GB, so a file that size could never have been loaded whole.
A real model has also crossed between two NAOMS instances. It was SmolLM2-360M, a 368.5 MiB chat model in GGUF, the single-file format local model runners use. In that test the two instances ran side by side on one build machine, each with its own storage, talking over the same peer-to-peer protocol devices use. The model arrived under its fingerprint as its file name, its size matched the publisher's exactly (386,404,992 bytes), and the fingerprint recomputed over the received copy matched the signed one. The install step then stopped. The test's own harness had not handed the importer its streaming route, which the product's install path already supplies. The importer refused by name rather than load 368 MiB into memory, which is the behaviour described above. The harness has since been corrected.
What is still pending proof: a recorded rerun of that real-model case from start to finish after the harness correction. The run on 14 September passed the streaming tests and the transfer between the two instances, but that run skipped the real-model case because no model file was provided.
The memory check, and why a zero can lie
The real-model test is also written to check that the receiver never held the whole model in memory. The way it does so is worth borrowing.
It reads the process's high-water mark, the most memory it has ever used, before and after the transfer, and requires the rise to stay under a quarter of the model's size. A high-water mark catches a buffer that was allocated and freed mid-transfer, which a single point-in-time reading would miss.
But a rise of zero can mean two things. Either the receiver streamed, or it buffered the whole file and still never went above a peak it had already reached while starting up. In the second case a buffering receiver passes the check. So before trusting the result, the test first checks that the number could have moved: that current memory plus the model's size would have set a new peak. Only when that margin exists does a small rise count as evidence.
That is a general lesson about measurement. A check that can pass while the thing it guards is broken is not yet a check, and it is worth asking what else could make it pass before believing it.
The publishing side
On the same day, the command-line publishing path was run end to end against a fresh install and a real group. It ended with a real published pack announced on the group's shared record. Two real product defects came up on the way and were fixed:
- Publishing from the command line stalled on an approval prompt that nothing on the command line could answer, until the request timed out.
- Model files ending in
.binwere not recognised as belonging to the GGUF family, which one speech model uses.
The model published in that run was a small one. Publishing a large model and fetching it on a second instance, in one recorded run, is the end-to-end proof still to come.
Status: this work was written in late August, committed with its measurements on 14 September, and reached the main line of development on 19 September. The full publish, discover and install chain between two instances has been run end to end with a small non-model package; no recorded test yet drives a real model through that whole chain.
Related: How to download a file you can actually trust ยท There Is No Ungoverned Route To A Model
Written by AI agents from real project logs; owned and edited by Mujo.