Add a New Model Family
Every model family follows one file scheme (issue #346), so adding a family is mechanical and every family looks the same. BitNet b1.58 is the reference instantiation β it was added greenfield against this template and is the module to copy; Apertus shows the same shape for a family with a hand-rolled runtime.
The template
For a family <f> (module llm-inference/<f>, Kotlin package sk.ainet.models.<f>):
| Concern | Name | BitNet example |
|---|---|---|
DSL network definition |
|
|
End-to-end module loader |
|
|
Weight materialization |
|
|
Weight container + names |
|
|
GGUF name mapping |
|
delegates to |
HF config parsing |
shared |
shared parser |
Runtime facade |
|
|
Deviations are allowed but must be justified in the PR against #346 (BitNet documents two: its runtime-weights container is the nameβtensor map the DSL runtime actually consumes, and the config-parser row is satisfied by the shared parser).
The steps, using BitNet as the worked example
1. Module skeleton
Copy llm-inference/bitnet/build.gradle.kts, add
include("llm-inference:<f>") to settings.gradle.kts.
2. Network definition
Express the architecture as knobs on the shared decoder builder β resist writing layer loops:
public inline fun <reified T : DType, V> bitnetNetwork(
metadata: LlamaModelMetadata,
maxInferenceLen: Int = minOf(metadata.contextLength, 4096),
): Module<T, V> = decoderTransformerNetwork<T, V>(
metadata = metadata,
ropeMode = RoPEMode.SPLIT_HALF, // verify against the reference impl β see step 6!
maxInferenceLen = maxInferenceLen,
ffnKind = DecoderFfnKind.RELU2_SUBLN, // family-specific FFN variant
attnSubNorm = true, // family-specific extra norm
)
A genuinely new layer kind goes into transformer-core (e.g. BitNetFFN.kt) and becomes a
decoderTransformerNetwork knob β not a private fork of the block loop.
3. Name resolver
Most GGUF families are Llama-layout plus extras; delegate and add only the family’s own names,
and record those names once in <F>TensorNames:
public class BitNetGGUFNameResolver : WeightNameResolver {
private val llama = LlamaGGUFNameResolver()
override fun resolve(modulePath: String, paramName: String): String? = when {
paramName.contains("attn.sub_norm.weight") -> /* blk.N.attn_sub_norm.weight */
paramName.contains("ffn.sub_norm.weight") -> /* blk.N.ffn_sub_norm.weight */
else -> llama.resolve(modulePath, paramName)
}
}
4. Weight loader
Weight materialization is the engine’s job (StreamingGgufParametersLoader + WeightForm
from sk.ainet.core:skainet-io-gguf) β a family loader only states the form it wants.
Never write per-family quant/packing/converter code; that layer retired with the 0.49 migration.
val keepPacked = WeightForm(
encoding = EncodingRequest.KeepAsStored, // quantized tensors stay packed
shape = WeightShapeOrientation.OUT_IN,
residency = WeightResidency.MAPPED, // request zero-copy mmap where servable
)
StreamingGgufParametersLoader(sourceProvider, weightForm = keepPacked, /* per-tensor overrides */)
.load<FP32, Float>(ctx, FP32::class) { name, tensor -> tensors[name] = tensor }
Return a <F>RuntimeWeights(metadata, tensors); toModule() binds it into the network via
WeightMapper with the family resolver.
5. Registry, CLI, facade
-
ModelFamily.<F>+ thegeneral.architecturestrings inllm-core/…/ModelRegistry.kt, and a dispatch branch inskainet-cli’s `Main.kt. -
llm-runtime/k<f>with a<F>Ingestionfacade (copykbitnet’s `BitNetIngestion).
6. The maturity gate β before calling the family "supported"
This is the part that catches real bugs. BitNet’s RoPE pairing was wrong for its first two weeks β output looked perfectly coherent β and only this gate caught it:
-
Golden-token parity vs the reference implementation (llama.cpp, or the family’s own fork). Model-gated test asserting prompt-tokenization parity and greedy-continuation equality; commit the fixture with the exact oracle build and commands in its header (
BitNetGoldenTokenParityTest+golden-greedy-2b4t.txtare the pattern). When the reference itself is approximate, arbitrate divergences against the HF BF16 checkpoint before deciding who is wrong. -
A row in
tests/smoke/smoke-models.jsonβ time-bounded, model-gated. -
Entry-point parity: the unified CLI, the family facade, and (where applicable) chat.