Changelog¶
v0.3.0 β 2026-09-01¶
Minor, not patch: several changes alter results rather than only adding surface.
sparse_kl is on by default and shifts loss values slightly; IdentityAlignment
now sums colliding probability mass instead of dropping it, which changes the
distillation target; compare_retrievers defaults to device="auto" rather
than "cuda"; params_m is no longer rounded inside the result; 8-bit config
construction now requires bitsandbytes; and mine_hard_negatives omits the
negative key rather than emitting a false one. Pin olaverse-foundry==0.2.1 if
you need the old behaviour.
Fusion¶
- Fix:
fusion_strategy="mean_ce"silently ran MinCE. The registry key is"mean", but the docs, CLI help and recipe examples have always said"mean_ce"β and the trainers looked it up withSTRATEGY_REGISTRY.get(name, STRATEGY_REGISTRY["min_ce"]), so the documented spelling fell through to the default. A run configured to average its teachers selected one per token instead, with nothing in the output to indicate it. The same silent fallback swallowed any typo."mean_ce"is now an accepted alias, an unrecognised name raisesValueError, andFusionConfig.strategyaccepts the documented spelling.
Distillation loss¶
- The KL is now computed over the teacher's top-k support (
sparse_kl=True, the default) instead of scattering it into a dense(B, S, vocab)target. A teacher returns ~8MB of top-k data atB=8, S=2048, K=64; the dense path turned that into ~10GB per teacher per step at a 152k vocabulary, in numpy, then copied it to the device again.KL(TβS)has no contribution whereT(v) = 0, so that was arithmetic on zeros. Measured atV=152k: 730Γ less memory and ~56Γ faster. This affected the same-tokenizer path (IdentityAlignment) exactly as much as the cross-tokenizer one, so it was the binding constraint on any large-vocabulary run. - The two paths differ very slightly: the dense one adds
1e-9to every vocab entry before renormalising, smearing ~1.5e-4 of mass across the vocabulary. The sparse path renormalises the top-k mass over its own support. Against a dense reference without that smoothing the two agree to float32 precision. Passsparse_kl=Falseto reproduce an older run. - Fix:
IdentityAlignmentandEMAlignmentdisagreed on collisions. Identity used plain assignment, so when a token appeared twice in one top-k the earlier probability was silently dropped; EM usednp.add.atand summed. Both now sum, which is correct β those are mass on the same token β and the sparse path deduplicates to match, including for a single teacher, since aMinEDAlignmentmaps several teacher tokens onto one student token.
Large-model distillation¶
Three changes that together make a large-teacher β small-student run fit on one machine. A 27B teacher is ~54GB in bf16; a 2B student full-weight trained needs ~30GB of weights, gradients and optimizer moments before activations.
- New:
foundry.training.loraβ LoRA training for the student.attach_lorafreezes the base and returns aPeftModelthat drops straight into any existing trainer, because every foundry trainer already trains whatever requires grad. There is deliberately noLoRADistillTrainer: LoRA is a property of the model, not a kind of training.merge_and_saveproduces a plain HuggingFace directory;save_adapterproduces a PEFT directory;to_skillpackfeeds the existing skill-pack machinery. Quantized bases are routed throughprepare_model_for_kbit_training, without which the run trains at a flat loss and never errors. quantizefails fast with an actionable error when bitsandbytes is absent.BitsAndBytesConfig.post_init()readsimportlib.metadata.version("bitsandbytes")on some transformers releases and not others, so the same call raisedPackageNotFoundErroron one version and succeeded on another β then failed much later insidefrom_pretrained. Neither message named what to install. foundry now checks up front, so the behaviour is identical on every transformers version.quantize="4bit"/"8bit"now works for training, not just inference.io.loaderbuilttorch_dtypeanddevice_mapbut never aquantization_config, even thoughinference.pyalready constructed one β so a teacher could not be quantized through the library that loads it.ModelRefandHFTeacherboth takequantizenow, and the BitsAndBytes config is built in one shared place instead of two. A 27B teacher drops from ~54GB to ~15GB.- Fix:
HFTeacher.load()called.to(device)on an already-placed model. A bitsandbytes-quantized model refuses.to()outright, and a model dispatched by accelerate (device_map="auto", the default) carries hooks that.to()invalidates β which is how a sharded teacher ends up half on the wrong device. It now detects placement and leaves those models where transformers put them. - Fix: 7 of 8 trainers built their optimizer over
model.parameters(), ignoringrequires_grad(onlyheads.pyfiltered). AdamW skips parameters whose grad isNone, so this was not a memory leak, but it made a LoRA or partially-frozen run unpredictable: clipping walked tensors that never had gradients, and anything later flippingrequires_gradwould silently start updating weights the caller froze. All trainers now sharetraining._params.trainable_parameters.
Teacher logit cache¶
LogitCachecan now be disk-backed and bounded. It was a plain in-memory dict, andpopulate_dataset()filled it for the whole dataset up front β attop_k=64with 8x512 batches that is ~21GB of RAM for only 41M tokens, which does not survive contact with a real run. Passingcache_dircaps RAM atmax_entriesand spills evictions to sharded.npzfiles, reading them back on a miss. The shard index lives incache_dir, so a populated cache is reused by later processes rather than rebuilt.- Eviction is LRU, not FIFO. Training reads batches in order, so FIFO evicted precisely the entries the next epoch read first: a cache smaller than the dataset achieved a ~0% hit rate and re-ran the teachers every epoch β the one cost the cache exists to avoid, with nothing in the output to indicate it.
statsgainsdisk_hits,spillsandon_disk.
Data quality¶
- New:
foundry.qualityβ the gate between synthesis and training.clean_pairs/dedup_pairs/drop_degenerate_pairsremove duplicate pairs, untranslatedanchor == positivepairs and false negatives;embedding_healthdetects a collapsed encoder;quality_report/print_quality_reportsummarise a pair set before you train on it. numpy-only, so it runs on a core install. Language ID, translation adequacy and toxicity are deliberately out of scope β those need real models. - Fix:
mine_hard_negativescould return a false negative. It skipped duplicate candidates by index, but duplicate passages are common in translated corpora, so a "negative" textually identical to the pair's own positive could be selected β asking InfoNCE to push two identical strings apart. It now skips by normalised text, and omits the key entirely when no distinct candidate exists, so a missing negative is distinguishable from a bad one.
Retrieval¶
- Fix:
compare_retrieversno longer defaults todevice="cuda". The benchmark helper called.to("cuda")unconditionally, so it raised on any CPU-only machine. It now resolves"auto". - Fix:
params_mis no longer rounded inside the result. Rounding to 1dp made every model under 50k params report0.0with the true count unrecoverable from the dict callers publish as a benchmark table. Rounding moved toprint_retrieval_comparison.
Recipes¶
EmbedRecipe.run()validatesseed.modelup front. It was passed straight toAutoModel.from_pretrained, so an embed recipe missing that field failed deep inside transformers instead of naming the missing field. UnlikeFoundryRecipethere is no random-init path for embeddings.
Testing & CI¶
- New: real-model CPU integration suite (
tests/test_integration_cpu.py). The rest of the suite runs on hand-rollednn.Modulestubs, which prove the training maths but never touchAutoModel,config.json,save_pretrainedor the safetensors round-trip. These tests build genuineBertModeland tokenizer instances, save them as real HuggingFace directories, and run the pipeline end-to-end β including the README's claim that output reloads withtransformersalone. No network, no GPU, ~9s. - New:
core-installCI job. Every existing job installed[torch,lego,data,dev], so nothing verified the advertisedpip install olaverse-foundry. On a core install the suite failed βtests/test_m4.pyimported the PEFT weight bridge unguarded, erroring 14 tests instead of skipping them. Fixed, and now covered. - The import check moved to Python 3.9 (the version floor, not 3.11) and now
walks every submodule rather than the top-level re-exports. The 0.2.1 bug β a
runtime
X | Nonealias breaking everyfoundry.fusionimport on 3.9 β could not have been caught by a check running on 3.11. - New:
qualityCI job runningruffandmypy. The library shipspy.typedbut nothing verified those annotations. Ruff is scoped to correctness rules only; pyupgrade is deliberately disabled, since it rewritesOptional[X]toX | Noneand would reintroduce the 0.2.0 break. - 25
raise ... from Noneon errors that translate an opaque failure into an actionable one, so a chainedNo module named 'torch'no longer buries the message explaining how to fix it. 40 dead imports removed.
Packaging¶
- PEP 561 type marker β
foundry/py.typedships in the wheel, so mypy and pyright now read the library's annotations instead of treating it as untyped. - PyPI metadata gained
DocumentationandChangeloglinks, so the docs site is reachable from the project sidebar rather than only from inside the README. - Releases are now tag-driven: pushing
vX.Y.Zbuilds and publishes to PyPI via trusted publishing, and refuses to run if the tag andpyproject.tomldisagree.
Docs¶
- The docs site adopts the "Paper / Ink" theme shared with the Olaverse SDK docs and the marketing site.
- The landing-page version badge is generated from
foundry/__init__.pyat build time, so it can't drift behind a release.
v0.2.1 β 2026-07-16¶
Fixes¶
- Python 3.9 support actually works: a runtime
X | Nonetype alias in the fusion strategy registry broke every import offoundry.fusionon 3.9 (CI had never been green).
v0.2.0 β 2026-07-16¶
Encoder base models¶
MLMTrainerβ masked-language-modeling pretraining of an encoder backbone from scratch (teacherless).WithMLMHeadadds an MLM head to a custom encoder.EncoderDistillTrainerβ token-level hidden-state distillation from a teacher encoder into a smaller architecture, with automatic studentβteacher projection.DistilMLMTrainerβ combined distillation + MLM in a single multi-part loss (the DistilBERT objective: MLM CE + temperature-scaled KL + hidden-state cosine).
Retrieval¶
ContrastiveTrainerβ InfoNCE / MultipleNegativesRanking training on{anchor, positive[, negative]}pairs, with in-batch negatives and optional hard negatives, for (cross-lingual) retrieval.evaluate_retrieval()/compare_retrievers()/print_retrieval_comparison()β nDCG@k / Recall@k scoring and a head-to-head model table; each model encoded with its own tokenizer, pooling, and prefixes (e5 / bge / LaBSE auto-configured).encode_texts()β batched no-grad encoding to numpy, with pooling, normalisation, and prefix support.
Synthetic data¶
synthesize_pairs()/generate_hard_negatives()β query + hard-negative generation with an open, Apache-licensed instruct LLM (load_generator, Qwen/Mistral).mine_hard_negatives()β encoder-based hard-negative mining (LLM-free; the right choice for low-resource languages).synthesize_parallel()/translate_texts()β synthetic parallel pairs for no-data languages via an open MT model (load_translator, MADLAD-400).
Task heads¶
SequenceClassificationTrainer/TokenClassificationTrainerβ fine-tune classification / NER heads on any base encoder (model-agnostic; any model returning.logits).freeze_backbone()+HeadTrainConfig(freeze_backbone=True)β train only the head so many heads share one frozen encoder.build_encoder_with_head(base, num_labels, task)β attach a fresh head in one line.DataPipeline(label_column=...)β emit{input_ids, attention_mask, labels}(scalar or-100-padded token labels).
Quantization-aware training¶
prepare_qat(model, QATConfig)β int8/int4 fake-quant (straight-through) on any model's linears; train with any trainer.export_quantized()(footprint report),int8_state_dict()(packed int8 + scales),quantize_tensor().
Evaluation & inference¶
compare_encoders()/evaluate_encoder()/print_comparison()/macro_f1()β head-to-head accuracy / macro-F1 table (each model tokenised with its own tokenizer).load_for_inference()(optional 4-bit/8-bit, optional skill-pack merge) andgenerate().
Growth¶
- Native merge β
run_merge()materialises the grown model with transformers + safetensors; no external merge tool required. detect_layer_prefix()β auto-detects the transformer block prefix, so growth works on Llama, BERT, GPT-2, and more.
Fixes¶
- Security β all trainers now load checkpoints with
torch.load(..., weights_only=True), so resuming from a checkpoint can never execute arbitrary pickled code. - The test suite now skips torch-dependent tests cleanly when torch is not installed, instead of failing at collection.
MLMTrainerno longer produces a NaN loss when a batch masks zero tokens.recipe.run()raises instead of silently falling back to a numpy stub when torch is absent, and refuses to train on synthetic random tokens.- Removed the
mergekitdependency (the native merge backend replaces it).
v0.1.0 β 2026-06-16¶
First public release of olaverse-foundry.
Trainers¶
TorchDistillTrainerβ single-GPU CE+KL distillation against one or many teachersCachedDistillTrainerβ multi-epoch distillation with on-diskLogitCache+accelerateDDP/FSDP supportEmbeddingDistillTrainerβ MSE/cosine loss on pooled sentence vectors for bi-encoder distillation
Production training features (all trainers)¶
- Mixed precision β
torch_dtype="bfloat16"/"float16"/"float32" - Gradient accumulation β
grad_accumulation_steps=N - LR scheduler β
"cosine"/"linear"/"constant"with linear warmup - Reproducible seed β
seed=42wires torch + numpy + random - Checkpoint save/resume β
save_checkpoint()/resume_from_checkpoint() - Auto-checkpoint β
save_every=N, save_dir=... - Eval loop β
eval_every=Nwith held-out dataset - W&B / TensorBoard logging β
log_backend="wandb"or"tensorboard" - OOM handling β CUDA OOM caught and re-raised with actionable suggestions
on_stepcallback for custom progress tracking
DataPipeline¶
- Unified dataset adapter for HF
Dataset/IterableDataset,list[str],list[dict],list[np.ndarray] - Modes:
"lm"(int arrays) and"embed"(input_ids + attention_mask dicts) - Reservoir shuffle buffer for streaming sources
len()for finite sources;TypeErrorfor streaming (passtotal_steps=to trainer)
Teachers¶
TeacherRegistryβ pool of teachers with relative weightsHFTeacherβ supportsmodel_type="causal_lm"andmodel_type="encoder"(for embedding teachers)ToyTeacher/ToyEmbeddingTeacherβ lightweight test stubsLogitCacheβ top-k logit storage with.npzserialisation
Model loading¶
load_model(ref, model_class=None)βmodel_classparameter for encoder vs causal LM
Skill packs¶
SkillPack/SkillRegistryβ detachable LoRA adapterssnap_on()β right-to-left key matching handles HF's deeply-nested state dict keys- PEFT format round-trip:
save_as_peft()/load_from_peft()/peft_config_dict()
Growth & fusion¶
plan_growth()/GrowthPlanβ SOLAR depth up-scalingupscale_layer_map()/layers_for_param_target()growth_plan_to_mergekit_yaml()/save_mergekit_config()/run_merge()MinEDAlignmentβ cross-tokenizer vocab alignment via edit distance- Fusion strategies:
min_ce,mean_ce
Recipes¶
FoundryRecipe/EmbedRecipeβ Pydantic-validated YAML recipe filesRecipe.load()β auto-detect recipe type
CLI¶
foundry doctorβ environment checkfoundry plan/foundry runβ causal LM recipesfoundry embedβ embedding recipesfoundry strategiesβ list fusion strategiesfoundry backendsβ backend summary
Backends¶
detect_backend()β torch, cuda, mps, accelerate, peft, safetensors, wandb, rapidfuzz
Optional extras¶
| Extra | What it installs |
|---|---|
[torch] |
torch, transformers, safetensors, accelerate |
[lego] |
peft |
[data] |
datasets |
[align] |
rapidfuzz |
[logging] |
wandb |
[all] |
all of the above |