This document explains the why behind Mosaic's design choices. For the what (API surface, command reference, build instructions), see README.md, mosaic-core/README.md, and mosaic-cli/README.md.
Three Gradle modules, with a strict dependency direction:
consumers
│
┌──── ▼ ────┐
┌──────────►│ mosaic-cli│
│ └───────────┘
│
┌─────────┴─────────┐
│ mosaic-core │◄── mosaic-samples
│ (the library) │
└─────────┬─────────┘
│
┌───▼────┐
│tessera │ (JitPack: HectorIFC/tessera)
└────────┘
mosaic-coreis the only published artifact. It hasexplicitApi()enabled, so its public surface is locked down — anything not markedpublicisinternaland unreachable frommosaic-cliormosaic-samples. This is structurally enforced by Kotlin's module-scopedinternalvisibility, not just convention.mosaic-cliis a runnable wrapper that exists primarily for interactive debugging. It must use onlymosaic-core's public API — a constraint that the module boundary makes impossible to violate (and which we double-checked by grepping the CLI sources fordev.mosaic.internalimports → zero matches).mosaic-samplesis a separate module so the demo programs never bleed into the library's classpath.
Tessera is a hard dependency at the api configuration level: any consumer of mosaic-core gets dev.tessera.* transitively. This is intentional — TesseraEmbeddings exposes BpeTokenizer directly in its public signature.
The embedding matrix lives in a single contiguous FloatArray of size vocabSize × embeddingDim. Row r occupies indices [r × embeddingDim, (r + 1) × embeddingDim).
// ❌ The naïve layout
val data = Array(vocabSize) { FloatArray(embeddingDim) }
// ✅ What Mosaic does
val data = FloatArray(vocabSize * embeddingDim)The naïve layout is an array of pointers to per-row arrays. Every row access dereferences a pointer; consecutive rows can land arbitrarily far apart in heap memory; each row carries an object header (~16 bytes on 64-bit JVM).
The flat layout:
- Allocates one large primitive buffer — no boxing, no per-row object overhead, ~1 % heap waste over the theoretical bound (measured in
BENCHMARKS.md). - Is cache-friendly: a
mostSimilarscan of 50 000 rows × 128 dims streams 25 MB of sequentialfloats, which the CPU prefetcher loves. - Makes
saveandloadtrivial: dump the whole buffer in oneByteBuffer.putFloatloop; no need to walk a 2D structure.
The trade-off is that row indexing requires manual row × cols arithmetic. That lives in the internal FlatMatrix class — the public API never exposes the offset math.
Industry-standard embeddings are float32 (PyTorch, TensorFlow, ONNX). Mosaic follows suit:
- Half the memory of
doublefor no measurable accuracy loss in similarity tasks. - Fits the on-disk format choice (IEEE-754 single precision, 4 bytes per value).
- Avoids autoboxing on the JVM —
FloatArrayis a true primitive array, like Java'sfloat[].
Long sums of small Floats drift. dotProduct and norm accumulate in Double internally and narrow back to Float on return:
public fun dotProduct(a: FloatArray, b: FloatArray): Float {
requireSameSize(a, b)
var sum = 0.0 // Double accumulator
for (i in a.indices) sum += a[i].toDouble() * b[i].toDouble()
return sum.toFloat() // narrow on return
}The narrowing is intentional — callers should not see a Double in the public API, which would suggest more precision than the underlying storage provides.
A naïve mostSimilar would compute every score, sort the whole list, take the first K. That's O(N log N). For vocabSize = 50 000 and topK = 10, the sort dominates.
Mosaic uses a fixed-capacity min-heap of size K (internal/TopKHeap.kt):
1. Compute the query vector's norm once.
2. For each row r in [0, vocabSize):
score := cosineSimilarity(query, row[r])
if heap.size < K: push (r, score)
else if score > heap.peek().score: replaceTop(r, score)
3. Extract the heap, sort the K elements descending.
This is O(N log K) — for K = 10 and N = 100 000 that's about 10× fewer comparisons than a full sort, and the extracted result is a list of size 10 instead of 100 000.
Pre-normalizing means cosine reduces to a single dot product (no per-row norm). It's roughly 2× faster but changes semantics — the table's rows are no longer the "raw" vectors you wrote with set. For now we keep set/get symmetric.
Two files per saved table:
<path> — binary payload
<path>.meta.json — sidecar with metadata + integrity info
0..3 magic (int32 LE) — must equal 0x43534F4D ("MOSC" on disk)
4..7 version (int32 LE) — 1 today; bumped on any binary breakage
8..11 vocabSize (int32 LE)
12..15 embeddingDim (int32 LE)
16.. vocabSize × embeddingDim × 4 bytes (IEEE-754 float32 LE)
The 16-byte header is fixed and self-describing — you can run xxd on any Mosaic file and instantly see what it claims to be. Dimensions are duplicated in both the binary header and the JSON sidecar; load checks they agree.
JVM ByteBuffer defaults to big-endian. We override that everywhere via ByteOrder.LITTLE_ENDIAN. Reason: the entire ML ecosystem (numpy, PyTorch, TensorFlow, ONNX) standardized on little-endian floats. A Mosaic file should be loadable by tools written in other languages without endian conversion.
The sidecar records the SHA-256 of the binary payload (header + floats). load recomputes it on every call and fails fast if it doesn't match. This catches bit rot, partial downloads, and any in-flight corruption.
The cost is one extra pass over the bytes per save and per load. It's measurable but small — see save / load timings in BENCHMARKS.md. The safety win is worth it. The verifyChecksum API exposes the check without the full load, for tools that only want integrity verification.
We check things in this order, so the user gets the most specific error first:
.binfile exists?.meta.jsonfile exists?- Metadata version compatible? (rejects future formats)
.binfile size matchesexpectedBinarySize(vocabSize, embeddingDim)? (catches truncation before we burn cycles on SHA)- SHA-256 of
.binmatches the sidecar's checksum? - Binary header's magic / version / vocabSize / dim match the sidecar?
Steps 1–4 are O(1) or single-stat; step 5 is the expensive one but unavoidable.
TesseraEmbeddings is a thin wrapper:
public class TesseraEmbeddings(
private val tokenizer: BpeTokenizer,
public val embeddings: EmbeddingTable,
) {
init {
require(tokenizer.vocabSize == embeddings.vocabSize) { ... }
}
public fun encode(text: String): Array<FloatArray> = embeddings.get(tokenizer.encode(text))
public fun encodeMeanPooled(text: String): FloatArray { ... }
}The only constraint enforced at construction is that the tokenizer's vocab and the table's vocab line up. Catching this mismatch in init (instead of at the first encode call) is one of the few "fail eagerly" hot-paths in the API — vocab-size mismatches are catastrophic and almost always caused by reloading a tokenizer or table from the wrong snapshot.
encodeMeanPooled is a sentence-embedding baseline: average all token vectors element-wise. It's not state-of-the-art (transformer-style pooled outputs are far richer) but it's a reasonable starting point and costs nothing beyond encode + one pass.
Tessera owns the tokenizer state — including special-token reservations that affect the effective vocab size. If a user constructs EmbeddingTable with a hardcoded vocab and the tokenizer ever changes (gains special tokens, etc), the runtime mismatch will be caught. The library tells you exactly where they disagree, not just "out of bounds at id 50000".
mosaic-cli is a separate module that only uses mosaic-core's public API. This is enforced by Kotlin's internal visibility being module-scoped: even if a CLI command tried import dev.mosaic.internal.FlatMatrix, the compile would fail because mosaic-core's internal symbols aren't visible across the module boundary.
Args.kt is ~75 lines. We chose manual parsing over kotlinx-cli because:
- Mosaic's argument shapes are simple (
--name valueplus boolean flags). - Tessera uses manual parsing — keeping the sister projects symmetric helps readers who jump between them.
- Zero additional dependencies on the CLI distribution.
The only non-trivial parse rule is distinguishing a short flag (-h) from a negative-number value (-0.5). The heuristic: a flag name starts with - followed by a letter; anything else (numbers, decimals) is a value.
Tests can't easily catch exitProcess. So:
main(args)is a thin shim that callsrunCli(args)and only invokesexitProcessif the return code is non-zero.runClireturnsIntdirectly; tests invoke it and assert on the return code + captured stdout/stderr.
0— success1— usage error (missing/invalid argument, unknown command). Triggered by anUsageErrorthrown anywhere inside a command.2— runtime failure (file not found, corrupted file, checksum mismatch). Triggered by aRuntimeFailure.
These are caught in a single dispatcher (dispatch { ... }) so commands can just throw and never need to know about exit codes.
Unlike most tools where inspect is purely informational, mosaic-cli inspect returns exit code 2 (not 0) when the SHA-256 doesn't match — so it composes in shell scripts:
mosaic-cli inspect --input embeddings.bin || echo "file is corrupted"Initializer is a fun interface with six built-in factories on the companion. Notable details:
- Default =
uniformDefault, with bound0.5 / dim— matches PyTorch'snn.Embedding. Predictable to anyone arriving from a PyTorch background. - Every random initializer takes a
seed: Longparameter, defaulting to42L. Reproducibility is a hard requirement in ML pipelines; we never want a "the test passes locally but fails in CI" because of a re-seeded RNG. - The captured
Randomis stateful across rows. A single initializer instance, used to fill 1 000 rows, produces 1 000 different draws — not the same draw 1 000 times. (We have a dedicated test for this exact gotcha.)
xavier: bound = sqrt(6 / (fanIn + fanOut))
he: bound = sqrt(6 / fanIn)
These are the uniform-variant bounds, not the Gaussian forms. They map cleanly onto Initializer.uniform(bound, seed). For embedding layers, uniformDefault is usually the right choice; the other two are there for users wiring Mosaic vectors directly into a downstream MLP.
- Training. No backprop, no autograd, no Word2Vec, no GloVe. Mosaic is a storage and lookup layer. A separate project will (someday) read/write Mosaic tables as part of a training loop.
- GPU. JVM-only. CPU-only. The
mostSimilarnumbers inBENCHMARKS.mdshow that for vocab sizes up to ~100 K, this is fine. - Quantization. No int8, no int4. Float32 is the format. If a future project wants quantized tables, it'll be a separate format (and probably a separate library).
- Multi-platform. JVM target only. The API would translate cleanly to Kotlin Multiplatform — the only
java.io.Filereferences are in convenience overloads — but it's not a current goal. - Approximate nearest neighbors. Exact
mostSimilaronly. FAISS-style indexing it`s stretch goals and would belong in a separate library or module.
These are not oversights; they are scoping decisions made explicitly to keep mosaic-core small, auditable, and dependency-free.